Open19

Reactの新しいドキュメント(英語版)のチュートリアルをやってみる

さとのわさとのわ

React公式ドキュメント新版のチュートリアルをやります。
新旧ともに、お題は「Tic-Tac-Toe(三目並べ)」です。学生の頃、〇×ゲームと呼んでいた懐かしいものです。自分もノートの端に書いてやってました。

はじめに

  • ReactはUIを「コンポーネント」と呼ばれる部品に分解して整理するためのライブラリです。
  • 新版ドキュメントでは、すべての説明が、クラスではなく、Hooksを使って書かれています。
  • ここではドキュメントの翻訳ではなく、理解したことをまとめています。
さとのわさとのわ

CodeSandboxについて

新版チュートリアルは、CodeSandboxというブラウザ統合環境を使っています。旧版はcodepenでした。Sandboxというのは砂場という意味だそうで、作って壊してがやりやすい仮想環境なのだそうです。

CodeSandboxでは、プロジェクトのファイル構成を見ることができたり、複数のjsファイル、cssファイル、jsonファイルなどを開いては閉じて、ブラウザでの表示状況を確認することもできます。すごく使いやすい。優れもの。

Reactプロジェクトのファイル構成をみて、各ファイルの役割を把握する

今回のチュートリアルでは、最初から必須ファイルを整えてくれています。公式チュートリアルページから埋め込みコードの右上にある「Fork」(コピーの生成)ボタンを押して、CodeSandboxに移動します。

https://react.dev/learn/tutorial-tic-tac-toe

CodeSandbox画面左側にて、ファイル構成を確認することができます。

  • Public
    • index.html
  • App.js:Reactコンポーネントを宣言する
  • index.js:Reactをインポートして、rootの<div>をAppコンポーネントに置き換える
  • style.css:スタイルを定義する
  • package.json:パッケージ管理のためのjsonファイル。バージョン情報など記述する

今回はApp.jsを書き換えて、Reactコンポーネントをつくり上げていきます。

さとのわさとのわ

App.js

スターターコードをCodeSandboxのブラウザデモで表示すると、□に×が表示されているはずです。まずは、このコードを見てみましょう。

App.js
export default function Square() {
  return <button className="square">X</button>;
}

App.jsの1行目はSquare関数コンポーネントを宣言しています。(※クラスコンポーネントではありません。)javascriptキーワードのexportを付けると、ファイル外から定義した関数にアクセスできるようになります。defaultキーワードは、このファイルにおけるメインの関数であることを示すものです。

2行目はボタン要素を返しています。javascriptのreturnキーワードで、関数の返り値を指定しています。<buton>はJSX要素で、className="square"は、ボタン要素のプロパティ属性です。CSSのスタイルを指定しています。Xはボタン内の表示テキストで、</button>はJSXの閉じタグです。CSSの中身は、style.cssにて定義されています。

旧版のチュートリアルでは、最初からSquare, Board, Gameの3つのコンポーネントを用意していますが、新版ではSquareのみです。ここから、どのような考え方で、コンポーネントの必要性を判断していくのかを、順を追って辿っていくような形になります。

index.js

このファイルは、App.jsで作成したコンポーネントとWebブラウザの橋渡しをするものです。このチュートリアルでは編集しません。

index.js
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";

import App from "./App";

const root = createRoot(document.getElementById("root"));
root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

React本体。
React DOMのライブラリ。
コンポーネントのスタイル。
App.jsで作成したAppコンポーネント。

4つのピースを使って、最終的に出来上がった要素を、index.htmlに差し込んでいます。

さとのわさとのわ

盤面の構築

まずは、ゲームの盤面をつくるために、マス目を9つ並べます。

export default function Square() {
 return (
+    <>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
     <button className="square">X</button>
+    </>
 );
}

すると、以下のようになります。

組み替えましょう。cssファイルを見ると、board-rowというクラスで分けられそうなので、ちょいちょいとHTMLを書き換えます。

export default function Square() {
 return (
   <>
+      <div className="board-row">
       <button className="square">1</button>
       <button className="square">2</button>
       <button className="square">3</button>
+      </div>
+      <div className="board-row">
       <button className="square">4</button>
       <button className="square">5</button>
       <button className="square">6</button>
+      </div>
+      <div className="board-row">
       <button className="square">7</button>
       <button className="square">8</button>
       <button className="square">9</button>
+      </div>
   </>
 );
}

よしよし。この9つの箱が並んだ状態は、マス目ではなく、盤なので、SquareからBoardに名前を変更します。ついでに、それぞれの<button>のJSXをSquareコンポーネントにして、コードの重複を回避します。

