🌉

Next.js + TypeScript + Recoil + Herp社ESLint Config でReactチュートリアルを作る。

18 min read

説明のため、変数名やコメントは、一般的なレベルよりも説明的にしています。

制作したもの

https://github.com/mumei-xxxx/nextjs-recoil-tic-tac-toe-0

概要

Reactのチュートリアルの三目並べを Next.js 12 + TypeScript + Recoil + @herp-inc/eslint-config の構成で、新しめの記述を取り入れた形に書き直してみた。
(上記のリポジトリにすべてのコードを掲載した。)

コンセプトは、

  • TypeScriptの型チェックと厳しめのESLintルールでバグが起きづらい形にし、スケールしても内部品質を担保。
  • Next.jsで作ることにより、ブラウザでのパフォーマンスと開発体験の向上

である。

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

改良したいポイント

Reactのチュートリアルの三目並べ(マルバツゲーム)の公式サイトに掲載されている元のコードは以下である。

このcodeに関して、以下のような課題が考えられる。

  • Reactの書き方が、現在では非推奨のクラスコンポーネントである。(関数コンポーネント + React Hooks)。
  • 素のJavaScriptで書かれており型チェックが効いていない。
  • Gameコンポーネントに処理をやや詰め込みすぎ。

正直、これは個人によって見解が分かれる部分もあると思う。
Reactのクラスコンポーネントはともかく、今回の三目並べ程度の規模だとわざわざJavaScriptから、TypeScriptにする必要はないというのも見識だと私は考える。

この記事では、今回の三目並べをあくまで、これから大きな規模なスケールしていくアプリケーションと想定して、

  • Next.js 使用。
  • React 関数コンポーネント
  • TypeScript
  • Gameコンポーネントは、Recoil の状態管理を利用して、分割。

を行いたいと思う。
あくまで三目並べだけを作ると考えてみた場合、今回の構成は、オーバーエンジニアリング気味かもしれない。

これによって、元の構成よりも、アプリケーションがスケールした際、

  • 理解容易性(Understandability)、変更容易性(Modifiability)が担保される

と考える。

課題を解決する技術、手法の概要

  • Next.js
    Next.js はフレームワークのフォルダ構成がもとから決まっている。
    理解容易性(Understandability)という点から言えば、コードリーディングのさい、そのルールにそって読んでいけばよいので、理解はしやすくなると思われる。
    また、Next.js を採用することでパフォーマンス上の恩恵を得られる。

  • TypeScript

  • Recoil
    「三目並べの勝敗の決定機能」「ゲーム履歴機能」は別のコンポーネントに分ける。
    コンポーネント間で、stateを共有するため、少ないコードでグローバルステートを実現できる、Recoil を導入する。

  • @herp-inc/eslint-config
    コードの内部品質を向上するため、厳しめのLintルールである、@herp-inc/eslint-config を導入する。
    こちらは、実際に使用して、Airbnb社のESLintルールであるeslint-config-airbnb よりも厳しいと感じた。

https://github.com/herp-inc/eslint-config

上記を踏まえて、制作したもの

ディレクトリ構成

上記を踏まえて、開発を行った。
ディレクトリ構成は以下のようにした。

.
├── LICENSE
├── next.config.js
├── next-env.d.ts
├── package.json
├── package-lock.json
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── README.md
├── src
│   ├── components
│   │   ├── Board.tsx
│   │   ├── GameHistory.tsx
│   │   ├── Game.tsx
│   │   └── Square.tsx
│   ├── infrastructure
│   │   └── recoil
│   │       ├── historyAtom.ts
│   │       ├── index.ts
│   │       ├── stepNumberState.ts
│   │       └── xIsNextAtom.ts
│   ├── pages
│   │   ├── _app.tsx
│   │   └── index.tsx
│   ├── styles
│   │   ├── globals.css
│   │   └── Home.module.css
│   ├── @types
│   │   └── global.d.ts
│   └── useCases
│       └── calculateWinner.ts
├── tsconfig.json
└── yarn.lock

src配下に Clean Architecture を意識した、ディレクトリ構成にした。

本格的な Clean Architecture と比較すると、これが本当に Clean Architecture と言えるのかわからない。
しかし、Clean Architecture の「依存の方向性を一方向にする」というは、保つようにした。

  • src/components → src/useCases
  • src/components → src/infrastructure/recoil

