注目キーワード
  1. react
  2. docker
  3. インターン

Reactのチュートリアルをhooks + TypeScriptでモダンな仕様にリファクタしてみた

この記事はCyberAgent20新卒エンジニアAdvent Calendar 2019の14日目の記事です。

reactの入門と言えば本家の丸ばつゲームを作るReactチュートリアルが有名ですが、少なくとも僕が勉強を始めた3年ぐらい前からサンプルのコード自体は変更されていません。(知らぬまに公式が日本語に対応していましたがw)

hooksが登場したあたりからFacebookがこれからはクラスベースじゃなくて関数ベースのコンポーネントを使っていくようにしようね、とアナウンスしてるにも関わらず公式はクラスベースのままですし、時代はTypeScriptなのにJavaScript(しかもコードが読みにくく結構危険なコードを含んでいる)で書かれています。そこでhooksとTypeScriptを使ってモダンな仕様にリファクタしてみることにしました。

これからReactを勉強しようとしているけどモダンな書き方がわからないという人の助けになれば幸いです!

本家のチュートリアル

コード

本家のコードをそのまま引っ張ってきました。

コード(長いので閉じています。右の+を押すと展開します。)
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>
    );
  }
}

// ========================================

ReactDOM.render(<Game />, document.getElementById("root"));

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

このコードの問題点

冒頭で述べた点を含めこのコードの問題点として以下のことがあげられます。

クラスベース

開発元のFacebookが関数ベースで実装しようね〜とアナウンスしてるので、クラスをやめて関数ベースで実装しましょう。クラスベースが混在しても問題はないのですが、せっかくこれから学ぶのであれば最新のシンタックスで書くに越したことはありません。また、hooksも積極的に使っていきましょう。

生のJavaScript

生のJavaScriptで書くなんて恐ろしいですね。巨大なanyがあなたに襲い掛かります。

TypeScriptにすべきです。多少の学習コストはありますが、一度覚えてしまえば開発をめちゃくちゃ加速させられるので最初のうちに覚えてしまいましょう!

immutableでない処理を含んでいる

うまくコピーできていないと参照を書き換えてしまって副作用を持つ関数やコンポーネントになってしまいバグの原因となりますできるだけimmutableに処理するようにした方がバグが減るので新しい配列やObjectを返すように実装しましょう。

ロジックと状態とviewが混在している

それはパスタですね。分離しましょう。

また、チュートリアル自体はブラウザ上のCodePenで実装することを想定してあるので仕方ないことですが、せっかくなのでファイル分割もして綺麗にまとめてしまいましょう。

renderHoge()のようなjsxを返す関数を定義している

renderHoge()のようなjsxまたは配列を返す関数はかなり可読性を下げてしまうので、render()内(関数コンポーネントならreturnの中)に書いちゃいましょう。そこに書くには複雑すぎるならコンポーネントとして切り出すべきです。責務を分離して可読性と保守性を向上させましょう。

関数ベース + hooksに書き換える

そもそもhooksとは

hooksとは、React v16.8から追加されたAPIで簡単にまとめると関数コンポーネントでもクラスベースのコンポーネントと同等のこと+αができるようになるAPIと思っておけば大丈夫です。

おそらくよく使うのはuseStateuseEffectなので、この2つさえ覚えておけばおkです。

useState

useStateはクラスコンポーネントでいうthis.stateを管理するAPIです。

import React, { useState } from 'react';

function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

上記の例だとsetCountに入れた値がcountに反映されるというシンプルな形になっています。また、this.state.countcountになるので記述もかなりシンプルにすることができます。

useEffect

useEffectはコンポーネントのライフサイクルによって発火する関数です。conponentDidMountconpomentWillUpdateなどを一括で管理できるAPIです。

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffectの第二引数に監視したい変数を配列で渡すとその変数が変更されたときにのみ実行されます。たとえば、

useEffect(() => { 
  document.title = `You clicked ${count} times`; 
}, [count]);

とするとcountが変更されたときだけ呼び出されるようになります。空の配列を渡しておくと初回render時にのみ実行されます。

詳しい説明は公式ドキュメントを読んでください!

ユーザインターフェース構築のための JavaScript ライブラリ…

hooksの導入

