Hooks + TypeScriptでReact公式チュートリアルをリファクタ

Hooks + TypeScriptでReact公式チュートリアルをリファクタ

React公式サイトには入門に最適なチュートリアルが用意されています。マルバツゲームの作成を通して、Reactとその構文について学ぶことができるようになっています。実際に手を動かしながら学ぶことができるので、一段階ずつ学べるガイドと合わせてスムーズにReactに入門できるようになっています。

今回の記事では、そのチュートリアルをJavaScriptからTypeScriptへ、クラスベースのコンポーネントから関数ベースのコンポーネントへリファクタします。リファクタを通して、ReactでTypeScriptを使うための型定義について、関数ベースのコンポーネントの書き方について紹介していきます。

リファクタ前のコード

公式チュートリアルを終えると、このようなコードが出来上がります。(ブラウザでのコーディングではなく、ローカル開発環境でのコーディングの結果です。)

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}
class Board extends React.Component {
  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }
  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}
class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{ squares: Array(9).fill(null) }],
      stepNumber: 0,
      xIsNext: true,
    };
  }
  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{ squares: squares }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }
  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: step % 2 === 0,
    });
  }
  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);
    const moves = history.map((step, move) => {
      const desc = move ? 'Go to move #' + move : 'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }
    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <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;
}
// ========================================
ReactDOM.render(<Game />, document.getElementById('root'));

リファクタの目標

今回のリファクタでは、下記の点をリファクタしていきます。

JavaScript→TypeScript

TypeScript・TypeScriptに対応したエディタを導入することで、型システムによる整合性のチェックやコード補完が効くようになり、開発の生産性アップが望めます。また、リファクタもしやすくなるため、大規模プロジェクトでも品質を保ちやすいと言われています。

TypeScriptはほとんどの言語機能をJavaScriptから継承しており、JavaScript→TypeScriptのリファクタに必要なのは、ほとんど型宣言だけです。そのため、JavaScriptのコードをTypeScriptとして書き、いくつかのコンパイルエラーを解消してあげれば、それはTypeScriptのコードになります。本記事では、次の手順でJavaScript→TypeScriptのリファクタを行います。

  1. React×TypeScriptの雛形プロジェクトの作成
  2. 公式チュートリアル通りの実装(JavaScript)を、作成したTypeScriptの雛形プロジェクトにコピペ(コンパイルエラーが発生します)
  3. コンパイルエラーを1つずつ解決していく

クラスベースのコンポーネント→関数ベースのコンポーネント

Reactコンポーネントにはクラスベースの書き方と関数ベースの書き方の2通りの書き方があります。

クラスベースのコンポーネントは、classキーワードを使ってコンポーネントを定義する従来の定義方法です。クラスベースのコンポーネントはstateで状態を持つことができます。

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }
  render() {
    return (
      <button className="square" onClick={() => alert('click')}>
        {this.props.value}
      </button>
    );
  }
}

関数ベースのコンポーネントは、状態を持たず、渡された値をもとに要素を返すだけのシンプルなコンポーネントです。

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

状態を持つことができるぶん、クラスベースのコンポーネントのほうが関数ベースのコンポーネントよりも優れているように思う方もいらっしゃると思います。しかし、クラスベースのコンポーネントは、Reactの公式ドキュメントに「クラスは人間と機械の両方を混乱させる」と言われている通り、コードが冗長になりがちであったり、さまざまなツールがうまく働かないなどの問題があります。

クラスを使わずにReactの様々な問題を解決するために、Hooksが考案されました。Hooksを使えば、関数コンポーネントでも状態を持つことができるようになり、(本記事では紹介しませんが)多種多様な機能をクラスを使わずに実現できるようになります。

公式チュートリアルではGameコンポーネントで状態を持つためにクラスベースのコンポーネントを利用しています。本記事では、GameコンポーネントをHooksを使った関数コンポーネントにリファクタしてきます。

JavaScript→TypeScript

React×TypeScriptの雛形プロジェクトの作成

create-react-appを使って雛形プロジェクトを作成しますが、今回はTypeScriptを使うので、–typescriptオプションを付けて実行します。

$ npx create-react-app my-app --typescript

yarn startしてみるなどして正常にパッケージがインストールされていることを確認してください。

公式チュートリアル通りに実装

create-react-appで作られるサンプル画面のソースコードをクリアします。

$ cd my-app
$ cd src
# If you're using a Mac or Linux:
$ rm -f *
# Or, if you're on Windows:
$ del *
# Then, switch back to the project folder
$ cd ..