+ function Square() {
+  return <button className="square">1</button>;
+ }

export default function Board() {
 return (
   <>
     <div className="board-row">
+        <Square />
+        <Square />
+        <Square />
     </div>
     <div className="board-row">
+        <Square />
+        <Square />
+        <Square />
     </div>
     <div className="board-row">
+        <Square />
+        <Square />
+        <Square />
     </div>
   </>
 );
}

ですよねー。全部1になってしまいました。

さとのわさとのわ

props属性でデータを渡す

それぞれの数字をvalueプロパティに入れて、親<Board />コンポーネントから下位の<Square />コンポーネントに落とし込みます。そして、<Square />コンポーネントでは、Boardから渡された値を関数の引数として受け取り、画面に表示させます。

+ function Square({ value }) {
+  return <button className="square">{value}</button>;
}

export default function Board() {
 return (
   <>
     <div className="board-row">
+       <Square value="1" />
+       <Square value="2" />
+       <Square value="3" />
     </div>
     <div className="board-row">
+       <Square value="4" />
+       <Square value="5" />
+       <Square value="6" />
     </div>
     <div className="board-row">
+       <Square value="7" />
+       <Square value="8" />
+       <Square value="9" />
     </div>
   </>
 );
}

ここまではできた。次はインタラクティブなコンポーネントの作成です。ここから本番ですね。

インタラクティブなコンポーネントの作成

Squareコンポーネントをクリックすると、Xにする関数handleClick()を定義します。Squareコンポーネントの中で宣言します。

function Square({ value }) {
+ function handleClick() {
+   console.log('clicked!');
+ }
 return (
   <button
     className="square"
+     onClick={handleClick}
   >
     {value}
   </button>
 );
}

これで、マス目をクリックすると、CodeSandboxのBrowserセクションの下にあるConsoleタブに「clicked!」というログが表示されるはずです。2回クリックすると、2回コンソールにログが表示されます。

次に、Squareコンポーネントがクリックされたことを「記憶」させて、Xを表示させます。状態を記憶するuseStateを使います。まず、React組み込みフックであるuseStateをimportして使用可能にしておきます。次に、Squareコンポーネントの冒頭で、state変数valueと値を更新するsetValue関数を宣言します。useState()で初期値を渡すことができます。今回のvalueの初期値はnullです。

+ import { useState } from 'react';

- function Square() {
+ const [value, setValue] = useState(null);

 function handleClick() {
+   setValue('X');
 }

 return (
   <button
     className="square"
     onClick={handleClick}
   >
     {value}
   </button>
 );
}

これでマス目をクリックすると、valueの値にXが格納されます。Squareコンポーネントは自分で状態変数を持っているため、親のBoardコンポーネントからデータを受け取る必要はなくなりました。Boardコンポーネントも書き換えましょう。

// ...
export default function Board() {
  return (
    <>
      <div className="board-row">
-        <Square />
-        <Square />
-        <Square />
      </div>
      <div className="board-row">
-        <Square />
-        <Square />
-        <Square />
      </div>
      <div className="board-row">
-        <Square />
-        <Square />
-        <Square />
      </div>
    </>
  );
}

これで、それぞれのSquareコンポーネントがクリックによってイベント発火し、表示がXに変わるようになりました。

さとのわさとのわ

ゲームを仕上げていく

ゲームを完成させるためには、交互に差すプレイヤーに応じて、”X”と”O”を配置し、勝敗を決めなければいけません。勝敗を決めるためには、9つのすべてのマス目の状態を把握する必要があります。これを、Boardコンポーネントで管理します。

複数の子コンポーネントからデータを収集したり、2つの子コンポーネントが互いにデータをやりとりしたりするには、その上位の親コンポーネントで共有する状態を宣言します。親コンポーネントは、状態をプロパティ属性として子どもに渡すことができます。これにより、子コンポーネントは互いに、そして親コンポーネントと同期した状態に保たれます。

Reactコンポーネントでは、親コンポーネントに状態を持ち込むのが一般的です。では、<Board />コンポーネントの初期値として、9つのマス目に対応する長さ9のnull配列をセット。<Board />管理のSquare変数の各値を、プロパティ属性で<Square />コンポーネントに落とし込みます。

import { useState } from 'react';

function Square({ value }) {
  return <button className="square">{value}</button>;
}