コードの変更

hooks導入後のコード(横の+で展開)
import React, { useState } from "react";
import ReactDOM from "react-dom";

const Square = ({ value, onClick }) => {
  // ...
};

const Board = ({ squares, onClick }) => {
  // ...
};

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

  const handleClick = i => {
    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 }]));
    setStepNumber(history.length);
    setXIsNext(!xIsNext);
  };

  const jumpTo = step => {
    setStepNumber(step);
    setXIsNext(step % 2 === 0);
  };

  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={handleClick} />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
      </div>
    </div>
  );
};

ReactDOM.render(<Game />, document.getElementById("root"));

function calculateWinner(squares) {
  // ...
}

Gameコンポーネントのみデータを保持していたのでこのコンポーネントのみを変更しました。他のコンポーネントも一部アロー関数にしていますが、それほど大きな変更は加えていません。

もっとも大きな変更は、

  const [history, setHistory] = useState([
    {
      squares: Array(9).fill(null)
    }
  ]);
  const [stepNumber, setStepNumber] = useState(0);
  const [xIsNext, setXIsNext] = useState(true);

の部分です。これまで、this.state={ ... }の形で宣言していたものをこのように宣言するように変更しました。1つの変数で管理してもいいのですが、ネストの深いデータはバグの元になるので今回は別々の変数で定義しています。それによってセッターも3つ生成されています。

また、状態のセットの方法が変わったのでイベントハンドラの実装も変更を加えました。

  const handleClick = i => {
    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 }]));
    setStepNumber(history.length);
    setXIsNext(!xIsNext);
  };

  const jumpTo = step => {
    setStepNumber(step);
    setXIsNext(step % 2 === 0);
  };

これまで一気にセットしていたところをそれぞれの状態ごとに変更をセットするようになりました。一気にsetStateするよりもスコープが小さいので相互に影響し合ってバグが生じるという可能性も小さくすることができます。

hooksのデメリット

ここまでの内容だけだといいこと尽くしのように思えますが、もちろんデメリットもあります。

それは、一個一個宣言していくので変数が大量に生成されてしまうということです。名前空間でスコープを作ることはできますが、どういった規則で作るのかという課題もあるので、そこだけは難点です。

とはいえ、今までのAPIよりも状態管理がしやすくなっているのでどんどん使っていきましょう!

TypeScriptを導入しよう

TypeScriptを導入するメリット

TypeScriptを導入することで受けられる一番のメリットは、型の情報があるためコードを読んだり関数を使うときに劇的に開発速度が上がります。

「型なんてなくてもコード読めるし、なんなら型を定義しているだけ時間の無駄!」と思う方もいるかもしれません。では、以下のコードのような状況になっても同じことが言えるでしょうか?

const hoge = hoge => {
  const folloers = hoge.users.map(user => {
    if (user.followers) {
      return user.followers.map(follower => ({
        id: follower.id,
        name: follower.name,
        likes: folloers.likes.filter(like => isRecently(like))
      }));
    }

    return null;
  });

  if (folloers) {
    return { ...hoge, users: { ...users, followers } };
  }
};

世にも恐ろしいコードですね。。これでパッとみてデータ構造がわかるでしょうか?しかももしこのデータをサーバーから取得しているとしたら全てのデータが入ったサンプルのObjectすらどこにも定義されていないかもしれません。架空のコードで僕が3分ぐらいで適当に書いただけですが、それでもこれぐらい複雑になってしまいます。

TypeScriptならこの引数hogeに何が入ってくるのかを定義することができるようになりかなり安全にコードがかけるようになります。nullになりうるところにアクセスしようとしていたり、そもそも存在しないプロパティにアクセスしようとしていた場合はコンパイル時でエラーを教えてくれますしそもそもエディターに怒られます。

これだけでもconsole.log()を大量に仕込んでデバッグするという状況はかなり減らせて開発速度をあげることができます。

また、IDEだとhoverしたり何かしらのショートカットを押すことで変数の型をみたり定義元にジャンプしたりできるようになります。

TypeScriptの導入

以下が実際にTypeScriptを導入したコードです。

タイトル
import React, { useState } from "react";
import ReactDOM from "react-dom";

