タイムトラベル機能の追加|Stateのリフトアップ、再び
squares
を配列history
に保存しておき、あとで参照できるようにしていきます。
これまで、stateはBoard
コンポーネントに実装していましたが、これをGame
コンポーネントに移し、Board
にはpropsを渡すことによってアクセスできるようにします。
Gameコンポーネント
まず、Game
コンポーネントにstateのhistory
とxIsNext
を設定します。
const [history, setHistory] = useState([{ squares: Array(9).fill(null) }]); const [xIsNext, setXIsNext] = useState(true);
history
は括弧が多く少々分かりづらいですが、{squares: Array()}
を要素に持つ配列です。
次に、handleClickメソッドをBoardコンポーネントからGameコンポーネントに移動します。
stateがsquaresからhistoryに変わったので、それに対応するように編集します。
const handleClick = (i: number): void => { const current = history[history.length - 1]; const squaresSlice = current.squares.slice(); // 勝者確定かマスが埋まっていたら、クリックしてもマスが変化しないようにする if (calculateWinner(squaresSlice) || squaresSlice[i]) { return; } squaresSlice[i] = xIsNext ? 'X' : 'O'; setHistory( history.concat([ { squares: squaresSlice, }, ]), ); setXIsNext(!xIsNext); };
まず、history
から最新のBoard
の状態をcurrent
で抜いて勝者判定をし、Boardが更新された後の状態squaresSlice
をhistory
に追加し、concatでhistoryにくっつけてstateにセットしています。
最後にGameコンポーネントの残りの部分です。
const current = history[history.length - 1]; const winner = calculateWinner(current.squares); const status = winner ? `Winner: ${winner}` : `Next player: ${xIsNext ? 'X' : 'O'}`; return ( <div className="game"> <div className="game-board"> <Board squares={current.squares} onClick={(i) => handleClick(i)} /> </div> <div className="game-info"> <div>{status}</div> <ol>{/* TODO */}</ol> </div> </div> );
こちらも、handleClick
と同様に、これまでBoardを直接参照していたところを、history
から最新の状態をcurrent
に格納するようにしています。
また、状態をGameコンポーネントで管理するようになったので、Boardにはこれをpropsで渡せるようにします。
<Board squares={current.squares} onClick={(i) => handleClick(i)} />
squares
は今のBoardの状態、onClickはO/Xを配置する関数です。
ついでに、Boardに渡すpropsの型も定義しておきます。こちらはファイルの頭に追加します。
type BoardProps = { squares: FillSquare[]; onClick: (i: number) => void; };
Boardコンポーネント
まず、propsを受け取るようにしたいので、
const Board: VFC<BoardProps> = (props) => { const { squares, onClick } = props; ︙
のように変更します。
さらに、公式チュートリアルと同じく、
Board の renderSquare にある this.state.squares[i] を this.props.squares[i] に置き換える。 Board の renderSquare にある this.handleClick(i) を this.props.onClick(i) に置き換える。
ことをします。
そして編集後は下のようになります。
const Board: VFC<BoardProps> = (props) => { const { squares, onClick } = props; const renderSquare = (i: number): ReactElement => ( <Square value={squares[i]} onClick={() => onClick(i)} /> ); return ( <div> <div className="board-row"> {renderSquare(0)} {renderSquare(1)} {renderSquare(2)} </div> <div className="board-row"> {renderSquare(3)} {renderSquare(4)} {renderSquare(5)} </div> <div className="board-row"> {renderSquare(6)} {renderSquare(7)} {renderSquare(8)} </div> </div> ); };
編集後のindex.tsx
import { VFC, useState, ReactElement } from 'react'; import ReactDOM from 'react-dom'; import './index.css'; // Squareの中身の型、XかOか空(null)の3通り type FillSquare = 'X' | 'O' | null; type SquareProps = { value: FillSquare; onClick: () => void; }; type BoardProps = { squares: FillSquare[]; onClick: (i: number) => void; }; const calculateWinner = (squares: FillSquare[]) => { 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 += 1) { const [a, b, c] = lines[i]; if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { return squares[a]; } } return null; }; const Square: VFC<SquareProps> = (props) => { const { value, onClick } = props; return ( <button type="button" className="square" onClick={onClick}> {value} </button> ); }; const Board: VFC<BoardProps> = (props) => { const { squares, onClick } = props; const renderSquare = (i: number): ReactElement => ( <Square value={squares[i]} onClick={() => onClick(i)} /> ); return ( <div> <div className="board-row"> {renderSquare(0)} {renderSquare(1)} {renderSquare(2)} </div> <div className="board-row"> {renderSquare(3)} {renderSquare(4)} {renderSquare(5)} </div> <div className="board-row"> {renderSquare(6)} {renderSquare(7)} {renderSquare(8)} </div> </div> ); }; const Game: VFC = () => { const [history, setHistory] = useState([{ squares: Array(9).fill(null) }]); const [xIsNext, setXIsNext] = useState(true); const handleClick = (i: number): void => { const current = history[history.length - 1]; const squaresSlice = current.squares.slice(); // 勝者確定かマスが埋まっていたら、クリックしてもマスが変化しないようにする if (calculateWinner(squaresSlice) || squaresSlice[i]) { return; } squaresSlice[i] = xIsNext ? 'X' : 'O'; setHistory( history.concat([ { squares: squaresSlice, }, ]), ); setXIsNext(!xIsNext); }; const current = history[history.length - 1]; const winner = calculateWinner(current.squares); const status = winner ? `Winner: ${winner}` : `Next player: ${xIsNext ? 'X' : 'O'}`; return ( <div className="game"> <div className="game-board"> <Board squares={current.squares} onClick={(i) => handleClick(i)} /> </div> <div className="game-info"> <div>{status}</div> <ol>{/* TODO */}</ol> </div> </div> ); }; // ======================================== ReactDOM.render(<Game />, document.getElementById('root'));
タイムトラベル機能の追加
過去の着手の表示
mapを用いて、過去の手番にジャンプするボタンを実装します。
// Gameコンポーネント内 const moves = history.map((step, move) => { const desc = move ? `Go to move #${move}` : 'Go to game start'; return ( <li key={move.toString()}> <button type="button" onClick={() => jumpTo(move)}> {desc} </button> </li> );
keyを選ぶで述べられていますが、ここではkeyとしてmove
を選びます。keyはstring型なので、toString()
で型を変換して渡しています。
タイムトラベルの実装
プレイ中のステップ数を記録するstateであるstepNumber
を追加します。
const [stepNumber, setStepNumber] = useState(0);
次に、jumpTo
関数を次のように実装します。
const jumpTo = (step: number) => { setStepNumber(step); setXIsNext(step % 2 === 0); };
さらに、handleClick
を編集し、タイムトラベルで手番を移動したときのhistory
と現在の手番であるstepNumber
を更新するようにします。
const handleClick = (i: number): void => { const historySlice = history.slice(0, stepNumber + 1); const current = historySlice[historySlice.length - 1]; const squares = current.squares.slice(); // 勝者確定かマスが埋まっていたら、クリックしてもマスが変化しないようにする if (calculateWinner(squares) || squares[i]) { return; } squares[i] = xIsNext ? 'X' : 'O'; setHistory([...historySlice, { squares }]); setStepNumber(historySlice.length); setXIsNext(!xIsNext); };
公式チュートリアルとは異なり、history
で定義されている変数がhistorySlice
になっていますが、これは名前の衝突が起こってしまうための回避策です。
historySlice
は指定した手番までのhistory
を切り出し、current
は指定した手番のBoard
の状態にしています。
さらに、
setHistory([...historySlice, { squares }]); setStepNumber(historySlice.length);
を追加し、タイムトラベルした次の手番の状態を記録してstateを更新します。
あとは、return前の現在のBoardの状態と勝者判定の部分を次のように書き換えます。
これにより、stepNumber
の手番を描画するようにします。
const currentHistory = [...history]; const current = currentHistory[stepNumber]; const winner = calculateWinner(current.squares);
完成したコード
これで完成です!!
こちらから
やってみて
チュートリアルのリファクタは意外とすんなりいけました。useReducer(Redux)を使うとなお良いのかなと思います。
ブログを書くのが大変でした笑