💡

React新ドキュメント(英語版)のチュートリアル追加課題

2023/03/17に公開

React公式ドキュメント新版のチュートリアル追加課題

新旧ともに、チュートリアルのお題は「Tic-Tac-Toe(三目並べ)」です。
https://react.dev/learn/tutorial-tic-tac-toe

はじめに

React初学者です。調査・考察しながら書いています。間違いは指摘いただけると幸甚です。
ReactはUIを「コンポーネント」と呼ばれる部品に分解して整理するためのライブラリです。
新版ドキュメントでは、すべての説明が、クラスではなく、Hooksを使って書かれています。

チュートリアル挑戦の過程はスクラップに残しています。
https://zenn.dev/satonowa275/scraps/c89bed789ab99e

追加課題5つ(難しい順)

  1. 現在の手に対してのみ、ボタンの代わりに「You are at move #...」と表示する。
  2. 碁盤をハードコードするのではなく、2つのループでマスを作るように書き換える。
  3. トグルボタンを追加して、手を昇順または降順に並べ替えられるようにする
  4. 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する (誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する)。
  5. 着手履歴のリストに、各手順の位置を(col, row)の形式で表示する。

5⇒4⇒3⇒2⇒1の順にやります。
すべて、公式チュートリアル最後のCodeSandboxをForkした状態から始めます。
https://react.dev/learn/tutorial-tic-tac-toe

5. 着手履歴のリストに、各手順の位置を(col, row)の形式で表示する

リストに書き入れたいので、リストJSX構文の箇所に、各手順の位置を表す変数positionを追記します。まず、仮の値1行2列として、初手とそれ以外の場合分けをしておきました。