interface BoardProps {
  squares: ISquare[];
  onClick: (i: number) => void;
}
interface SquareProps {
  value: ISquare;
  onClick: () => void;
}
interface HistoryElement {
  squares: ISquare[];
}
type History = HistoryElement[];
type ISquare = "X" | "O" | null;

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

const Board: React.SFC<BoardProps> = ({ squares, onClick }) => {
  const renderSquare = (i: number) => (
    <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 = () => {
  const [history, setHistory] = useState<History>([
    {
      squares: Array<ISquare>(9).fill(null)
    }
  ]);
  const [stepNumber, setStepNumber] = useState(0);
  const [xIsNext, setXIsNext] = useState(true);

  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 }]));
    setStepNumber(history.length);
    setXIsNext(!xIsNext);
  };

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

  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={handleClick} />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
      </div>
    </div>
  );
};

ReactDOM.render(<Game />, document.getElementById("root"));

function calculateWinner(squares: 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;
}

実はそんなに記述量は増えません。これで完璧に型情報を付与できているのでコードを書くのも読むのも一部を利用するのもとても簡単になります。

interfaceを定義することで、そのコンポーネントにどんなプロパティがあるのかがすぐにわかるようになり、わざわざ実装されているコードをみに行ったりドキュメントを読んで試行錯誤しなくてよくなります。

interface BoardProps {
  squares: ISquare[];
  onClick: (i: number) => void;
}

例えば、上記のようなinterfaceの場合は盤面の情報であるsquaresとイベントハンドラであるonClickを渡せばいいとわかります。また、引数も型を指定しているので、おそらくindexなんだろうなということがコードを読まなくてもわかります。

また、

type ISquare = "X" | "O" | null;

のように文字列を型として定義することで受け取れる文字列限定することもできます。

interfaceを定義したぐらいでそれほどコードに変更はありませんが、これでかなり可読性と安全性を向上させることができました。

以下のファイルがここまでのコードです。

GitHub

Contribute to Dragon-taro/modern-tutorial development by cre…

ファイルを分割しよう

続いて、今まで1つのファイルで書いていたのを複数のファイルに分割しましょう。200行ぐらいを超えてくると可読性が下がりどこに何が書いてあるのかわからなってしまいます。

├── components
│   ├── Board.tsx
│   ├── Game.tsx
│   ├── Moves.tsx
│   └── Square.tsx
├── domain
│   ├── entity.ts
└── index.tsx

今回は上記のようにファイルを分割しました。それぞれのコンポーネントを移動させただけなので、詳細なコードに関しては以下のコミットのtreeをみてください。

GitHub

Contribute to Dragon-taro/modern-tutorial development by cre…

また、entityというディレクトリをコンポーネントとは独立に作成しました。この中のentityにはアプリケーション固有のデータ構造を書いていきます。アプリケーション固有のデータ構造とは、これまでに定義したinterfaceのうち状態やロジックに関わるデータ構造です。

Movesという見慣れないコンポーネントがあると思いますが、これはmovesというjsxを返す関数をコンポーネント内で定義していたものを1つのコンポーネントとしてくくり出したものです。コンポーネント内でjsxを返す関数を定義するのは可読性を下げる原因になるのでアンチパターンとされています。

type ISquare = "X" | "O" | null;

例えばこれはこのアプリケーション固有のデータ構造です。なぜ分離して管理するようにしたかというと、このデータ構造はアプリケーションに依存すべきであってviewに依存すべきでないからです。複数のコンポーネントから使う可能性があるデータ構造は独立して管理できるようにするのが好ましいです。

リファクタしよう

冒頭でも述べたようにこのコードは一部危険なコードを含んでいたりロジックとviewがごちゃまぜになっていたりしていいコードとは言えないのでいい感じに分離しましょう。

チュートリアルはどうしても小さく始めることを意識しているためとにかく動くものを作る傾向があります。それはそれで大切なことなのですが、設計の部分も触れておかないといざ自分で何かを作ろうと思ったときにどこに何を書いていいのかわからないということが起こりかねないのでこの記事ではそこまで説明することにします。(実際に僕もここは苦しんだところです。)

renderHoge()は廃止しよう

