React チュートリアルを Hooks + TypeScript へリファクタリングする

13 min read読了の目安(約11800字

はじめに

https://ja.reactjs.org/tutorial/tutorial.html

React 公式チュートリアルの三目並べ (Tic Tac Toe) を React Hooks と TypeScript (以下、TS )でリファクタリングしてみます。
リファクタリングするにあたっては、以下のようなドキュメントが参考になると思います。

https://typescript-jp.gitbook.io/deep-dive/type-system/migrating

https://ja.reactjs.org/docs/hooks-overview.html

公式チュートリアルの最終結果

準備

  1. create-react-app の TypeScript テンプレートを利用してプロジェクトフォルダを作成します。
bash
% npx create-react-app zenn --template typescript

Creating a new React app in /Users/sprout/Downloads/zenn.

Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts with cra-template-typescript...

~ snip ~

We suggest that you begin by typing:

  cd zenn
  npm start

Happy hacking!

% cd zenn
  1. React Tutorial JSReact Totorial CSS コードをそれぞれ index.tsx, index.css という名前で src フォルダに配置します。

  2. index.tsx の先頭に以下の3行を加えます。

index.tsx
import React, { useState } from "react";
import ReactDOM from "react-dom";
import "./index.css";

元は JavaScript で書かれたコードに tsx の拡張子をあたえているため、VSCode のようなコードエディタでプロジェクトを開くと以下のようにたくさんのエラーが表示されます。

これらのエラーを一つ一つ修正していくことをこのリファクタリングの方針とします。

手順

1. 末尾の calculateWinner 関数を冒頭に移動する

calculateWinner 関数はコードの末尾に置かれていますが、TS では関数をその定義より前に呼び出すことはできないので冒頭に移動させます。

https://developer.mozilla.org/ja/docs/Glossary/Hoisting

移動させるついでにアロー関数に書き換えます。

index.tsx
import ReactDOM from "react-dom";
import "./index.css";

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

アロー関数に書き換えることで JavaScript の this にまつわる煩雑さが軽減されます。

https://qiita.com/takeharu/items/9935ce476a17d6258e27

2. squares の型を検討する

calculateWinner 関数の引数 squares へ型を与えないといけません。

親コンポーネントである Game コンポーネントのステートを見ると、squares は9つの要素を持つ配列であると分かります。

index.tsx
class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
        },
      ],
      stepNumber: 0,
      xIsNext: true,
    };
  }

いっぽう Square 関数コンポーネントでは、三目並べの各マスに入る 'X' または 'O' ( string 型)もしくは何もなし( null 型)として props を受け取っています。

index.tsx
function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

よって squares の型は string 型もしくは null 型の配列となります。

index.tsx
type SquaresType = (string | null)[];

配列の要素数を指定する型定義も可能ですが、やや難易度が上がるのでここでは参考記事を紹介するに留めます。

https://qiita.com/uhyo/items/80ce7c00f413c1d1be56
index.tsx
const calculateWinner = (squares: SquaresType) => {
  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;
};

3. Square コンポーネントへの props の型を検討する

Square 関数コンポーネントを SquareProps 型の props を引数とするコンポーネントとして定義します。

index.tsx
const Square: React.VFC<SquareProps> = (props) => {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
};

SquareProps の型定義を検討しなければいけません。

props.value はすでに見てきた通り、各マスへ与えられる 'X' または 'O' の string 型、もしくは nullです。

props.onClick は、いまのところ引数も返り値もない関数であるように見えますので () => void という型定義を与えておきます。

index.tsx
interface SquareProps {
  value: string | null;
  onClick: () => void;
}

4. Board コンポーネントを関数コンポーネントへ書き換える

クラスコンポーネントである BoardBoardProps 型の props を引数にとる関数コンポーネントに置き換えます。

index.tsx
const Board: React.VFC<BoardProps> = (props) => {

renderSquare 関数をアロー関数に書き換える

クラスメソッドであった renderSquare をアロー関数に書き換えます。
また、すでにクラスではなくなったので this. は削除します。

index.tsx
  const renderSquare = (i) => {
    return (
      <Square
        value={props.squares[i]}
        onClick={() => props.onClick(i)}
      />
    );
  }

引数の i は下の render 文から number 型であると判断できます。

index.tsx
  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}

renderSquare 関数の引数に number 型を付与します。

index.tsx
const Board: React.VFC<BoardProps> = (props) => {
  const renderSquare = (i: number) => {
    return (

render メソッドを通常の return 文にする

render()this. を削除します。

index.tsx
  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>
  );

5. Board コンポーネントへの props の型を検討する

BoardProps 型の定義が必要となりました。

renderSquare 関数から以下のことが分かります。

  • props.squaresSquaresType 型の配列である
  • props.onClicknumber 型の引数を取り、返り値はない
index.tsx
  const renderSquare = (i: number) => {
    return <Square value={props.squares[i]} onClick={() => props.onClick(i)} />;
  };

結果、BoardProps の型定義は以下のようになります。

index.tsx
interface BoardProps {
  squares: SquaresType;
  onClick: (i: number) => void;
}

6. Game コンポーネントを関数コンポーネントに書き換える

これ以降は、親コンポーネントである Game コンポーネントを関数コンポーネントへ置き換えていきます。このリファクタリングでの一番の難所となるかも知れません。

とりあえず React 関数コンポーネントとして定義し、

index.tsx
const Game: React.VFC = () => {

上の Board コンポーネント同様に以下の編集をおこないます。

  • render メソッドを通常の return 文にする
  • this. を削る

7. Game コンポーネントのステートを useState フックに置き換える

クラスコンポーネントではコンストラクタの中で初期化されていたステートの内容を検討します。

index.tsx
class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
        },
      ],
      stepNumber: 0,
      xIsNext: true,
    };
  }