export default function Game() {
//...
  const moves = history.map((squares, move) => {
    let description;
+    let position;
+    let col = 1;
+    let row = 2;
    if (move > 0) {
      description = 'Go to move #' + move;
+      position = '(' + col + ',' + row + ')';
    } else {
      description = 'Go to game start';
+      position = '';
    }
    return (
      <li key={move}>
+        <button onClick={() => jumpTo(move)}>{description}{position}</button>
      </li>
    );
  });
//...

よし。いま、1,2と表示されている箇所を変数にしたいです。いろいろ試行錯誤した結果、状態変数historyに、squaresとcol/rowを一緒に格納することになりました。

先に、各指し手の位置の求め方を考えます。左上が(1,1)、右上が(3,1)、左下が(1,3)、右下が(3,3)です。

これは、何手目かを表すmoveからは計算することはできないですね。<Square />コンポーネントの番号{i}を使って算出します。列は3で割った余りに1を足す。行は3で割った商の値で良さそうです。整数に丸めるのを忘れずに。

function Board({ xIsNext, squares, onPlay }) {
 function handleClick(i) {
   if (calculateWinner(squares) || squares[i]) {
     return;
   }
   const nextSquares = squares.slice();
   if (xIsNext) {
     nextSquares[i] = 'X';
   } else {
     nextSquares[i] = 'O';
   }
+   let col = i % 3 + 1;
+   let row = Math.floor(i / 3) + 1;
+   const nextColRow = [col, row];
+   onPlay(nextSquares, nextColRow);
 }

次に、状態変数historyを書き換えます。初期値はnull。更新時にsquares配列とcolRow配列を持たせます。

export default function Game() {
 //historyの初期値に、打ち手の位置をセットすると、{moves}の出力で、ゲームスタート時に一手目のボタンが表示されてしまう
+ const [history, setHistory] = useState([[Array(9).fill(null)]]);
 const [currentMove, setCurrentMove] = useState(0);
 const xIsNext = currentMove % 2 === 0;
+ const currentSquares = history[currentMove][0];

 function handlePlay(nextSquares, nextColRow) {
+   const nextHistory = [...history.slice(0, currentMove + 1), [nextSquares, nextColRow]];
   setHistory(nextHistory);
   setCurrentMove(nextHistory.length - 1);
 }
 
//...
//map()は配列historyのすべての要素に対して、関数を実行した結果を新しい配列として返す
//配列historyは多次元配列なので注意
+ const moves = history.map((position, move) => {
   let description;
   if (move > 0) {
+     description = 'Go to move #' + move +  '(' + position[1][0] + ',' + position[1][1] + ')';           
   } else {
     description = 'Go to game start';
   }
   return (
     <li key={move}>
       <button onClick={() => jumpTo(move)}>{description}</button>
     </li>
   );
 });

できました!一番時間かかったのが、多次元配列のmap()メソッドの制御なので、Reactではなくjavascriptのスキル不足で詰みました。なんとかクリアです。
https://codesandbox.io/s/reacttiyutoriaruzhui-jia-ke-ti-5-dohr04?file=/App.js

4. 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する (誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する)

誰かが勝ったときなので、calculateWinner()で勝利判定が出たときに、強引に色も変えてしまえばよさそうです。

for (let i = 0; i < lines.length; i++) {
   const [a, b, c] = lines[i];
   if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
+     document.getElementsByClassName("square")[a].style.backgroundColor = 'orange';
+     document.getElementsByClassName("square")[b].style.backgroundColor = 'orange';
+     document.getElementsByClassName("square")[c].style.backgroundColor = 'orange';
     return squares[a];
   }
 }
 return null;

あ、ゲームスタートを押すと、背景色がハイライトのままです。こっちが本題か!

では、<Square />コンポーネントを更新のたびに、透明にリセットするような指示をインラインで書き入れてみます。

function Square({ value, onSquareClick }) {
 return (
+   <button className="square" onClick={onSquareClick} style={{background:'transparent'}}>
     {value}
   </button>
 );
}

あれ。Squareコンポーネントの再レンダリングをしてくれるのかと思ったんだけど、当てが外れました。

では、勝利判定で勝者がいない場合に、すべてのマスを透明にする処置を施します。

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
     document.getElementsByClassName("square")[a].style.backgroundColor = 'orange';
     document.getElementsByClassName("square")[b].style.backgroundColor = 'orange';
     document.getElementsByClassName("square")[c].style.backgroundColor = 'orange';
      return squares[a];
    }
+    for (let j = 0; j < 9; j++) {
+      const e = document.getElementsByClassName("square")[j];
+      e.style.backgroundColor = "transparent";
*    }
  }
  return null;
}

よし。…と一瞬思いましたが、ブラウザをリセットするとエラーが出ました。

TypeError
Cannot read properties of undefined (reading 'style')

多分最初のレンダリングのときに、背景を透明にしたい相手がいませんよって言われる。なので、透明化する部分のコードをまるごとjumpTo()の中に移動させました。

  function jumpTo(nextMove) {
    //タイムトラベルボタンを押したら、背景色ハイライトは全部リセットする
    for (let j = 0; j < 9; j++) {
      const e = document.getElementsByClassName("square")[j];
      e.style.backgroundColor = "transparent";
    }
    setCurrentMove(nextMove);
  }

バッチリです。

次は引き分けの表示です。Winnerのところに「引き分けです!」と表示すれば良さそうです。引き分けの条件は、Winnerがいないことと、moveが10手目であること。

Winnerは<Board />コンポーネントにあるので、moveは使いにくそうです。今回はmoveではなく、squaresのマス目が全部埋まっているかどうかで判断することにしました。