先ほどもMovesで触れましたが、jsxを返す関数をコンポーネント内で持つのはよくありません。Boardコンポーネントにもこのような関数が残っています。ただ、このコンポーネントでは単純にmapして値を渡しているだけなのでそれほど大した処理をしていません。こういう場合はjsx内に直接mapを書いてしまうのが良いでしょう。

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

  // mapでまとめて書くように
  return (
    <div className="board">
      {Array<number>(9)
        .fill(0)
        .map((_, i) => (
          <Square key={i} value={squares[i]} onClick={() => onClick(i)} />
        ))}
    </div>
  );
};

export default Board;

このように変更しました。

ロジックを分離しよう

続いてロジックを分離しましょう。ロジックを分離することで以下のような利点が得られます。

  • コンポーネントのコードがスッキリして見通しがよくなる
  • ロジックの再利用性が高まる
  • テストしやすくなる

今回は、domain/services.jsというファイルを作ってそこにまとめました。ロジックが増えればここをディレクトリにしてさらにファイルを分割するのがよいでしょう。

import { ISquare, Histories } from "./entity";

/**
 * 現在の盤面からゲームが終わったかどうかを判定
 * @param squares 現在の盤面
 */
export const calculateWinner = (squares: 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;
}

/**
 * 表示するステータスを返す関数
 * @param winner 勝者の文字列(決着がついていないときは`null`)
 * @param xIsNext 次がxかどうかのフラグ
 */
export const getStatus = (winner: ISquare, xIsNext: boolean) => {
  // テンプレート文字列を使う
  if (winner) {
    return `Winner: ${winner}`;
  } else {
    const nextPlayer = xIsNext ? "X" : "O";
    return `Next player: ${nextPlayer}`;
  }
};

/**
 * クリックイベント後の盤面の新しい配列を返す関数
 * @param squares 盤面の状態
 * @param xIsNext 次がxかどうか
 * @param i 何番目の要素が変更されたのか
 */
export const createNewSquares = (
  squares: ISquare[],
  xIsNext: boolean,
  i: number
) =>
  // mapで新しい配列を返すように変更
  squares.map((square, index) => {
    if (i === index) {
      return xIsNext ? "X" : "O";
    }

    return square;
  });

一部冗長なコードがあったので不要なロジックの削除等も行なっています。

このリファクタでGameコンポーンットはかなりスッキリしました。

import React, { useState } from "react";

import { Histories, ISquare } from "../domain/entity";
import {
  calculateWinner,
  getStatus,
  createNewSquares
} from "../domain/services";
import Board from "./Board";
import Moves from "./Moves";

const Game = () => {
  const [histories, setHistory] = useState<Histories>([
    {
      squares: Array<ISquare>(9).fill(null)
    }
  ]);
  const [stepNumber, setStepNumber] = useState(0);
  const [xIsNext, setXIsNext] = useState(true);

  const handleClick = (i: number) => {
    // 参照かどうかを気にしなくていいので完結にかける
    const _squares = histories[stepNumber]?.squares;

    // すでに勝者が決まっている場合 or すでに選んだボタンのときはbreak
    if (calculateWinner(_squares) || _squares[i]) {
      return;
    }

    // imuutableに
    // squares[i] = xIsNext ? "X" : "O";
    const squares = createNewSquares(_squares, xIsNext, i);

    const newHistories = [...histories, { squares }];

    setHistory(newHistories);
    setStepNumber(histories.length);
    setXIsNext(!xIsNext);
  };

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

  const current = histories[stepNumber];

  const winner = calculateWinner(current.squares);

  // renderする関数を書くのは基本NG
  //   const moves = histories.map((_, move) => {
  //     const desc = move ? "Go to move #" + move : "Go to game start";
  //     return (
  //       <li key={move}>
  //         <button onClick={() => jumpTo(move)}>{desc}</button>
  //       </li>
  //     );
  //   });

  const status = getStatus(winner, xIsNext);

  return (
    <div className="game">
      <div className="game-board">
        <Board squares={current.squares} onClick={handleClick} />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <Moves histories={histories} jumpTo={jumpTo} />
      </div>
    </div>
  );
};

export default Game;

これでリファクタも終わり完成しました。

完成版のコードはここに上げています。

GitHub

Contribute to Dragon-taro/modern-tutorial development by cre…