src/ フォルダ内に index.css という名前のファイルを作り、ここの CSS コードを貼り付けます。

src/ フォルダ内に inde.tsx という名前のファイルを作り、チュートリアルで作成したリファクタ前のコードを貼り付けます。すると、TypeScriptのエラーがいくつか表示されますので、これらを1つずつ解決していきます。

コンパイルエラーを解決していく

関数の引数の型を制約する

Boardコンポーネント内の関数renderSquareの引数iの部分に「パラメーター ‘i’ の型は暗黙的に ‘any’ になります。」とエラーが出ていると思います。

パラメータ(引数)の型が暗黙的にanyになるというのは、引数iがどのような型でも受け取れてしまう、つまり、文字列などを渡してしまってSquareに間違ったvalueを渡してしまう(例えばthis.props.squares[1]のつもりがthis.props.squares[“1”]を渡してしまう)ことでコンポーネントが正常に機能しなくなる可能性がある状態という意味です。「エラーが出る」というはあまりいい響きの言葉ではないと感じる方もいらっしゃると思いますが、TypeScriptではこういった危険をエラーによって検知することができます。

このエラーを解決するためには、引数に型注釈を付与し、明示的に型を制約します。具体的には、下記のように書き換えます。

  renderSquare(i: number) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

renderSquareの引数iをnumber型に制約することができたため、iの部分のエラーは消えます。index.tsx内の同様のエラーを解消していきます。

  handleClick(i: number) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{ squares: squares }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }
  jumpTo(step: number) {
    this.setState({
      stepNumber: step,
      xIsNext: step % 2 === 0,
    });
  }
    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i: number) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );

関数calculateWinnerの引数squaresの型は上記のものと比べて複雑で、stringもしくはnullが入った配列になります。この場合の型の指定の方法の一つとしてcalculateWinner(squares: Array<string | null>)のような指定の仕方があります。これはGenericsという機能(<>で囲んでいる部分)を使って、stringもしくはnullが入った配列であるということを表しています。また、string | nullのようにパイプ(|)で型をつなぎ、複数の方のどれかにあてはまるように定義された型をUnion型と呼びます。GenericsもUnion型もTypeScriptの機能です。

上記の指定の仕方でもよいのですが、typeキーワードを使って type ISquare = 'X' | 'O' | null;とすれば、’X’もしくは’O’もしくはnullのみを許容する独自の型を定義することができます(リテラル型)。こちらの方が厳格に制約できるため、今回はこの方法でISquare型を定義し、引数squaresの型を「ISquare型が入った配列」に制約します。

type ISquare = 'X' | 'O' | null;
function calculateWinner(squares: Array<ISquare>) {
  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;
}

コンポーネントの引数の型を制約する

Squareコンポーネントの引数propsにエラーが出ているので、関数の引数と同様に型を制約していきます。

Squareコンポーネントの引数propsの型を下記の通り定義し、型注釈をつけます。

interface SquareProps {
  value: ISquare;
  onClick: () => void;
}
function Square(props: SquareProps) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

interfaceキーワードは、typeと同様に型を定義することができます。(ただしくは、interface→型の定義をする役割、type→他の型のエイリアスを作る役割/リテラル型の定義/Union型の定義/etc…など違いがありますが、ここではその説明は割愛させていただきます)。ここでは、SquarePropsという型を定義し、SquareコンポーネントがISquare型のvalue, 何も返さない無名関数のonClickを引数として受け取ることを型注釈で明示することができました。

同様に、Boardコンポーネントの引数の型も制約していきます。

interface BoardProps {
    squares: ISquare[];
    onClick: (i: number) => void;
}
class Board extends React.Component<BoardProps> {
  renderSquare(i: number) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

クラスベースのコンポーネントは、上記のようにReact.ComponentのGenericsを使うことで引数に型注釈をつけることができます。

Gameコンポーネントも同様に引数の型を制約しますが、Gameコンポーネントは引数を受け取らないので、下記のように{}で型注釈をつけます。

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

コンポーネントのStateの型を制約する

Gameコンポーネントのthis.state.xxxにエラーが出ています。

これは、GameコンポーネントのStateの型が制約されていないためです。下記の通りGameコンポーネントのStateの型を定義します。

type History = {
  squares: ISquare[];
};
interface GameState {
  history: History[];
  stepNumber: number;
  xIsNext: boolean;
}
class Game extends React.Component<{}, GameState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      history: [{ squares: Array(9).fill(null) }],
      stepNumber: 0,
      xIsNext: true,
    };
  }

