このチュートリアルでは、小さな三目並べゲームを作成します。このチュートリアルでは、Reactの既存の知識は前提としていません。チュートリアルで学ぶテクニックは、あらゆるReactアプリを構築する上で基本的なものであり、それを完全に理解することで、Reactについて深く理解することができます。
このチュートリアルはいくつかのセクションに分かれています
- チュートリアルのセットアップでは、チュートリアルに従うための開始点を提供します。
- 概要では、Reactの基礎であるコンポーネント、props、および状態について学びます。
- ゲームの完成では、React開発における最も一般的なテクニックを学びます。
- タイムトラベルの追加では、Reactの独自性である強みへのより深い洞察が得られます。
何を構築しますか?
このチュートリアルでは、Reactを使ったインタラクティブな三目並べゲームを作成します。
完成した時の見た目はここで見ることができます
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 [history, setHistory] = useState([Array(9).fill(null)]); const [currentMove, setCurrentMove] = useState(0); 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); } 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"> <ol>{moves}</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; }
まだコードが理解できない場合や、コードの構文に慣れていない場合でも、心配はいりません!このチュートリアルの目標は、Reactとその構文を理解するのを助けることです。
チュートリアルを続ける前に、上記の三目並べゲームを試してみることをお勧めします。気づく機能の1つは、ゲームボードの右側に番号付きのリストがあることです。このリストは、ゲームで発生したすべての動きの履歴を提供し、ゲームの進行に合わせて更新されます。
完成した三目並べゲームで遊んだら、スクロールを続けてください。このチュートリアルでは、よりシンプルなテンプレートから始めます。次のステップは、ゲームの構築を開始できるようにセットアップすることです。
チュートリアルのセットアップ
以下のライブコードエディターで、右上のフォークをクリックして、CodeSandboxのウェブサイトを使用して新しいタブでエディターを開きます。CodeSandboxを使用すると、ブラウザでコードを記述し、作成したアプリがユーザーにどのように表示されるかをプレビューできます。新しいタブには、空の正方形とこのチュートリアルのスターターコードが表示されます。
export default function Square() { return <button className="square">X</button>; }
概要
これで準備が整いましたので、Reactの概要を見ていきましょう!
スターターコードの調査
CodeSandboxには、主に3つのセクションがあります。
- Filesセクションには、
App.js、index.js、styles.cssのようなファイルと、publicというフォルダがあります。 - コードエディターでは、選択したファイルのソースコードが表示されます。
- ブラウザセクションでは、記述したコードがどのように表示されるかを確認できます。
FilesセクションでApp.jsファイルが選択されているはずです。コードエディター内のそのファイルの内容は、次のようになっているはずです。
export default function Square() {
return <button className="square">X</button>;
}ブラウザセクションには、次のようなX印の付いた正方形が表示されるはずです。
それでは、スターターコード内のファイルを見ていきましょう。
App.js
App.js内のコードは、コンポーネントを作成します。Reactでは、コンポーネントは、ユーザーインターフェースの一部を表す再利用可能なコードの断片です。コンポーネントは、アプリケーションのUI要素のレンダリング、管理、および更新に使用されます。コンポーネントを一行ずつ見て、何が起こっているかを確認しましょう。
export default function Square() {
return <button className="square">X</button>;
}最初の行では、Squareという関数を定義しています。exportというJavaScriptキーワードは、この関数をこのファイルの外部からアクセスできるようにします。defaultキーワードは、コードを使用している他のファイルに対して、これがファイル内のメイン関数であることを伝えます。
export default function Square() {
return <button className="square">X</button>;
}2行目はボタンを返します。returnというJavaScriptキーワードは、後に続くものが関数の呼び出し元に値として返されることを意味します。<button>はJSX要素です。JSX要素は、JavaScriptコードと表示したいHTMLタグの組み合わせであり、表示したいものを記述します。className="square"は、CSSにボタンのスタイルを設定する方法を伝えるボタンのプロパティまたはpropです。Xはボタンの中に表示されるテキストで、</button>はJSX要素を閉じて、後に続くコンテンツをボタンの内側に配置しないことを示します。
styles.css
CodeSandboxのFilesセクションで、styles.cssというラベルの付いたファイルをクリックしてください。このファイルは、Reactアプリのスタイルを定義します。最初の2つのCSSセレクター(*およびbody)はアプリの大部分のスタイルを定義し、.squareセレクターは、classNameプロパティがsquareに設定されているコンポーネントのスタイルを定義します。コードでは、それはApp.jsファイルのSquareコンポーネントのボタンに一致します。
index.js
CodeSandboxのFilesセクションで、index.jsというラベルの付いたファイルをクリックしてください。このチュートリアルではこのファイルを編集することはありませんが、これはApp.jsファイルで作成したコンポーネントとWebブラウザとの間の架け橋です。
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';1~5行目は必要なものをすべてまとめています。
- React
- Webブラウザと通信するためのReactのライブラリ(React DOM)
- コンポーネントのスタイル
App.jsで作成したコンポーネント。
ファイルの残りの部分では、すべての部分をまとめ、最終的な製品をpublicフォルダのindex.htmlに注入します。
ボードの構築
App.jsに戻りましょう。ここでは、チュートリアルの残りの時間を費やすことになります。
現在、ボードは1つの正方形しかありませんが、9つ必要です!正方形をコピー&ペーストして、次のように2つの正方形を作成してみます。
export default function Square() {
return <button className="square">X</button><button className="square">X</button>;
}すると、このエラーが発生します。
<>...</>を使用しますか?Reactコンポーネントは、2つのボタンのような複数の隣接するJSX要素ではなく、単一のJSX要素を返す必要があります。これを修正するには、フラグメント(<>と</>)を使用して、次のように複数の隣接するJSX要素をラップできます。
export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}これで、次のようになります。
すばらしい!あとは数回コピー&ペーストして9つの正方形を追加するだけです。
大変です!マスがすべて1列に並んでいて、盤面に必要なグリッド状になっていません。これを修正するには、divでマスを列ごとにグループ化し、いくつかのCSSクラスを追加する必要があります。その際、各マスがどこに表示されるかを確認するために、番号を振っておきましょう。
App.jsファイルで、Squareコンポーネントを次のように更新します。
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>
</>
);
}styles.cssで定義されているCSSは、classNameがboard-rowのdivをスタイルします。これで、スタイルされたdivを使ってコンポーネントを列ごとにグループ化したので、三目並べの盤面ができました。
しかし、ここで問題が発生しました。Squareという名前のコンポーネントは、もはや正方形ではありません。これを修正するために、名前をBoardに変更しましょう。
export default function Board() {
//...
}この時点で、コードは次のようになっているはずです。
export default function Board() { 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> </> ); }
propsを介したデータの受け渡し
次に、ユーザーがマスをクリックしたときに、マスの値を空から「X」に変更したいとします。今のところ、盤面を構築した方法では、マスを更新するコードを9回(マスごとに1回)コピー&ペーストする必要があります!コピー&ペーストする代わりに、Reactのコンポーネントアーキテクチャを使用すると、再利用可能なコンポーネントを作成して、乱雑で重複したコードを回避できます。
まず、最初のマスを定義している行(<button className="square">1</button>)をBoardコンポーネントから新しいSquareコンポーネントにコピーします。
function Square() {
return <button className="square">1</button>;
}
export default function Board() {
// ...
}次に、Boardコンポーネントを更新して、JSX構文を使用してそのSquareコンポーネントをレンダリングします。
// ...
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>
</>
);
}ブラウザのdivとは異なり、独自のコンポーネントであるBoardとSquareは、大文字で始まる必要があることに注意してください。
見てみましょう。
大変です!以前の番号付きのマスがなくなってしまいました。今はどのマスも「1」と表示されます。これを修正するには、propsを使用して、各マスが持つべき値を親コンポーネント(Board)から子(Square)に渡します。
Squareコンポーネントを更新して、Boardから渡されるvalue propを読み取ります。
function Square({ value }) {
return <button className="square">1</button>;
}function Square({ value })は、Squareコンポーネントにvalueというpropを渡せることを示します。
次に、各マス内で1の代わりに、そのvalueを表示する必要があります。このようにしてみてください。
function Square({ value }) {
return <button className="square">value</button>;
}おっと、これは望んでいたものではありません。
コンポーネントから「value」という単語ではなく、valueというJavaScript変数をレンダリングしたいのです。JSXから「JavaScriptにエスケープ」するには、中括弧が必要です。このようにJSXでvalueを中括弧で囲みます。
function Square({ value }) {
return <button className="square">{value}</button>;
}今のところ、空の盤面が表示されるはずです。
これは、Boardコンポーネントが、まだvalue propを、レンダリングする各Squareコンポーネントに渡していないためです。これを修正するには、value propを、Boardコンポーネントによってレンダリングされる各Squareコンポーネントに追加します。
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>
</>
);
}これで、再び数字のグリッドが表示されるはずです。
更新されたコードは次のようになっているはずです。
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で埋めましょう。Square内でhandleClickという関数を宣言します。次に、Squareから返されるボタンJSX要素のpropsにonClickを追加します。
function Square({ value }) {
function handleClick() {
console.log('clicked!');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}今、マスをクリックすると、CodeSandboxのブラウザセクションの下部にあるコンソールタブに"clicked!"というログが表示されるはずです。マスを複数回クリックすると、"clicked!"が再度ログに記録されます。同じメッセージのコンソールログが繰り返されても、コンソールにはそれ以上の行は作成されません。代わりに、最初の"clicked!"ログの横にインクリメントカウンターが表示されます。
次のステップとして、Squareコンポーネントにクリックされたことを「記憶」させ、それを「X」マークで埋めたいとします。「記憶」するために、コンポーネントは状態を使用します。
Reactは、コンポーネントから呼び出して「記憶」させることができるuseStateという特別な関数を提供します。Squareの現在の値を状態に保存し、Squareがクリックされたときに変更しましょう。
ファイルの先頭で useState をインポートしてください。Square コンポーネントから value プロップを削除してください。代わりに、Square の先頭に useState を呼び出す新しい行を追加してください。これにより、value という名前の状態変数が返されるようにします。
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
//...value は値を格納し、setValue は値を変更するために使用できる関数です。useState に渡される null は、この状態変数の初期値として使用されるため、ここでの value は null と等しい状態から始まります。
Square コンポーネントはプロップを受け取らなくなるため、Board コンポーネントによって作成された9つの Square コンポーネントすべてから value プロップを削除する必要があります。
// ...
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>
</>
);
}次に、クリック時に「X」を表示するように Square を変更します。console.log("clicked!"); イベントハンドラーを setValue('X'); に置き換えます。これで、Square コンポーネントは次のようになります。
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}この set 関数を onClick ハンドラーから呼び出すことで、Reactに、<button> がクリックされるたびにその Square を再レンダリングするように指示しています。更新後、Square の value は 'X' になり、ゲームボードに「X」が表示されます。いずれかの Square をクリックすると、「X」が表示されるはずです。
各 Square は独自の状態を持っています。各 Square に格納されている value は、他のものとは完全に独立しています。コンポーネントで set 関数を呼び出すと、React は内部の子コンポーネントも自動的に更新します。
上記を変更した後、コードは次のようになります。
import { useState } from 'react'; function Square() { const [value, setValue] = useState(null); function handleClick() { setValue('X'); } return ( <button className="square" onClick={handleClick} > {value} </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> </> ); }
React Developer Tools
React DevToolsを使用すると、Reactコンポーネントのプロップと状態を確認できます。CodeSandboxの *ブラウザ* セクションの下部にReact DevToolsタブがあります。
画面上の特定のコンポーネントを検査するには、React DevToolsの左上隅にあるボタンを使用します。
ゲームの完了
この時点で、三目並べゲームの基本的な構成要素はすべて揃っています。ゲームを完成させるには、ボードに「X」と「O」を交互に配置し、勝者を決定する方法が必要です。
状態の持ち上げ
現在、各 Square コンポーネントは、ゲームの状態の一部を保持しています。三目並べゲームで勝者をチェックするには、Board は、9つの Square コンポーネントの状態を何らかの方法で知る必要があります。
どのようにアプローチしますか?最初に、Board が各 Square にその Square の状態を「尋ねる」必要があると推測するかもしれません。このアプローチはReactでは技術的に可能ですが、コードが理解しにくくなり、バグが発生しやすく、リファクタリングが難しくなるため、推奨しません。代わりに、最適なアプローチは、各 Square ではなく、親 Board コンポーネントにゲームの状態を格納することです。Board コンポーネントは、各 Square に数値を渡したときのように、プロップを渡すことで、各 Square に何を表示するかを伝えることができます。
複数の子からデータを収集したり、2つの子コンポーネントを相互に通信させたりするには、代わりに親コンポーネントで共有状態を宣言します。親コンポーネントは、プロップを介してその状態を子に渡し返すことができます。これにより、子コンポーネントは互いに、そして親と同期が保たれます。
親コンポーネントへの状態の持ち上げは、Reactコンポーネントがリファクタリングされる際に一般的です。
この機会に試してみましょう。Board コンポーネントを編集して、9つのマスに対応する9つの null の配列をデフォルトとする squares という名前の状態変数を宣言するようにしてください。
// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}Array(9).fill(null) は、9つの要素を持つ配列を作成し、それぞれの要素を null に設定します。その周りの useState() の呼び出しは、初期値がその配列に設定された squares 状態変数を宣言します。配列内の各エントリは、マスの値に対応します。後で盤面を埋める際、squares 配列は次のようになります。
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]これで、Board コンポーネントは、レンダリングする各 Square に value プロパティを渡す必要があります。
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 コンポーネントを編集して、Board コンポーネントから value プロパティを受け取るようにします。これには、Square コンポーネント自身の value の状態管理と、ボタンの onClick プロパティを削除する必要があります。
function Square({value}) {
return <button className="square">{value}</button>;
}この時点で、空の三目並べ盤面が表示されるはずです。
そして、あなたのコードは次のようになるはずです。
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は、空のマスを表す 'X'、'O'、または null のいずれかの value プロパティを受け取るようになります。
次に、Square がクリックされたときに何が起こるかを変更する必要があります。Board コンポーネントは、どのマスが埋まっているかを管理するようになりました。Square が Board の状態を更新する方法を作成する必要があります。状態はそれを定義するコンポーネントにプライベートであるため、Square から直接 Board の状態を更新することはできません。
代わりに、Board コンポーネントから Square コンポーネントに関数を渡し、マスがクリックされたときに Square にその関数を呼び出させるようにします。まず、Square コンポーネントがクリックされたときに呼び出す関数から始めます。その関数を onSquareClick と呼びます。
function Square({ value }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}次に、onSquareClick 関数を Square コンポーネントのプロパティに追加します。
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}次に、onSquareClick プロパティを、Board コンポーネント内の handleClick という名前の関数に接続します。onSquareClick を handleClick に接続するには、最初の Square コンポーネントの onSquareClick プロパティに関数を渡します。
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={handleClick} />
//...
);
}最後に、盤面の状態を保持する squares 配列を更新するために、Boardコンポーネント内で 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 (
// ...
)
}handleClick 関数は、JavaScript の slice() 配列メソッドを使用して、squares 配列のコピー(nextSquares)を作成します。次に、handleClick は、最初のマス([0] インデックス)に X を追加するように nextSquares 配列を更新します。
setSquares 関数を呼び出すと、React にコンポーネントの状態が変更されたことが通知されます。これにより、squares 状態を使用するコンポーネント(Board)と、その子コンポーネント(盤面を構成する Square コンポーネント)の再レンダリングがトリガーされます。
これで、盤面に X を追加できます。ただし、左上のマスのみです。handleClick 関数は、左上のマス(0)のインデックスを更新するようにハードコードされています。どのマスでも更新できるように、handleClick を更新しましょう。更新するマスのインデックスを受け取る handleClick 関数に引数 i を追加します。
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 (
// ...
)
}次に、その i を handleClick に渡す必要があります。次のように、JSX で直接 onSquareClick プロパティを handleClick(0) に設定しようとするかもしれませんが、これは機能しません。
<Square value={squares[0]} onSquareClick={handleClick(0)} />これがうまくいかない理由です。 handleClick(0) の呼び出しは、ボードコンポーネントのレンダリングの一部になります。 handleClick(0) は setSquares を呼び出すことによってボードコンポーネントの状態を変更するため、ボードコンポーネント全体が再度レンダリングされます。しかし、これにより handleClick(0) が再度実行され、無限ループが発生します。
なぜこの問題が以前に発生しなかったのでしょうか?
onSquareClick={handleClick} を渡していたときは、handleClick 関数をプロップとして渡していました。呼び出していなかったのです!しかし、今は handleClick(0) のように括弧が付いていることに気づいてください。これにより、関数がすぐに実行されてしまいます。ユーザーがクリックするまで handleClick を呼び出したくありません!
handleClick(0) を呼び出す handleFirstSquareClick のような関数、 handleClick(1) を呼び出す handleSecondSquareClick のような関数などを作成して、これを修正できます。これらの関数を onSquareClick={handleFirstSquareClick} のようにプロップとして(呼び出すのではなく)渡します。これで無限ループが解決します。
ただし、9 つの異なる関数を定義し、それぞれに名前を付けるのは冗長すぎます。代わりに、これを行いましょう。
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
);
}新しい () => 構文に注目してください。ここで、() => handleClick(0) はアロー関数であり、関数を定義する簡単な方法です。正方形がクリックされると、=> "アロー" の後のコードが実行され、handleClick(0) が呼び出されます。
次に、他の 8 つの正方形を更新して、渡すアロー関数から handleClick を呼び出す必要があります。 handleClick の各呼び出しの引数が、正しい正方形のインデックスに対応していることを確認してください。
export default function Board() {
// ...
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>
</>
);
};これで、ボード上の任意の正方形をクリックして、再び X を追加できます。
しかし今回は、すべての状態管理が Board コンポーネントによって処理されます!
これがコードの見た目です。
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)); function handleClick(i) { const nextSquares = squares.slice(); nextSquares[i] = 'X'; setSquares(nextSquares); } 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> </> ); }
状態処理が Board コンポーネントにあるため、親の Board コンポーネントは、子 Square コンポーネントにプロップを渡して、正しく表示できるようにします。Square をクリックすると、子 Square コンポーネントは親 Board コンポーネントにボードの状態を更新するように要求します。 Board の状態が変化すると、Board コンポーネントとすべての子 Square が自動的に再レンダリングされます。すべての正方形の状態を Board コンポーネントに保持することで、将来の勝者を決定できるようになります。
ユーザーがボードの左上の正方形をクリックして X を追加するときに何が起こるかをまとめましょう。
- 左上の正方形をクリックすると、
SquareからonClickプロップとして受け取った関数がbuttonで実行されます。Squareコンポーネントは、その関数をBoardからonSquareClickプロップとして受け取りました。Boardコンポーネントは、その関数を JSX で直接定義しました。これは、0の引数を使用してhandleClickを呼び出します。 handleClickは、引数(0)を使用して、squares配列の最初の要素をnullからXに更新します。Boardコンポーネントのsquares状態が更新されたため、Boardとそのすべての子が再レンダリングされます。これにより、インデックス0のSquareコンポーネントのvalueプロップがnullからXに変わります。
最終的に、ユーザーは左上の正方形が空からクリック後に X に変わったことを確認します。
不変性が重要な理由
handleClick で、既存の配列を変更する代わりに、squares 配列のコピーを作成するために .slice() を呼び出していることに注目してください。理由を説明するために、不変性と、不変性を学ぶことが重要な理由について説明する必要があります。
データを変更する方法には、一般的に 2 つのアプローチがあります。最初のアプローチは、データの値を直接変更することによってデータを変更することです。2 番目のアプローチは、必要な変更が加えられた新しいコピーでデータを置き換えることです。 squares 配列を変更した場合の例を次に示します。
const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// Now `squares` is ["X", null, null, null, null, null, null, null, null];次に、squares 配列を変更せずにデータを変更した場合の例を示します。
const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// Now `squares` is unchanged, but `nextSquares` first element is 'X' rather than `null`結果は同じですが、(基盤となるデータを変更することなく)直接変更しないことで、いくつかの利点が得られます。
イミュータビリティ(不変性)は、複雑な機能を実装するのを非常に簡単にします。このチュートリアルの後半では、ゲームの履歴を確認したり、過去の動きに「ジャンプバック」したりできる「タイムトラベル」機能を実装します。この機能はゲームに特有のものではなく、特定のアクションを元に戻したりやり直したりする機能は、アプリの一般的な要件です。直接的なデータ変更を避けることで、データの以前のバージョンをそのまま保持し、後で再利用できます。
イミュータビリティには、もう1つの利点もあります。デフォルトでは、親コンポーネントの状態が変更されると、すべての子コンポーネントが自動的に再レンダリングされます。これには、変更の影響を受けなかった子コンポーネントも含まれます。再レンダリング自体はユーザーには気づかれませんが(積極的に回避しようとするべきではありません!)、パフォーマンス上の理由から、明らかに影響を受けなかったツリーの一部を再レンダリングしないようにしたい場合があります。イミュータビリティを使用すると、コンポーネントはデータが変更されたかどうかを非常に簡単に比較できます。Reactがコンポーネントをいつ再レンダリングするかを決定する方法について詳しくは、memo APIリファレンスをご覧ください。
手番の交代
この三目並べゲームの重大な欠陥、つまり盤面に「O」をマークできないという問題を修正する時が来ました。
デフォルトでは、最初の動きを「X」に設定します。これを追跡するために、Boardコンポーネントに別の状態を追加しましょう。
function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
// ...
}プレーヤーが動くたびに、xIsNext(ブール値)が反転して、次にプレーするプレーヤーが決定され、ゲームの状態が保存されます。BoardのhandleClick関数を更新して、xIsNextの値を反転させます。
export default function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(i) {
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
setSquares(nextSquares);
setXIsNext(!xIsNext);
}
return (
//...
);
}これで、異なるマスをクリックすると、それらはXとOの間で交互に表示されるはずです!
しかし、ちょっと待ってください。問題があります。同じマスを複数回クリックしてみてください。
XがOで上書きされています!これはゲームに非常に興味深いひねりを加えるでしょうが、ここでは元のルールに従うことにします。
マスをXまたはOでマークするとき、最初にマスにすでにXまたはOの値があるかどうかを確認していません。これは早期リターンで修正できます。マスにすでにXまたはOがあるかどうかを確認します。マスがすでに埋まっている場合は、盤面状態を更新しようとする前に、handleClick関数でreturnします。
function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}これで、空のマスにのみXまたはOを追加できます!この時点でのコードは次のようになります。
import { useState } from 'react'; function Square({value, onSquareClick}) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } export default function Board() { const [xIsNext, setXIsNext] = useState(true); const [squares, setSquares] = useState(Array(9).fill(null)); 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> </> ); }
勝者の宣言
プレーヤーが交代できるようになったので、ゲームに勝った時や、もう交代する番がない時に表示するようにします。これを行うには、9つのマスの配列を受け取り、勝者を確認して、適切に'X'、'O'、またはnullを返すcalculateWinnerというヘルパー関数を追加します。calculateWinner関数についてはあまり気にしないでください。これはReact固有のものではありません。
export default function Board() {
//...
}
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;
}BoardコンポーネントのhandleClick関数でcalculateWinner(squares)を呼び出して、プレーヤーが勝ったかどうかを確認します。このチェックは、ユーザーがすでにXまたはOがあるマスをクリックしたかどうかを確認するのと同時に実行できます。両方の場合で早期にreturnしたいと考えています。
function handleClick(i) {
if (squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
//...
}ゲームが終了したときにプレーヤーに知らせるために、「勝者:X」または「勝者:O」などのテキストを表示できます。そのため、Boardコンポーネントにstatusセクションを追加します。ステータスは、ゲームが終了した場合は勝者を表示し、ゲームが進行中の場合は次にプレーするプレーヤーを表示します。
export default function Board() {
// ...
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">
// ...
)
}おめでとうございます!これで、動作する三目並べゲームが完成しました。そして、Reactの基本も学びました。ですから、あなたが本当の勝者です。コードは次のようになるはずです。
import { useState } from 'react'; function Square({value, onSquareClick}) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } export default function Board() { const [xIsNext, setXIsNext] = useState(true); const [squares, setSquares] = useState(Array(9).fill(null)); 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)} /> <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 squares[a]; } } return null; }
タイムトラベル機能の追加
最後の演習として、ゲームの前の動きに「時間を戻す」ことができるようにしましょう。
ムーブの履歴の保存
squares配列をミューテートした場合、タイムトラベルの実装は非常に困難になります。
しかし、あなたは各ムーブの後にslice()を使用してsquares配列の新しいコピーを作成し、それをイミュータブルとして扱いました。これにより、squares配列の過去のすべてのバージョンを保存し、すでに発生したターン間を移動できるようになります。
過去のsquares配列をhistoryという別の配列に格納します。これは新しい状態変数として格納されます。history配列は、最初のムーブから最後のムーブまでのすべての盤面状態を表し、次のような形状になります。
[
// Before first move
[null, null, null, null, null, null, null, null, null],
// After first move
[null, null, null, null, 'X', null, null, null, null],
// After second move
[null, null, null, null, 'X', null, null, null, 'O'],
// ...
]状態を再び引き上げる
過去のムーブのリストを表示するために、Gameという新しいトップレベルコンポーネントを作成します。そこには、ゲーム全体の履歴を含むhistory状態を配置します。
history状態をGameコンポーネントに配置すると、子コンポーネントであるBoardコンポーネントからsquares状態を削除できます。SquareコンポーネントからBoardコンポーネントに「状態を引き上げた」のと同じように、今度はBoardからトップレベルのGameコンポーネントに引き上げます。これにより、GameコンポーネントがBoardのデータを完全に制御できるようになり、historyから以前のターンをレンダリングするようにBoardに指示できます。
まず、export defaultを使用してGameコンポーネントを追加します。このコンポーネントでBoardコンポーネントといくつかのマークアップをレンダリングするようにします。
function Board() {
// ...
}
export default function Game() {
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<ol>{/*TODO*/}</ol>
</div>
</div>
);
}function Board() {宣言の前にあるexport defaultキーワードを削除し、function Game() {宣言の前に追加することに注意してください。これは、index.jsファイルに対して、Boardコンポーネントではなく、トップレベルコンポーネントとしてGameコンポーネントを使用するように指示します。Gameコンポーネントによって返される追加のdivは、後で盤面に追加するゲーム情報のためのスペースを確保しています。
Gameコンポーネントに、次のプレイヤーとムーブの履歴を追跡するための状態を追加します。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
// ...[Array(9).fill(null)]は、単一の項目を持つ配列であり、それ自体が9つのnullの配列であることに注意してください。
現在のムーブのマス目をレンダリングするには、historyから最後のマス目配列を読み取る必要があります。このためにはuseStateは必要ありません。レンダリング中に計算するための十分な情報がすでにあります。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const currentSquares = history[history.length - 1];
// ...次に、ゲームを更新するためにBoardコンポーネントによって呼び出されるGameコンポーネント内にhandlePlay関数を作成します。xIsNext、currentSquares、およびhandlePlayをBoardコンポーネントへのpropsとして渡します。
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コンポーネントを、受け取るpropsによって完全に制御されるようにしましょう。Boardコンポーネントを、xIsNext、squares、およびプレイヤーがムーブを行ったときに更新されたマス目配列を使用してBoardが呼び出すことができる新しいonPlay関数の3つのpropsを受け取るように変更します。次に、useStateを呼び出すBoard関数の最初の2行を削除します。
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}次に、BoardコンポーネントのhandleClick内のsetSquaresとsetXIsNextの呼び出しを、新しいonPlay関数の単一の呼び出しに置き換え、ユーザーがマスをクリックしたときにGameコンポーネントがBoardを更新できるようにします。
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コンポーネントは、Gameコンポーネントから渡されたpropsによって完全に制御されます。ゲームを再び機能させるには、GameコンポーネントでhandlePlay関数を実装する必要があります。
handlePlayは呼び出されたときに何をする必要がありますか?Boardが更新された配列でsetSquaresを呼び出していたことを思い出してください。現在では、更新されたsquares配列をonPlayに渡しています。
handlePlay 関数は、再レンダリングをトリガーするために Game の状態を更新する必要があります。しかし、以前のように呼び出すことができる setSquares 関数はもうありません。この情報を保存するために history 状態変数を使用しているからです。更新された squares 配列を新しい履歴エントリとして追加して、history を更新する必要があります。また、Board が以前行っていたように、xIsNext を切り替える必要もあります。
export default function Game() {
//...
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
//...
}ここで、[...history, nextSquares] は、history のすべての項目に続けて nextSquares を含む新しい配列を作成します。(...history スプレッド構文 は、「history のすべての項目を列挙する」と読むことができます。)
たとえば、history が [[null,null,null], ["X",null,null]] で、nextSquares が ["X",null,"O"] の場合、新しい [...history, nextSquares] 配列は [[null,null,null], ["X",null,null], ["X",null,"O"]] になります。
この時点で、状態を Game コンポーネントに移動させ、UI はリファクタリング前とまったく同じように完全に動作するはずです。この時点でのコードは次のようになります。
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; }
過去の着手を表示する
三目並べゲームの履歴を記録しているので、過去の着手の一覧をプレイヤーに表示できるようになりました。
<button> のような React 要素は、通常の JavaScript オブジェクトです。アプリケーション内で渡すことができます。React で複数のアイテムをレンダリングするには、React 要素の配列を使用できます。
すでに状態に history 着手の配列があるので、それを React 要素の配列に変換する必要があります。JavaScript では、ある配列を別の配列に変換するために、配列の map メソッドを使用できます。
[1, 2, 3].map((x) => x * 2) // [2, 4, 6]map を使用して、着手の history を画面上のボタンを表す React 要素に変換し、過去の着手に「ジャンプ」するためのボタンの一覧を表示します。Game コンポーネントの history を 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>
);
}以下にコードがどのようになるかを確認できます。開発者ツールコンソールに次のエラーが表示されることに注意してください。
このエラーは次のセクションで修正します。
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); } 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> ); } 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; }
map に渡された関数内で history 配列を反復処理すると、squares 引数は history の各要素を通過し、move 引数は各配列インデックスを通過します:0、1、2、... (ほとんどの場合、実際の配列要素が必要になりますが、着手の一覧をレンダリングするにはインデックスのみが必要です。)
三目並べゲームの履歴の各着手に対して、ボタン <button> を含むリスト項目 <li> を作成します。このボタンには、jumpTo という関数を呼び出す onClick ハンドラーがあります(まだ実装していません)。
今のところ、ゲームで発生した着手の一覧と、開発者ツールコンソールにエラーが表示されるはずです。「key」エラーが何を意味するのかを説明しましょう。
キーの選択
一覧をレンダリングする場合、React はレンダリングされた各リスト項目に関する情報を格納します。一覧を更新する場合、React は何が変更されたかを判断する必要があります。一覧のアイテムを追加、削除、並べ替え、または更新した可能性があります。
次の状態から
<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>次の状態に移行すると想像してください
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>更新されたカウントに加えて、これを読んだ人間はおそらく、アレクサとベンの順序を入れ替え、アレクサとベンの間にクラウディアを挿入したと言うでしょう。しかし、React はコンピュータープログラムであり、あなたが何を意図したのかを知らないため、各リスト項目を兄弟要素と区別するために、各リスト項目にキープロパティを指定する必要があります。データがデータベースからのものであれば、アレクサ、ベン、クラウディアのデータベース ID をキーとして使用できます。
<li key={user.id}>
{user.name}: {user.taskCount} tasks left
</li>リストが再レンダリングされると、React は各リスト項目のキーを取得し、前のリストの項目で一致するキーを検索します。現在のリストに以前存在しなかったキーがある場合、React はコンポーネントを作成します。現在のリストに以前のリストに存在していたキーがない場合、React は以前のコンポーネントを破棄します。2 つのキーが一致する場合、対応するコンポーネントが移動されます。
キーは、React に各コンポーネントの識別情報を伝えます。これにより、React は再レンダリング間で状態を維持できます。コンポーネントのキーが変更された場合、コンポーネントは破棄され、新しい状態で再作成されます。
key は、React において特別かつ予約済みのプロパティです。要素が作成される際、React は key プロパティを抽出し、返された要素に直接キーを格納します。key が props として渡されるように見えるかもしれませんが、React はどのコンポーネントを更新するかを決定するために key を自動的に使用します。コンポーネントが、親が指定した key を問い合わせる方法はありません。
動的なリストを作成する際は、適切なキーを割り当てることを強く推奨します。適切なキーがない場合は、データ構造を再構成することを検討してください。
キーが指定されていない場合、React はエラーを報告し、デフォルトで配列のインデックスをキーとして使用します。配列のインデックスをキーとして使用することは、リストの項目の並べ替えや、リスト項目の挿入/削除を行う際に問題が発生します。key={i} を明示的に渡すことでエラーは抑制されますが、配列のインデックスと同じ問題が発生するため、ほとんどの場合推奨されません。
キーはグローバルに一意である必要はなく、コンポーネントとその兄弟の間で一意であれば十分です。
タイムトラベルの実装
三目並べゲームの履歴では、過去の各動きには一意の ID が関連付けられています。それは、その動きの連番です。動きが並べ替えられたり、削除されたり、途中に挿入されたりすることはないため、動きのインデックスをキーとして使用しても安全です。
Game 関数では、<li key={move}> のようにキーを追加できます。レンダリングされたゲームをリロードすると、React の "key" エラーは消えるはずです。
const moves = history.map((squares, move) => {
//...
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});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); } 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 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"> <ol>{moves}</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; }
jumpTo を実装する前に、ユーザーが現在どのステップを見ているかを追跡するために、Game コンポーネントが必要です。これを行うには、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];
//...
}次に、Game 内の jumpTo 関数を更新して、その currentMove を更新します。また、currentMove を変更している数が偶数の場合は、xIsNext を true に設定します。
export default function Game() {
// ...
function jumpTo(nextMove) {
setCurrentMove(nextMove);
setXIsNext(nextMove % 2 === 0);
}
//...
}ここで、マスをクリックしたときに呼び出される Game の handlePlay 関数に 2 つの変更を加えます。
- 「時間を戻し」、その時点から新しい動きをした場合、その時点までの履歴のみを保持する必要があります。
history内のすべての項目 (...スプレッド構文) の後にnextSquaresを追加する代わりに、history.slice(0, currentMove + 1)内のすべての項目の後に追加して、古い履歴のその部分のみを保持するようにします。 - 動きが行われるたびに、
currentMoveを最新の履歴エントリを指すように更新する必要があります。
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
setXIsNext(!xIsNext);
}最後に、Game コンポーネントを修正して、常に最後の動きをレンダリングするのではなく、現在選択されている動きをレンダリングするようにします。
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[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); } 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 [currentMove, setCurrentMove] = useState(0); const currentSquares = history[currentMove]; function handlePlay(nextSquares) { const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; setHistory(nextHistory); setCurrentMove(nextHistory.length - 1); setXIsNext(!xIsNext); } function jumpTo(nextMove) { setCurrentMove(nextMove); setXIsNext(nextMove % 2 === 0); } 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"> <ol>{moves}</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; }
最終的なクリーンアップ
コードをよく見ると、currentMove が偶数の場合、xIsNext === true であり、currentMove が奇数の場合、xIsNext === false であることに気づくかもしれません。つまり、currentMove の値を知っていれば、xIsNext がどうあるべきかを常に把握できます。
これらの両方を状態に保存する理由はありません。実際には、冗長な状態は常に避けるようにしてください。状態に格納するものを単純化すると、バグが減り、コードが理解しやすくなります。Game を変更して、xIsNext を別の状態変数として保存するのではなく、currentMove に基づいて計算するようにします。
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
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);
}
// ...
}xIsNext の状態宣言や、setXIsNext の呼び出しは不要になりました。これで、xIsNext が currentMove と同期しなくなる可能性はなくなりました。コンポーネントをコーディング中に間違いを犯した場合でも同様です。
まとめ
おめでとうございます!次のことができる三目並べゲームを作成しました。
- 三目並べをプレイできる。
- プレイヤーがゲームに勝ったときに示す。
- ゲームの進行状況に応じてゲームの履歴を保存する。
- プレイヤーがゲームの履歴をレビューし、ゲームのボードの以前のバージョンを確認できるようにする。
よくできました!React がどのように動作するかを十分に理解できたと感じていただければ幸いです。
最終結果はこちらで確認できます
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 [history, setHistory] = useState([Array(9).fill(null)]); const [currentMove, setCurrentMove] = useState(0); 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); } 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"> <ol>{moves}</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; }
時間がある場合や、新しい React スキルを練習したい場合は、三目並べゲームに以下のような改善を加えるアイデアをいくつかご紹介します。難易度の低いものから順に並べています。
- 現在の動きについてのみ、ボタンの代わりに「あなたはムーブ #… にいます」と表示する。
Boardを書き換えて、マスをハードコーディングするのではなく、2 つのループを使用するようにする。- 動きを昇順または降順に並べ替えることができるトグルボタンを追加する。
- 誰かが勝利した場合、勝利の原因となった 3 つのマスをハイライト表示する(また、誰も勝利しなかった場合は、引き分けになったことを示すメッセージを表示する)。
- 動きの履歴リストに、各動きの位置を (行、列) 形式で表示する。
このチュートリアル全体を通して、要素、コンポーネント、props、状態などの React の概念に触れてきました。ゲームを構築する際にこれらの概念がどのように機能するかを確認したので、Thinking in React を参照して、アプリの UI を構築する際に同じ React の概念がどのように機能するかを確認してください。