export default function Board() {
+  const [squares, setSquares] = useState(Array(9).fill(null));
  return (
    <>
      <div className="board-row">
+        <Square value={squares[0]} />
+        <Square value={squares[1]} />
+        <Square value={squares[2]} />
      </div>
      <div className="board-row">
+        <Square value={squares[3]} />
+        <Square value={squares[4]} />
+        <Square value={squares[5]} />
      </div>
      <div className="board-row">
+        <Square value={squares[6]} />
+        <Square value={squares[7]} />
+        <Square value={squares[8]} />
      </div>
    </>
  );
}

続いて、<Square />コンポーネントで、親から渡されたデータを受け取ります。当初、<Square />コンポーネント内でvalue状態変数を管理していましたが、親の<Board />コンポーネントが状態を管理することになったので、<Square />内のstate変数は削除します。

function Square({value}) {
  return <button className="square">{value}</button>;
}

次に、クリックした時に発火するイベントですね。当初は<Square />コンポーネントでhandleClick()という関数で制御していましたが、全部消してしまいました。今度はonSquareClick()という関数で、状態変数を書き換えます。同じクリックイベントなんですけど、名前を変えて作り直します。

親<Board />コンポーネントから子<Square />コンポーネントに関数を渡し、Squareマスがクリックされたら、関数を呼び出すようにします。

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

次に、onSquareClick プロパティを Board コンポーネントの関数に接続し、handleClickと名付けます。onSquareClick と handleClickを接続するために、最初の<Square />コンポーネントの onSquareClick プロパティに関数を渡します。

handleClick関数の中身を書いていきましょう。