React.ComponentのGenericsは、引数(型引数)が1つのみの場合はその型をPropsの型として、2つの場合は1つ目の型でProps、2つ目の型でStateの型を制約します。GameコンポーネントはPropsを受け取らないので{}としています。

ここまで作業をすすめると、TypeScriptのコンパイルエラーはすべて解消され、タイムスリップ機能付きのマルバツゲームが動作します。

ファイルを分割する

Reactの公式チュートリアルは、ブラウザでコードを書きながら学習を進められるようにしていることもあり、すべてのコードが1つのファイルにまとめて書かれています。1つのファイルに書かれているコードが長くなりすぎると、どこに何があるのかわかりにくくなる場合があります。

JavaScript→TypeScriptのリファクタが完了したこのタイミングで、下記の通りファイルを分割します。

.
├── README.md
├── node_modules
├── package.json
├── public
├── src
│   ├── components
│   │   ├── Board.tsx
│   │   ├── Game.tsx
│   │   └── Square.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── interface.ts
│   └── react-app-env.d.ts
├── tsconfig.json
└── yarn.lock

それぞれのファイルの中身はこんな感じです。

// index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import Game from './components/Game'
import './index.css';
ReactDOM.render(<Game />, document.getElementById('root'));
// interface.ts
export type ISquare = 'X' | 'O' | null;
export interface History {
  squares: ISquare[];
}
// Game.tsx
import React from "react";
import { ISquare, History } from '../interface'
import Board from './Board'
interface GameState {
  history: History[];
  stepNumber: number;
  xIsNext: boolean;
}
class Game extends React.Component<{}, GameState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      history: [{ squares: Array(9).fill(null) }],
      stepNumber: 0,
      xIsNext: true,
    };
  }
  handleClick(i: number) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    this.setState({
      history: history.concat([{ squares: squares }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }
  jumpTo(step: number) {
    this.setState({
      stepNumber: step,
      xIsNext: step % 2 === 0,
    });
  }
  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);
    const moves = history.map((step, move) => {
      const desc = move ? "Go to move #" + move : "Go to game start";
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });
    let status;
    if (winner) {
      status = "Winner: " + winner;
    } else {
      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
    }
    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i: number) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}
function calculateWinner(squares: Array<ISquare>) {
  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;
}
export default Game;
// Board.tsx
import React from 'react';
import { ISquare } from '../interface';
import Square from './Square'
interface BoardProps {
    squares: ISquare[];
    onClick: (i: number) => void;
}
class Board extends React.Component<BoardProps> {
  renderSquare(i: number) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }
  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}
export default Board;
// Square.tsx
import React from 'react';
import { ISquare } from '../interface';
interface SquareProps {
    value: ISquare;
    onClick: () => void;
  }
  function Square(props: SquareProps) {
    return (
      <button className="square" onClick={props.onClick}>
        {props.value}
      </button>
    );
  }
  export default Square;

クラスベースのコンポーネント→関数ベースのコンポーネント

マルバツゲームのコードのクラスベースのコンポーネントを全て関数ベースのコンポーネントに書き換えていきます。

Boardを関数コンポーネントに書き換え

Boardコンポーネントはクラスベースで書かれていますが、stateを利用していないのでHooksを使わずに関数ベースのコンポーネントに書き換えられます。