src/useCases

useCases。ビジネスロジック層。ここでは、勝敗判定のロジック判定のファイルを置いている。

src/infrastructure/recoil

Recoilでの状態管理は、infrastructure 層に配置。

src/pages

こちらはNext.jsのデフォルトのルートコンポーネント。

src/components

React componentをまとめる。
src/pages とこの src/components は、Clean Architecture でのpresenter層。

src/@types

これは、あまり Clean Architecture は関係ない。TypeScript のグローバルな型を定義。

三目並べのプログラムのロジック

実装したコードを見ていく前に、この三目並べアプリケーションのロジックを簡単に説明していきたい。

まず、三目並べのルールを再度確認すると、

  • 先手、後手一手ずつ交代。
  • 先手なら、X。後手なら、Oを盤の上に記入する。
  • Xが縦、横、対角線上三マス埋まれば先手の勝ち。
  • Oが縦、横、対角線上三マス埋まれば後手の勝ち。

である。
直下のキャプチャでは、Xが対角線上に三マス埋まっているので、先手(X)の勝ちである。

これの三目並べ盤の状態、つまり、どのマスにXあるいは、Oが入っているかを配列で表すことを考える。

9マスなので、要素が9個の配列となる。
初期値を null と考えると以下のようになる。

 [null, null, null, null, null, null, null, null, null]

これを便宜上、この記事では、盤配列と呼ぶことにする。
上の配列を三目並べ盤に対応させると以下のようになる。

例えば、初手に先手が、真ん中にO
二手目に後手が、右下にXとすると配列は以下のような状態になる。

[null, null, null, null, 'X', null, null, null, 'O']

次に上に記載した、縦、横、対角線上に同じマーク(XかO)三マス埋まれば、そのマークの手番の勝ちというのをどう考えるか。

これは上の画像を見れば、例えば、例えば、右上から、左下の対角線一れるは、盤配列で言えば、index の 0,4,8 が同一のマークということだ。

ほかにも、真ん中、縦一列なら、index の 1,4,7 が同一のマークということになる。

以上のことを踏まえて、次は、TypeScript での型を考えたい。
盤配列の要素の値は、null or X or O なので、

src/@types/global.d.ts
// 升目に入りうる値
type SquareValueType = null | 'X' | 'O'

となる。

盤配列の型は、

type BoardArrType = SquareValueType[]

となる。

以上を踏まえて、

元記事の calculateWinner を書き直したのが、以下だ。

src/useCases/calculateWinner.ts
/**
 * @description [null, null, null, null, null, null, 'X', null, 'O']のような配列
 */
type BoardArrType = SquareValueType[]

/**
 * @description 勝者を判定する。
 */
export const calculateWinner = (squares: BoardArrType): SquareValueType => {
  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 (const line of lines) {
    const [a, b, c] = line
    // 縦、横、対角線 で3つXあるいは、Oが連続すれば、連続したvalueを返す。
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a]
    }
  }
  return null
}

ここでの元コードからの修正は、主に引数と戻り値に型をつけた。
また、ESLint ルールの prefer-for-of で for文を、for (let i = 0; i < lines.length; i++) から、for (const line of lines) に書き直した。

https://github.com/typescript-eslint/typescript-eslint/blob/v4.32.0/packages/eslint-plugin/docs/rules/prefer-for-of.md

続いて、Reactのコンポーネントの TypeScript化。

Squareコンポーネント、Boardコンポーネント

src/components/Square.tsx
interface SquarePropsType {
  value: SquareValueType
  onClick: () => void
}

/**
 * @description 三目ならべの升目のコンポーネント
 */
export const Square: React.FC<SquarePropsType> = ({ value, onClick }) => {

  return (
    <button className="square" onClick={onClick}>
      {value}
    </button>
  )
}
src/components/Board.tsx
import { Square } from './Square'

interface BoardPropsType {
  squares: SquareValueType[]
  onClick: (i: number) => void
}

/**
 * @description 三目ならべの盤面のコンポーネント
 */
export const Board: React.FC<BoardPropsType> = (props) => {

  const renderSquare = (i: number): JSX.Element => {
    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>
  )
}

SquareコンポーネントとBoardコンポーネント。