function Board({ xIsNext, squares, onPlay }) {
//...
  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = "Winner: " + winner;
+  } else if (squares.includes(null)) {
    status = "Next player: " + (xIsNext ? "X" : "O");
+  } else {
+    status = "引き分けです!";
  }
  return (
    <>
      <div className="status">{status}</div>
      //...

できました!
https://codesandbox.io/s/reacttiyutoriaruzhui-jia-ke-ti-4-jx3pyc?file=/App.js

3. トグルボタンを追加して、手を昇順または降順に並べ替えられるようにする


こんなイメージでしょうか。ボタンを押すと、{moves}リストが降順に並べ変えられて、ボタンの表記が「昇順にする」となれば良さそうです。

ゲーム盤の状態history、何手目かという状態currentMoveとは別の、独立した状態変数sortToggleを新しく設置してみることにします。履歴リスト{moves}は、JSXで記述した<li>要素を、json形式のオブジェクトにして配列に格納したものです。従って、{moves}をreverse()で反転させて、setSortToggleで状態変数を更新すれば再レンダリングするのでは!

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
+  const [sortToggle, setSortToggle] = useState(true);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

//...
+ function sortReverse(movesArray) {
+   movesArray.reverse();
+   setSortToggle(!sortToggle);
+ }
//...

return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
+      <button onClick={() => sortReverse(moves)}>降順にする</button>
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

しませんでした。コンソールに出していろいろ確認しましたが、Toggleも配列の反転もできているようです。では、再レンダリングの制御の問題ってことですね。公式ドキュメントを読んでみます。

公式ドキュメント Render and Commit

https://react.dev/learn/render-and-commit

訳)React(ウェイター)がブラウザクリックみたいなもので注文を受けて、その注文をもとにコンポーネント(料理人)が調理して、React(ウェイター)が注文を提供する。

大事なところだと思うので、丁寧に読んでいきます。レンダリングは3つのステップで考えられるそうです。

  1. Reactがレンダリングを始めるのは、以下の2つのケース。
  • 初期レンダリング:createRootを呼び出し、render()メソッドでコンポーネントを呼び出す
  • コンポーネント(またはその上位)の状態が更新された:set関数で状態を更新することで、レンダリングを開始することができる
  1. コンポーネントのレンダリング
    レンダリングが始まると、Reactはコンポーネントを呼び出し、画面に表示する内容を決めます。「レンダリング」とは、Reactがコンポーネントを呼び出すことです。
    初期レンダリングの場合は、ルートコンポーネントを呼び出す。それ以降のレンダリングでは、レンダリングのトリガーとなった状態更新の関数コンポーネントを呼び出す。ネストしたコンポーネントがなくなるまで再帰的に処理が続きます。
  1. DOMに変更をあてはめる
    初期レンダリングのときは、appendChild() DOM APIを使って、すべてのDOMを画面上に配置します。それ以降のレンダリングでは、必要最小限の操作を適用して、DOMを最新のレンダリング出力と一致させます。ReactがDOMを変更するのは、レンダリング間で差がある場合だけです。

公式ドキュメントを読むと、setSortToggleで状態変数を変更したのですが、この状態変数を反映させるコンポーネントがない。新しくなった状態を受け取るところがないことが問題のようです。
⇒ないなら作ればいいですね。<ToggleBtn />と<MovesList />。こんな感じでしょうか。