import React from 'react';
import { ISquare } from '../interface';
import Square from './Square'
interface BoardProps {
    squares: ISquare[];
    onClick: (i: number) => void;
}
function Board(props: BoardProps) {
  const renderSquare = (i: number) => {
    return (
      <Square
        value={props.squares[i]}
        onClick={() => props.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>
  );
}
export default Board;

BoardコンポーネントはReact.Componentクラスを継承したクラスでしたが、この書き換えによってBoardProps型の引数を受け取る関数になりました。クラスのプロパティだったpropsを関数の引数として扱うためthisキーワードを削除、JSXを返すrenderメソッドの中身を関数のreturnとしました。

関数コンポーネントをReact.FCの形式に書き換え

ここまでfunctionキーワードを使ってSquareコンポーネントとBoardコンポーネントを定義していましたが、これをReact.FC型とアロー関数を使った定義に書き換えます。まずは、Squareコンポーネントを下記の通り書き換えます。

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

React.FC型を使うことで、「これはReactの関数コンポーネントです」ということが明確にわかるようになります。React.FC型の定義を参照してみると(node_modules/@types/react/index.d.tsにあります)、React.FC型は下記のように定義してあります。

    type FC<P = {}> = FunctionComponent<P>;
    interface FunctionComponent<P = {}> {
        (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
        propTypes?: WeakValidationMap<P>;
        contextTypes?: ValidationMap<any>;
        defaultProps?: Partial<P>;
        displayName?: string;
    }

定義からも分かる通り、React.FC型で関数の型を制約すると、返却値の型もReactElement型に制約することができます。React.FC型とアロー関数を使わずにfunctionで返却値をReactElement型に制約しようとしたら、function Square(props: SquareProps): React.ReactElement {…のようになってしまいますので、記述量をかなり減らすことができます。

また、さらに記述量を減らしたい方は、下記のように書くと、関数内のprops.を省くこともできます。

const Square: React.FC<SquareProps> = ({ value, onClick }) => {
  return (
    <button className="square" onClick={onClick}>
      {value}
    </button>
  );
};

Squareコンポーネントと同様に、BoardコンポーネントもReact.FCとアロー関数の形で書き換えます。

const Board: React.FC<BoardProps> = ({ squares, onClick }) => {
  const renderSquare = (i: number) => {
    return (
      <Square
        value={squares[i]}
        onClick={() => onClick(i)}
      />
    );
  }

Gameコンポーネントは、次のセクションで関数コンポーネントに書き換えます。

Gameを関数コンポーネントに書き換え(Hooks使用)

これは、Gameコンポーネントのstateを初期化している記述です。

Gameクラスのプロパティであるstateをconstructorで初期化しています。

関数ベースのコンポーネントでクラスベースのコンポーネントのstateのような状態管理を実現するには、HooksのuseStateを使います。

import React, { useState } from "react";
import { ISquare, History } from '../interface'
import Board from './Board'
const Game: React.FC = () => {
  const [history, setHistory] = useState<History>([{ squares: Array(9).fill(null)}]);
  const [stepNumber, setStepNumber] = useState<number>(0);
  const [xIsNext, setXIsNext] = useState<boolean>(true);

(5行目)まず、GameコンポーネントをBoardコンポーネントやSquareコンポーネントと同様にReact.FCとアロー関数を使った形で書き換えます(文法エラーが大量に出るので、後で修正します)。クラスベースのコンポーネントではpropsの型(空)とstateの型をGenericsで設定していましたが、React.FC型では使用しません。GameStateを使わなくなったためです(理由は後述します)。

(1, 6-8行目)useState関数は、Genericsに定義したいstateの型、引数に定義したいstateの初期値を渡すと、[stateの値, stateを更新する関数]という形の配列を返します。Gameコンポーネントにはhistory, stepNumber, xIsNextの3つのstateが必要なので、それぞれuseState関数でstateとそれを更新する値を定義します。stateをそれぞれuseState関数で初期化するため、型も、GameState型で一括で制約するのではなく、それぞれのuseStateのGenericsに渡して制約しています。これが、GameState型を使わなくなった理由です。

文法エラーを解決していきます。

下記のように書き換えます。

  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";
    setHistory(_history.concat([{ squares: squares }]));
    setStepNumber(_history.length);
    setXIsNext(!xIsNext);
  }
  const jumpTo = (step: number) => {
    setStepNumber(step);
    setXIsNext(step % 2 === 0);
  }

(10, 25行目)handleClickやjumpToがクラスのメソッドの書き方になっているので、関数の中の関数として書き直します。

(11-13行目)クラスのプロパティとしてのstateを扱う書き方になっているので、useStateを使って定義したstate(history, stepNumber, xIsNext)を扱う書き方にします。このとき注意するのが、handleClickの中でhistoryとそのまま定義してしまうと、useStateを使って定義したhistoryと重複してしまうため、_historyとしています。

(20-22, 26-27行目)React.ComponentクラスのsetStateメソッドを使ってstateをセットする処理を、useStateからで定義したstateを更新する関数(setHistory, setStepNumber, setXIsNext)を使った処理に書き換えます。

最後に、renderメソッドを書き換えます。

下記のように書き換えます。

  // render() {
  const current = history[stepNumber];
  const winner = calculateWinner(current.squares);
  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>
    );
  });
  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = 'Next player: ' + (xIsNext ? 'X' : 'O');
  }
  return (
    <div className="game">
      <div className="game-board">
        <Board
          squares={current.squares}
          onClick={(i: number) => handleClick(i)}
        />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
      </div>
    </div>
  );
  // }

まず、Gameコンポーネントはクラスベースのコンポーネントではなくなり「current, status, movesなどの変数をrenderメソッドの中で用意してJSXをreturnする」必要がなくなったので、renderメソッドを削除してrenderメソッドの中にあった処理をそのまま外に出します。

(30行目)useStateで定義したhistoryとstepNumberを使ってcurrentを定義します。

(37, 54行目)メソッドを呼び出す処理を、関数を呼び出す処理に書き換えます。

(46行目)useStateで定義したxIsNextを使う処理に書き換えます。

Gameコンポーネントを関数コンポーネントに書き換えることができたため、マルバツゲームを構成するコンポーネントをすべて関数コンポーネントに書き換えることができました。

おまけ

「リファクタの目標」セクションで挙げた目標は達成しましたが、最後に少しだけコンポーネントの整理をしてリファクタを完了としたいと思います。

Gameコンポーネントのリファクタ

Gameコンポーネントの中にmovesというJSXを返す関数があります。JSXを返す関数をコンポーネントの中に持つのは、可読性を低下させる原因になる・テストしづらくなるなどの点からアンチパターンとされていることがあります。そのため、movesをMovesコンポーネントとして独立させます。

// Moves.tsx
import React from "react";
import { History } from "../interface";
interface MovesProps {
  history: History[];
  jumpTo: (move: number) => void;
}
const Moves: React.FC<MovesProps> = ({ history, jumpTo }) => {
  return (
    <ol>
      {history.map((_, move) => {
        const desc = move ? "Go to move #" + move : "Go to game start";
        return (
          <li key={move}>
            <button onClick={() => jumpTo(move)}>{desc}</button>
          </li>
        );
      })}
    </ol>
  );
};
export default Moves;
// Game.tsx
  return (
    <div className="game">
      <div className="game-board">
        <Board
          squares={current.squares}
          onClick={(i: number) => handleClick(i)}
        />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <Moves history={history} jumpTo={jumpTo}></Moves>
      </div>
    </div>
  );

Gameコンポーネントは、見た目の実装とロジックの実装が混在しているなど、まだリファクタすべき箇所ががありますが、記事が長くなりすぎるのでここまでにします。

Boardコンポーネントのリファクタ

GameコンポーネントのリファクタでJSXを返す関数を別のコンポーネントとして独立させたのと同様に、BoardコンポーネントもrenderSquare関数を使わないようにリファクタします。

import React from "react";
import { ISquare } from "../interface";
import Square from "./Square";
interface BoardProps {
  squares: ISquare[];
  onClick: (i: number) => void;
}
const Board: React.FC<BoardProps> = ({ squares, onClick }) => {
  return (
    <div>
      {[...Array(3)].map((_, i) => {
        return (
          <div className="board-row" key={i}>
            {[...Array(3)].map((_, j) => {
              const index = 3 * i + j;
              return (
                <Square
                  value={squares[index]}
                  onClick={() => onClick(index)}
                  key={j}
                ></Square>
              );
            })}
          </div>
        );
      })}
    </div>
  );
};
export default Board;

renderSquareを6つ並べるハードコーディングになっており、あまりイケてないコードでしたが、ループを2つ使ってうまくまとめることができました。なぜ[...Array(3)].map(~)のような書き方をしているかというと、JSXにはを埋め込むことはできるがを埋め込むことはできないためです。for文が使えないため、map関数を使ってループ処理をしています。Array(3)で要素数が3の配列を作り、それをスプレッド構文(…)で展開し、[undefined, undefined, undefined]を用意します。用意した配列にmap関数を使い、そのインデックスだけ利用しています。

まとめ

長くなりすぎてしまいました。Reactのチュートリアルを終えた方は、この記事にあるようになにか目当てを立ててリファクタをしたりすると、更にわかった気になれる理解が深まるのでおすすめです。書いたコードはこちらにまとめております。最後まで読んでいただき、ありがとうございました。

採用情報

メンバーズエッジで最高のチームで最高のプロダクトを作りませんか?

最高のプロダクトをつくる 最高のチームで働く

在宅でも、地方でも、首都圏でも。多様な働き方で最高のチームをつくり、お客様のプロダクトパートナーを目指します。アジャイル開発を通じ、開発現場の第一線で活躍し続けたいエンジニアを募集しています。