Square、升には、SquareValueType(null or 'X' or 'O')が入る。
また、次にクリック時のイベントとして、onClick 関数が入る。

Gameコンポーネント

src/components/Game.tsx
import React from 'react'
import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil'

import { Board } from '@/components/Board'
import { GameHistory } from '@/components/GameHistory'
import { historyState, stepNumberState, xIsNextState } from '@/infrastructure/recoil'
import { calculateWinner } from '@/useCases/calculateWinner'

/**
 * @description 三目ならべのルートコンポーネント
 */
export const Game: React.FC = () => {

  // ゲーム履歴の配列
  const historyArr: GameHistoryArrType = useRecoilValue(historyState)
  const setHistory: SetterOrUpdater<GameHistoryArrType> = useSetRecoilState(historyState)

  // 現在のステップ数(何手目か。)
  const stepNumber: number = useRecoilValue(stepNumberState)
  const setStepNumber: SetterOrUpdater<number> = useSetRecoilState(stepNumberState)

  // X(先手)の手番か。
  const xIsNext: boolean = useRecoilValue(xIsNextState)
  const setXIsNext: SetterOrUpdater<boolean> = useSetRecoilState(xIsNextState)

  // 升目をクリックしたときに以下のstateを更新する。
  // ゲーム履歴配列に新しく履歴追加。
  // 手数と次の手番更新。
  const handleClick = (indexNumber: number): void => {
    const beforeUpdatedHistory: GameHistoryArrType = historyArr.slice(0, stepNumber + 1)

    const current: { squares: SquareValueType[] } = beforeUpdatedHistory[beforeUpdatedHistory.length - 1]
    const squaresArr: SquareValueType[] = current.squares.slice()

    const isNotSquaresNull = squaresArr[indexNumber] !== null

    if (calculateWinner(squaresArr) || isNotSquaresNull) {
      return
    }

    // 三目並べ盤配列に、クリックを反映。
    // 先手なら、X。後手なら、O
    squaresArr[indexNumber] = xIsNext ? 'X' : 'O'

    // ゲーム履歴に最新の盤配列の状態を反映。
    const updatedHistory: GameHistoryArrType = beforeUpdatedHistory.concat([{ squares: squaresArr }])

    // Recoil で state をアップデート
    setHistory(updatedHistory)
    setStepNumber(beforeUpdatedHistory.length)
    setXIsNext(!xIsNext)
  }

  const current: { squares: SquareValueType[] } = historyArr[stepNumber]

  return (
    <div className="game">
      <div className="game-board">
        <Board
          squares={current.squares}
          onClick={(i) => {
            handleClick(i)
          }}
        />
      </div>
      <div className="game-info">
        <GameHistory />
      </div>
    </div>
  )
}

まず、ゲーム履歴を以下のような、盤配列の配列として、表現する。

[
  { squares: [null, null, null, null, null, null, null, null, null] }, // 初期状態
  { squares: [null, null, null, null, null, null, null, 'X', null] }, // 1手目
  { squares: [null, null, null, null, 'O', null, null, 'X', null] } // 2手目
]

この配列の名を便宜的にゲーム履歴配列と呼ぶ。
ゲーム履歴配列GameHistoryArrTypeの型は以下のようになる。

src/@types/global.d.ts
// 升目に入りうる値
type SquareValueType = null | 'X' | 'O'

// 追記 ゲーム履歴配列の型。
type GameHistoryArrType = Array<{ squares: SquareValueType[] }>

GameHistoryArrType はグローバルに定義している。
グローバルに定義するメリットデメリットがある。
VSCode(Windows環境)の場合だと、例えば、GameHistoryArrType をカーソルでホバーしてCtrlキーを押しながら、クリックすれば、src/@types/global.d.tsに飛ぶことができる。
したがって、今回はグローバルに定義することで、極端にコードが追いづらくなることもないと思い、グローバルに定義してみた。
(ここらへんのベストプラクティスは模索中。)

ちなみに、この型を、{ squares: SquareValueType[] }[]と書いたら、@typescript-eslint/array-type に引っかかった。

2元配列などの単純でない配列の型は、Array<T> という表記に統一したほうが、可読性があがるとのこと。

https://github.com/typescript-eslint/typescript-eslint/blob/v4.32.0/packages/eslint-plugin/docs/rules/array-type.md#array-simple