stepNumber ステートが number 型、xIsNextboolean 型であることは一目瞭然ですね。

useState フックの構文

const [foo, setFoo] = React.useState('bar');
  • foo: 現在のステートの値
  • setFoo: ステートを更新するメソッド
  • useState:
    • 引数はステートの初期値
    • 現在のステートと、それを更新するための関数とをペアにして返す

https://ja.reactjs.org/docs/hooks-state.html

stepNumber ステートと xIsNext ステートを useState フックで初期化します。

index.tsx
  const [stepNumber, setStepNumber] = useState(0);
  const [xIsNext, setXIsNext] = useState(true);

History 型を定義する

コンストラクタでの state.history の初期設定を参照すると、

    this.state = {
      history: [
        {
          squares: Array(9).fill(null),
        },
      ],

history は、squares というプロパティを持つオブジェクトの配列で、squares プロパティの値は SquaresType 型であることが分かります。

squares プロパティをもつオブジェクトを History 型として定義します。

index.tsx
interface History {
  squares: SquaresType;
}

history ステートは History 型オブジェクトの配列となるので、以下のように useState フックで初期化します。

index.tsx
  const [history, setHistory] = useState<History[]>([
    { squares: Array(9).fill(null) }
  ]);

Game コンポーネントのコンストラクタは削除します。

index.tsx
const Game: React.VFC = () => {
  const [history, setHistory] = useState<History[]>([
    { squares: Array(9).fill(null) }
  ]);
  const [stepNumber, setStepNumber] = useState(0);
  const [xIsNext, setXIsNext] = useState(true);

8. 各メソッドをアロー関数に書き換える

旧クラスメソッドをそれぞれアロー関数に書き換えます。

handleClick メソッド

  • 引数 i が number 型であることは上の通り
  • state. を削除
index.tsx
  const handleClick = (i: number) => {
    const history = history.slice(0, stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = xIsNext ? "X" : "O";
    setState({
      history: history.concat([
        {
          squares: squares,
        },
      ]),
      stepNumber: history.length,
      xIsNext: !xIsNext,
    });
  }

handleClick メソッドでの名前の衝突を解消する

アロー関数にしたことでメソッド内の変数名がステートと重複してしまったので、これを解消していきます。

関数内では history ステートの配列を直接書き換えることは避けて、historyCurrent というコピーを操作する必要があります。

index.tsx
  const handleClick = (i: number) => {
    const historyCurrent = history.slice(0, stepNumber + 1);
    const current = historyCurrent[historyCurrent.length - 1];
    const squares = current.squares.slice();

    if (calculateWinner(squares) || squares[i]) return;

https://qiita.com/sh-suzuki0301/items/597bdbf17253feb5f55b

https://zenn.dev/luvmini511/articles/85e8e3c71a2f41

handleClick メソッドでの setState 文を setHoge(value) へ変換する

setHistory には、やはりコピーである historyCurrent を渡します。

index.tsx
    setHistory(historyCurrent.concat([{ squares: squares }]));
    setStepNumber(historyCurrent.length);
    setXIsNext(!xIsNext);

上を少しモダンに書くと下のようになります。

index.tsx
    setHistory([...historyCurrent, { squares }]);

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

jumpTo メソッドをアロー関数に

index.tsx
  const jumpTo = (step: number) => {
    setStepNumber(step);
    setXIsNext(step % 2 === 0);
  };

9. Game (=親コンポーネント) での名前の衝突を解消する

各メソッド以外でもステートと変数名の衝突が起きているので、これらも解消していきます。

  • handleClick メソッドでの要領と同じ
  • state. は削除する
index.tsx
  const historyCurrent = [...history];
  const current = historyCurrent[stepNumber];
  const winner = calculateWinner(current.squares);

map メソッド内の desc 変数や status 変数でもモダンな(?)記法を使います。

index.tsx
  const moves = history.map((_step, move) => {
    const desc = move ? `Go to move #${move}` : "Go to game start";
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{desc}</button>
      </li>
    );
  });
index.tsx
  const status = winner
    ? `Winner: ${winner}`
    : `Next player: ${xIsNext ? "X" : "O"}`;

10. ここまでの結果

おまけ

さらに useReducer を投入します:

https://zenn.dev/sprout2000/articles/948e62987e0f81

E2E テストもやってみます:

https://zenn.dev/sprout2000/articles/d0aa297a48e4d0

他のチュートリアル:

https://zenn.dev/sprout2000/articles/60cc8f1aa08b4b