export default function Board() {
 const [squares, setSquares] = useState(Array(9).fill(null));

 function handleClick(){
+   const nextSquares = squares.slice();
+   nextSquares[0] = "X";
+   setSquares(nextSquares);
 }

 return (
      <>
        <div className="board-row">
//以下省略

slice()は配列を複製してくれます。コピーした新しい配列nextSquaresの0番目に”X”を入れる。そして、状態変数squaresにnextSquaresの値をセットすることで、Reactに状態が変化したことを知らせています。これが、<Board />と<Square />コンポーネントを再レンダリングするトリガーとなります。

次に、[0]だけでなく、クリックされた場所に応じたインデックスのデータを置き換える処置を施しましょう。

export default function Board() {
 const [squares, setSquares] = useState(Array(9).fill(null));

+ function handleClick(i){
     const nextSquares = squares.slice();
+   nextSquares[i] = "X";
     setSquares(nextSquares);
 }

 return (
      <>
        <div className="board-row">
+          <Square value={squares[0]} onSquareClick={handleClick(0)} />
//以下省略

こうすると、無限ループのエラーがでてきました。

なるほど。『<Board />をレンダリングする ⇒ handleClick(0)を呼び出す ⇒ handleClick(0)でsetSquaresにより再レンダリングのトリガー発火 ⇒ <Board />をレンダリングする (最初に戻る)』ということですね。

一旦、ここまで。左上のマス目をクリックしたときの、流れをおさらいします。

検証用の余計なコードも混ざっているので、参考程度にご覧ください。

さとのわさとのわ

なぜ不変性が重要なのか

handleClick関数の中身をみると、状態変数squaresを上書きせずに、slice()でコピーした新しい配列nextSquaresをわざわざ用意しています。このように、元の配列を書き換えず、コピーをベースに変更を加えていくことを、不変性(イミュータブル)を持たせるなどと言うそうです。

この利点は2つ。

  • タイムトラベル・履歴のような複雑な機能を簡単に実装することができる
  • 再レンダリングの際、前後を比較しやすい
const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// `squares` は変化なし, `nextSquares`は最初の要素に'X'が入っている
さとのわさとのわ

ゲームの打ち手を交代する

初手はデフォルトで "X "になるように設定しておきます。<Board />コンポーネントにもう一つ状態を追加することで、これを記録しておきましょう。xIsNextという状態変数を新しく用意します。これはブール値で、クリックにより反転させることにします。

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
+ const [xIsNext, setXIsNext] = useState(true);

 function handleClick(i){
   const nextSquares = squares.slice();
+   if(xIsNext){
+     nextSquares[i] = "X";
+   }else{
+     nextSquares[i] = "O";
+   }

   setSquares(nextSquares);
+   setXIsNext(!xIsNext);
 }

このままだと、一度差したマス目が上書きされてしまうので、マスが埋まっていたら(nullでなかったら)、ボードを更新する前にreturnするように追記します。

function handleClick(i) {
 if (squares[i]) {
   return;
 }
//...

ここまでのコードを確認します。

import { useState } from 'react';

function Square({value, onSquareClick}) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

export default function Board() {
  const [squares, setSquares] = useState(Array(9).fill(null));
  const [xIsNext, setXIsNext] = useState(true);

  function handleClick(i) {
    if (squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = "X";
    } else {
      nextSquares[i] = "O";
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  return (
    <>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}
さとのわさとのわ

勝敗を決める!

全ての勝ちパターンを調べて、該当しているかどうかを調べる関数をバチバチ作っていきます。

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]){
     return square[a];
   }
   return null;
 }
}

この関数calculateWinnerを、handleClick関数でクリックの度に呼び出します。すでに勝者がいる場合は、ボードを更新する前にreturnします。また、ゲームの進行状況を知らせるために、statusを表示します。ゲームが終わった時は勝者を表示し、ゲーム進行時は次のプレイヤーを表示します。

function handleClick(i) {
+    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

+  const winner = calculateWinner(squares);
+  let status;
+  if (winner) {
+    status = 'Winner: ' + winner;
+  } else {
+    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
+  }

  return (
    <>
+      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
//...
さとのわさとのわ

タイムトラベル機能

過去の打ち手を保存し、遡ることができるようにします。手を打つたびに、slice()を使ってSquaresを複製しているので、それらをhistoryという別の配列に格納します。

過去の手のリストを表示するために、Gameという新しいトップレベルコンポーネントを書きます。そこに、ゲーム全体の履歴を含む履歴状態を配置します。

「たくさんある状態をひとまとめに管理する」と、コンポーネントの構成を考えられそうです。これまで<Square />と<Board />コンポーネントでやってきたことを、<Board />と<Game />コンポーネントでやるようなものだと思うので、まずは一度、自力で挑戦してみます。

export default function Game() {
 return(
   <Board />
 );
}

全然ダメでした。やっぱりチュートリアルを見ながら書いていきます。

さとのわさとのわ

<Game />コンポーネントでは、ゲーム盤面とゲームの履歴を配置します。最上位のメインコンポーネントとなるので、export defaultキーワードも付けておきます。<Board />コンポーネントのexport defaultキーワードは消しておきます。

export default function Game() {
 return(
   //ゲーム盤面
   <div className="game">
     <div className="game-board">
     <Board />
     </div>
     <div className="game-info">
     <ol>{/* < TODO /> */}</ol>
     </div>     
   </div>
 );
}

次に、Game コンポーネントに、次のプレーヤーと指した手の履歴を記憶するために、いくつかの状態を追加します。

export default function Game() {
+ const [xIsNext, setXIsNext] = useState(true);
+ const [history, setHistory] = useState([Array(9).fill(null)]);
+ const currentSquares = history[history.length - 1];
 // ...

次に、ゲームを更新するためのhandlePlay関数をGameコンポーネントに作成します。

export default function Game() {
 const [xIsNext, setXIsNext] = useState(true);
 const [history, setHistory] = useState([Array(9).fill(null)]);
 const currentSquares = history[history.length - 1];

+ function handlePlay(nextSquares) {
+   // TODO
+ }

 return (
   <div className="game">
     <div className="game-board">
+       <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
       //...
 )
}

<Board />コンポーネントは、3つのプロパティ属性xIsNext={xIsNext}squares={currentSquares}onPlay={handlePlay}を受け取ります。squaresは親コンポーネントから与えられることになったので、これまで<Board />の冒頭で用意していた変数squaresは削除します。

+ 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";
   }
+   onPlay(nextSquares);
 }
 //...
}

修正前は、<Board />コンポーネント内で、handleClick関数がsetSquaressetXIsNextを呼び出し、「クリックしたら盤面の状態squaresと打ち手xIsNextを更新」していました。これを、新しいonPlay関数の呼び出しに置き換えることで、ユーザーがマス目をクリックしたときに<Game />コンポーネントが<Board />を更新するようにします。

さとのわさとのわ

<Board />コンポーネントのonPlayプロパティに、<Game />コンポーネントで宣言しているhandlePlay関数をのせます。handlePlay関数では、ゲームの更新をするsetHistoryとゲームの打ち手を交代するsetXIsNextを記述しています。

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

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';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = 'Winner: ' + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

+  function handlePlay(nextSquares) {
+    setHistory([...history, nextSquares]);
+    setXIsNext(!xIsNext);
+  }

  return (
    <div className="game">
      <div className="game-board">
+        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{/*TODO*/}</ol>
      </div>
    </div>
  );
}

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]) {
      return squares[a];
    }
  }
  return null;
}

setHistory関数で使用した ...historyは「配列history に含まれるすべての項目を列挙する」という意味で、スプレッド構文というそうです。データストアに新しいアイテムを追加したり、保存されているすべてのアイテムに新しく追加されたアイテムを加えて表示したりする場合に使用されます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