続いて、Recoilで管理しているstateについて。

// ゲーム履歴の配列
const historyArr: HistoryType = useRecoilValue(historyState)
const setHistory: SetterOrUpdater<HistoryType> = useSetRecoilState(historyState)

// 現在のステップ数(何手目か。)
const stepNumber: number = useRecoilValue(stepNumberState)
const setStepNumber: SetterOrUpdater<number> = useSetRecoilState(stepNumberState)

// X(先手)の手番か。
const xIsNext: boolean = useRecoilValue(xIsNextState)
const setXIsNext: SetterOrUpdater<boolean> = useSetRecoilState(xIsNextState)

historyArrstepNumberxIsNext は state。
setHistorysetStepNumbersetXIsNext は state を更新する関数である。(役割は、React Hooks の useState の setXXX と同じ。)

続いて、handleClick 関数について。
ここでは、升目がクリックされたときに、ゲーム履歴と手数と手番のstateを更新する。
元コードから、変数 history の命名の重複を改善した。

// 升目をクリックしたときに以下のstateを更新する。
// ゲーム履歴配列に新しく履歴追加。
// 手数と次の手番更新。
const handleClick = (indexNumber: number): void => {
  const beforeUpdatedHistory: GameHistoryArrType = historyArr.slice(0, stepNumber + 1)

  const current: { squares: SquareValueType[] } = beforeUpdatedHistory[beforeUpdatedHistory.length - 1]
  const squaresArr: SquareValueType[] = current.squares.slice()

  const isNotSquaresNull = squaresArr[indexNumber] !== null

  if (calculateWinner(squaresArr) || isNotSquaresNull) {
    return
  }

  // 三目並べ盤配列に、クリックを反映。
  // 先手なら、X。後手なら、O
  squaresArr[indexNumber] = xIsNext ? 'X' : 'O'

  // ゲーム履歴に最新の盤配列の状態を反映。
  const updatedHistory: GameHistoryArrType = beforeUpdatedHistory.concat([{ squares: squaresArr }])

  // Recoil で state をアップデート
  setHistory(updatedHistory)
  setStepNumber(beforeUpdatedHistory.length)
  setXIsNext(!xIsNext)
}

ゲーム履歴コンポーネント。

分割したゲーム履歴コンポーネントは以下のようになる。

src/components/GameHistory.tsx
import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil'

import { historyState, stepNumberState, xIsNextState } from '@/infrastructure/recoil'
import { calculateWinner } from '@/useCases/calculateWinner'

/**
 * @description 三目ならべのゲーム履歴機能のコンポーネント
 */
export const GameHistory: React.FC = () => {

  // ゲーム履歴の配列
  const historyArr: GameHistoryArrType = useRecoilValue(historyState)

  // 現在のステップ数(何手目か。)
  const stepNumber: number = useRecoilValue(stepNumberState)
  const setStepNumber: SetterOrUpdater<number> = useSetRecoilState(stepNumberState)

  // X(先手)の手番か。
  const xIsNext: boolean = useRecoilValue(xIsNextState)
  const setXIsNext: SetterOrUpdater<boolean> = useSetRecoilState(xIsNextState)

  const current: { squares: SquareValueType[] } = historyArr[stepNumber]

  // 勝者判定
  const winner: SquareValueType = calculateWinner(current.squares)

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

  // N手目に戻る。or ゲームを最初から始める。
  const moves: JSX.Element[] = historyArr.map((step, moveNumber: number) => {

    const description: string = moveNumber ? `Go to move #${moveNumber}` : 'Go to game start'

    return (
      <li key={moveNumber}>
        <button
          onClick={() => {
            jumpTo(moveNumber)
          }}
        >
          {description}
        </button>
      </li>
    )
  })

  // 勝者 or 次の手番の表示
  const getStatus = (winnerStr: SquareValueType): string => {
    if (winnerStr) {
      return 'Winner: ' + winnerStr
    }
    return 'Next player: ' + (xIsNext ? 'X' : 'O')
  }

  const status: string = getStatus(winner)

  return (
    <div className="game-info">
      <div>{status}</div>
      <ol>{moves}</ol>
    </div>
  )
}

以下、解説。

