Next.js + TypeScript + Recoil + Herp社ESLint Config でReactチュートリアルを作る。
制作したもの
概要
React のチュートリアルの三目並べを Next.js 12 + TypeScript + Recoil + @herp-inc/eslint-config の構成で、新しめの記述を取り入れた形に書き直してみた。
(上記のリポジトリにすべてのコードを掲載した。)
コンセプトは、
- TypeScriptの型チェックと厳しめのESLintルールでバグが起きづらい形にし、スケールしても内部品質を担保。
- Next.jsで作ることにより、ブラウザでのパフォーマンスと開発体験の向上
である。
改良したいポイント
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 よりも厳しいと感じた。
上記を踏まえて、制作したもの
ディレクトリ構成
上記を踏まえて、開発を行った。
ディレクトリ構成は以下のようにした。
.
├── 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 なので、
// 升目に入りうる値
type SquareValueType = null | 'X' | 'O'
となる。
盤配列の型は、
type BoardArrType = SquareValueType[]
となる。
以上を踏まえて、
元記事の calculateWinner を書き直したのが、以下だ。
/**
* @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)
に書き直した。
続いて、React のコンポーネントの TypeScript 化。
Squareコンポーネント、Boardコンポーネント
interface SquarePropsType {
value: SquareValueType
onClick: () => void
}
/**
* @description 三目ならべの升目のコンポーネント
*/
export const Square: React.FC<SquarePropsType> = ({ value, onClick }) => {
return (
<button className="square" onClick={onClick}>
{value}
</button>
)
}
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コンポーネント
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
の型は以下のようになる。
// 升目に入りうる値
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>
という表記に統一したほうが、可読性があがるとのこと。
続いて、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)
historyArr
、stepNumber
、xIsNext
は state。
setHistory
、setStepNumber
、setXIsNext
は 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)
}
ゲーム履歴コンポーネント。
分割したゲーム履歴コンポーネントは以下のようになる。
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
でラップする。
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 は、初期値を設定する。
import { atom } from 'recoil'
/**
* @description ゲーム履歴配列のstate
*/
export const historyState = atom<GameHistoryArrType>({
key: 'tic-tac-toe/historyState',
default: new Array({ squares: Array(9).fill(null) })
})
/**
* @description X(先手)の手番なら、true。
*/
export const xIsNextState = atom<boolean>({
key: 'tic-tac-toe/xIsNextState',
default: true
})
import { atom } from 'recoil'
/**
* @description 現在のステップ数(何手目か)のstate
*/
export const stepNumberState = atom<number>({
key: 'tic-tac-toe/stepNumberState',
default: 0
})
export { historyState } from "./historyAtom"
export { xIsNextState } from "./xIsNextAtom"
export { stepNumberState } from "./stepNumberState"
ここで、定義した historyState、xIsNextState、stepNumberState などの Atom を上述したようにコンポーネントで使う。
// ゲーム履歴の配列のstate
const historyArr: HistoryType = useRecoilValue(historyState)
// ゲーム履歴の配列のstateの更新関数
const setHistory: SetterOrUpdater<HistoryType> = useSetRecoilState(historyState)
useRecoilValue
やuseSetRecoilState
などの Recoil の API の詳細な解説は、公式ドキュメントなどに譲る。
簡単に言えば、ここでは、useRecoilValue(<Atom>)
で現在の state を取得している。
useSetRecoilState(<Atom>)
で現在の state の更新関数を取得できる。
昨年、Redux Toolkit を業務で触ってみて、それとの比較だと、Recoil のほうがワンステップ少なくコードを記述できる点がよいと思った。
Redux Toolkit の場合、Slice で定義した、reducer を configureStore などでひとつの Store に束ねるコードが必要になる。
Redux Toolkit 公式のチュートリアルの例:
Recoil の場合、上で見てきたように、Atom で定義したものをいきなり React コンポーネント側で使える場合もあるので、その点は楽である。
(2022年1月現在、Recoil は、まだ実験段階のライブラリであるようだ。)
感想
- TypeScript + 厳しめの ESLint ルート(当記事では、@herp-inc/eslint-config)にすると、書き方が拘束されるので、集団で開発してもある程度、コードの質は担保できそう。
- Recoil が useState とあまり変わらない感覚で書けるので楽。
- Next.js 上で開発すると、ある程度の環境とパフォーマンスがお膳立てされてるので保守は楽そう。
Discussion