さとのわさとのわ

過去の手札の表示

historyで履歴を記録しておけるようになったので、過去の手札をリスト表示する準備が整いました。このhistoryをjavascriptのarray.map()メソッドを使って、ボタンのReact要素へと変換します。

array.map()メソッドは、与えられた関数を配列のすべての要素に対して呼び出し、その結果からなる新しい配列を生成してくれます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const currentSquares = history[history.length - 1];

  function handlePlay(nextSquares) {
    setHistory([...history, nextSquares]);
    setXIsNext(!xIsNext);
  }

  function jumpTo(nextMove) {
    // TODO
  }

+  const moves = history.map((squares, move) => {
+    let description;
+    if (move > 0) {
+      description = 'Go to move #' + move;
+    } else {
+      description = 'Go to game start';
+    }
+    return (
+      <li>
+        <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">
+        <ol>{moves}</ol>
      </div>
    </div>
  );
}

配列historyの各要素(squares)に対応して、<li>ボタン要素を返し、新しい配列movesに格納しています。上記のコードでは、以下のようなエラーが出ます。このエラーは、次のセクションで修正します。

警告: 配列またはイテレータの各子要素は、一意の "key" プロパティを持つ必要があります。Game` の render メソッドをチェックしてください。

さとのわさとのわ

リストのキーを設定する

Reactに各コンポーネントのユニークな識別IDキーを設定することで、Reactが再レンダリングの際、リストの項目を追加したり、削除したり、並べ替えたり、更新したりできるようになります。

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>
    );
  });

moveは、history.map() のインデックス番号が入っています。

さとのわさとのわ

タイムトラベルの実装

履歴ボタンをクリックしたときにやることを実装します。jumpTo()の中身です。
まず、今、何手目か知りたい。状態変数currentMoveを用意します。初期値は0です。

export default function Game() {
  const [xIsNext, setXIsNext] = useState(true);
  const [history, setHistory] = useState([Array(9).fill(null)]);
+ const [currentMove, setCurrentMove] = useState(0);
  const currentSquares = history[history.length - 1];
 //...
}

currentMoveは、jumpTo()のタイミングで更新されます。

export default function Game() {
 // ...
  function jumpTo(nextMove) {
+   setCurrentMove(nextMove);
    setXIsNext(nextMove % 2 === 0);
 }
 //...
}

また、通常プレイ時も、currentMoveを1進める処理が必要です。さらに、履歴もcurrentMoveに応じて変化するように、書き換えます。

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

slice()は、配列の一部をコピーして新しい配列を返します。0番目の要素から、currentMove+1までの履歴に、最新の差し手を追加しています。また、履歴配列の長さから、次が何手目かを算出するようにしています。

さとのわさとのわ

最後の仕上げ

コードをよく見ると、currentMove が偶数のときは xIsNext === true、currentMove が奇数のときは xIsNext === false であることに気づくかもしれません。この2つを状態変数xIsNextで保存する必要はないので、CurrentMoveに基づいて計算するようにします。

import { useState } from 'react';

function Square({ value, onSquareClick }) {
  return (
    <button className="square" onClick={onSquareClick}>
      {value}
    </button>
  );
}

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';
    }
    onPlay(nextSquares);
  }

さとのわさとのわ

完成

以上で、〇×ゲームが完成しました!

  • 三目並べができる
  • プレイヤーがゲームに勝利したことを示す
  • ゲームの進行に伴い、ゲームの履歴を保存する
  • 対局履歴の確認や、過去の対局盤の確認ができるようにする

https://codesandbox.io/s/reacttiyutoriaru-e4l8qs?file=/App.js

上のCodeSandboxは、実際に私が作成したものです。新公式ドキュメントは、英語ですが非常に分かりやすいです。自然と浮かぶ疑問を丁寧に解消し、思考ステップを辿るように実践できます。

旧版ではクラスコンポーネント、新版では関数コンポーネントです。どちらが優れているとかではなく、情報が偏るのはよくないと思っています。少しでも参考になれば幸いです。

さとのわさとのわ

追加課題

はい。やります。三目並べゲームのブラッシュアップ例として、5つ挙げられています。難しい順です。

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

ここまで取り組んできたチュートリアルの延長線上で取り組むので、関数コンポーネントを使います。また、javascript力は低いので、強引だったり冗長だったりするかもしれません。ひとつの回答例としてご覧いただければと思います。

zennのスクラップは目次が出ないんですね。目次は欲しいので追加課題は記事の方に頑張ってまとめてみます。

https://zenn.dev/satonowa275/articles/7f74a9152b575e