// ゲーム履歴の配列
const historyArr: GameHistoryArrType = useRecoilValue(historyState)

// 現在のステップ数(何手目か。)
const stepNumber: number = useRecoilValue(stepNumberState)
const setStepNumber: SetterOrUpdater<number> = useSetRecoilState(stepNumberState)

// X(先手)の手番か。
const xIsNext: boolean = useRecoilValue(xIsNextState)
const setXIsNext: SetterOrUpdater<boolean> = useSetRecoilState(xIsNextState)

ここら辺は上記の Gameコンポーネントと同様である。
Recoil で state と更新用の関数を定義している。

更新用関数は、特定の履歴にジャンプするためのjumpTo関数に使っている。

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

Recoil の利用

先にコンポーネント側での Recoil の利用を書いた。
今回実装してみて、ほとんど React の useState と同じ感覚で書けることがわかった。
次に、この Next.js での Recoil に利用について、飛ばしていた部分を書いていこうと思う。

まず、Next.js の _app.tsx 内のコンポーネントを RecoilRoot でラップする。

src/pages/_app.tsx
import '../styles/globals.css'
import type { AppProps /*, AppContext */ } from 'next/app'
import { RecoilRoot } from 'recoil'

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
  return (
    <RecoilRoot>
      <Component {...pageProps} />
    </RecoilRoot>
  )
}

// eslint-disable-next-line import/no-default-export
export default MyApp

次に Atom で初期stateを定義する。
key は、そのAtomにユニークなkeyを、defalt は、初期値を設定する。

src/infrastructure/recoil/historyAtom.ts
import { atom } from 'recoil'

/**
 * @description ゲーム履歴配列のstate
 */
export const historyState = atom<GameHistoryArrType>({
  key: 'tic-tac-toe/historyState',
  default: new Array({ squares: Array(9).fill(null) })
})
src/infrastructure/recoil/xIsNextAtom.ts
/**
 * @description X(先手)の手番なら、true。
 */
export const xIsNextState = atom<boolean>({
  key: 'tic-tac-toe/xIsNextState',
  default: true
})
src/infrastructure/recoil/stepNumberState.ts
import { atom } from 'recoil'

/**
 * @description 現在のステップ数(何手目か)のstate
 */
export const stepNumberState = atom<number>({
  key: 'tic-tac-toe/stepNumberState',
  default: 0
})
src/infrastructure/recoil/index.ts
export { historyState } from "./historyAtom"
export { xIsNextState } from "./xIsNextAtom"
export { stepNumberState } from "./stepNumberState"

ここで、定義した historyState、xIsNextState、stepNumberState などの Atom を上述したようにコンポーネントで使う。

(再掲)src/components/Game.tsx
// ゲーム履歴の配列のstate
const historyArr: HistoryType = useRecoilValue(historyState)
// ゲーム履歴の配列のstateの更新関数
const setHistory: SetterOrUpdater<HistoryType> = useSetRecoilState(historyState)

useRecoilValueuseSetRecoilStateなどの Recoil の API の詳細な解説は、公式ドキュメントなどに譲る。
簡単に言えば、ここでは、useRecoilValue(<Atom>)で現在の state を取得している。
useSetRecoilState(<Atom>)で現在のstateの更新関数を取得できる。

昨年、Redux Toolkit を業務で触ってみて、それとの比較だと、Recoil のほうがワンステップ少なくコードを記述できる点がよいと思った。
Redux Toolkit の場合、Slice で定義した、reducer を configureStore などでひとつの Store に束ねるコードが必要になる。

Redux Toolkit 公式のチュートリアルの例:

https://redux-toolkit.js.org/tutorials/quick-start

Recoil の場合、上で見てきたように、Atom で定義したものをいきなり Reactコンポーネント側で使える場合もあるので、その点は楽である。
(2022年1月現在、Recoilは、まだ実験段階のライブラリであるようだ。)

感想

  • TypeScript + 厳しめのESLintルート(当記事では、@herp-inc/eslint-config)にすると、書き方が拘束されるので、集団で開発してもある程度、コードの質は担保できそう。
  • Recoil が useState とあまり変わらない感覚で書けるので楽。
  • Next.js 上で開発すると、ある程度の環境とパフォーマンスがお膳立てされてるので保守は楽そう。

Discussion

ログインするとコメントできます