rikuto tech blog

ゆる〜くやってます

React Tutorialを TypeScriptとHooksで書き直してみた⑤(fin)

タイムトラベル機能の追加|Stateのリフトアップ、再び

squaresを配列historyに保存しておき、あとで参照できるようにしていきます。

これまで、stateはBoardコンポーネントに実装していましたが、これをGameコンポーネントに移し、Boardにはpropsを渡すことによってアクセスできるようにします。

Gameコンポーネント

まず、GameコンポーネントにstateのhistoryxIsNextを設定します。

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が更新された後の状態squaresSlicehistoryに追加し、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

githubこちら

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)を使うとなお良いのかなと思います。

ブログを書くのが大変でした笑