return (
   <div className="game">
     <div className="game-board">
       <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
     </div>
     <div className="game-info">
+       <ToggleBtn />
+       <ol><MovesList /></ol>
     </div>
   </div>	

一度、コンポーネントの関係を整理してみます。

setSortToggleで再レンダリングさせようとしたら、sortToggle変数を使っているコンポーネントしか変更されないんだと思います。それと、今気づきましたが、{moves}は配列historyだけで作ることができるので、あとは昇順降順を判断するためのsortToggleを持ってくればあっさりコンポーネント化できそうです。

最初からやり直してみます。

//<ToggleBtn />コンポーネント 
function ToggleBtn({ sortToggle, onToggleBtnClick }) {
 let sortBtnText;
 if (sortToggle) {
   sortBtnText = "降順にする";
 } else {
   sortBtnText = "昇順にする";
 }
 return (
   <button onClick={() => onToggleBtnClick({ sortToggle })}>
     {sortBtnText}
   </button>
 );
}

//<MovesList />コンポーネント 
function MovesList({ history, toggle, jumpTo }) {
 const moves = history.map((squares, move) => {
   let description;
   if (move > 0) {
     description = "Go to move #" + move;
   } else {
     description = "Go to game start";
   }
   return (
     <li key={move}>
       <button onClick={() => jumpTo(move)}>{description}</button>
     </li>
   );
 });

 var array;
+ if (!toggle) {
+   //reverse()を使ったら戻らなくなってしまったのでNG
+   const reversedArr = moves.map((_, i, a) => a[a.length - 1 - i]);
+   array = reversedArr;
+ } else {
+   array = moves;
+ }
 return array;
}

export default function Game() {
 const [history, setHistory] = useState([Array(9).fill(null)]);
 const [currentMove, setCurrentMove] = useState(0);
+ const [sortToggle, setSortToggle] = useState(true);
 const xIsNext = currentMove % 2 === 0;
 const currentSquares = history[currentMove];

 function handlePlay(nextSquares) {
   const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
   setHistory(nextHistory);
   setCurrentMove(nextHistory.length - 1);
 }

 function jumpTo(nextMove) {
   setCurrentMove(nextMove);
 }

 //toggleボタンを押す
+ function handleToggle(nextToggle) {
+   //関数型のstate更新:https://qiita.com/jonakp/items/7d768bc680b8a7a5e84f
+   setSortToggle((nextToggle) => !nextToggle);
+ }

 // const moves = history.map((squares, move) => {
 //   let description;
 //   if (move > 0) {
 //     description = 'Go to move #' + move;
 //   } else {
 //     description = 'Go to game start';
 //   }
 //   return (
 //     <li key={move}>
 //       <button onClick={() => jumpTo(move)}>{description}</button>
 //     </li>
 //   );
 // });

 return (
   <div className="game">
     <div className="game-board">
       <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
     </div>
     <div className="game-info">
+       <ToggleBtn sortToggle={sortToggle} onToggleBtnClick={handleToggle} />
       <ol>
+         <MovesList history={history} toggle={sortToggle} jumpTo={jumpTo} />
       </ol>
     </div>
   </div>
 );
}

できました!!!
https://codesandbox.io/s/3-reacttiyutoriaruzhui-jia-ke-ti-lrf2kx?file=/App.js

詰んだところがたくさんありましたが、なんとかクリアしました。

  • 配列を逆順にするjavascriptのreverse()を使うと、元の配列が壊されてしまうので、map()関数で逆順にしました
  • トグルのset関数。最初は以下のように書いていました
setSortToggle(!nextToggle);

一回のクリックでtrueからfalseに変わったあと、制御できなくなってしまいました。ググったら解説が出てきました。良い書き方はこちら。

setSortToggle((nextToggle) => !nextToggle);

読んでもわからなかったので積読にしてます。
https://qiita.com/jonakp/items/7d768bc680b8a7a5e84f

今までの課題よりも、Reactにきちんと向き合った感じがあってよかったです!

2. 碁盤をハードコードするのではなく、2つのループでマスを作るように書き換える。


これをループに変えたいのですが、returnの中でループを使えないようです。returnの前に移動して、変数boardPlateみたいなものに一度格納して、一気に出力にもっていく作戦でいきます。{moves}で同じようなことをやったので、できそうな気がしてきました。

ループを作っていきます。

あー、なるほど。HTMLテキスト列がブラウザ画面に表示されてしまいました。こういう課題だったわけですね。わかってきました。

コンポーネントをどうやって連結させればよいか分からない。JSXで記述しているから一見文字列みたいに見えるけど、中身はオブジェクトなんですよね。{moves}の時は、map()関数を使って新しい配列を作っていました。

同じようにやるなら、まずはベースとなる連番配列をつくって、map()で加工していきます。
※参考
https://qiita.com/suin/items/1b39ce57dd660f12f34b

  const boardPlate = [...Array(3)].map((_, i) => {
    let threeSquare = [...Array(3)].map((_, j) => {
      let n = 3 * i + j;
      return (
        <Square value={n} onSquareClick={() => handleClick(n)} />
      );
    });
    return (
      <div className="board-row">
        {threeSquare}
      </div>
    );
  });


3×3で、それぞれの値も良い感じに入りました。

配列にしたときは、keyが必要ですよと怒られました。チュートリアルの本課題でもやったやつですね。Boardのrender()を確認してくださいと書いてあります。二つ配列を作っているので、どちらも制御できるように、それぞれkeyをつけておきました。

  const boardPlate = [...Array(3)].map((_, i) => {
    let threeSquare = [...Array(3)].map((_, j) => {
      let n = 3 * i + j;
      return (
        <Square
          key={n}
          value={squares[n]}
          onSquareClick={() => handleClick(n)}
        />
      );
    });
    return (
      <div key={i} className="board-row">
        {threeSquare}
      </div>
    );
  });

  return (
    <>
      <div className="status">{status}</div>
      {boardPlate}
    </>
  );
}

完成です!
https://codesandbox.io/s/2-reacttiyutoriaruzhui-jia-ke-ti-nu74j2?file=/App.js

ちなみに、ループといったときにmap()を使うのが正しかったかどうか分かりません。forループでもできるのでしょうか?これは保留です。

1. 現在の手に対してのみ、ボタンの代わりに「You are at move #...」と表示する


たとえば上の例だと「Go to move #3」ではなく「You are at move #3」となる感じですね。確かに現在の手の時に、このボタンは押しても何も変化しないので意味がない状態です。


一度、「Go to move #2」ボタンを押すと手が戻ります。この時、「Go to move #3」はボタンであってほしいし、「Go to move #2」は「You are at move #2」となってほしいです。

という制御を作りましょうという課題です。OKOK。

{moves}は<li>オブジェクトが入った配列です。追加課題2でもmap()関数を使ってJSXに差し込む配列を用意しましたが、同じようなことが必要です。それと、ボタンをクリックすることで、ゲーム盤面だけでなく、リストそのものを再レンダリングする必要があるので、{moves}をコンポーネントにします(これは追加課題3でやりました)。

function MovesList({ history, currentMove, jumpTo }) {
  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      if (move === currentMove) {
        description = "You are at move #" + move;
      } else {
        description = "Go to move #" + move;
      }
    } else {
      description = "Go to game start";
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });
  return moves;
}

Gameコンポーネントの出力部分は次のような感じです。

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>
          <MovesList
            history={history}
            currentMove={currentMove}
            jumpTo={jumpTo}
          />
        </ol>
      </div>
    </div>
  );

復習みたいなものなので、あっさりできました。ここからが本番ですかね。<button>と<div>の出し分けをしたいです。リストをdivにしたい。divにすると、下のような文字列がリスト表示されます。

それぞれコンソールで配列の中身を見てみました。
・buttonのとき

・divのとき

要するに、このオブジェクトのchildren{type: button}とchildren{type: div}を、currentMoveの値によって、出し分けしたい。こういうことできますか? >>chatGPT先生?

すごい(語彙力)。よくあるテクニックだそうです。TagName変数をつくって、JSXに埋め込むことができるみたいです。これを真似して修正してみます。

できました!

履歴ボタン押すと変わります!すごい!
https://codesandbox.io/s/1-reacttiyutoriaruzhui-jia-ke-ti-zsudb3?file=/App.js

TagNameみたいな感じで変数を埋め込めるなら、もっといろいろなことができるのでは?
などとワクワクしています。すごい!React!

まとめ

振り返ると、難易度は確かに1>2>3>4>5の順番だったを思います。特に、最後に取り組んだ1の課題は、2と3の課題を応用して乗り越えることができました。いきなり1からやってたら一人でクリアできなかったと思います。

紆余曲折の過程を少し載せているので、冗長な記事になってしまいましたが、誰かの助けになれば幸いです。

Discussion