React チュートリアルの三目並べに useContext, useReducer によるグローバルな状態管理を導入する
はじめに
React を使用した Web アプリケーションの開発現場では、状態管理のために Redux や Zustand, Jotai などの状態管理ライブラリを導入していることが多いかと思います。
これらの状態管理ライブラリを使うと、より効率的でスケーラブルな状態管理が可能になります。
しかし、いきなりライブラリに頼ると、React が本来持つ「状態管理のしくみ」を理解しにくくなるというデメリットもあります。
そこで本記事では、React チュートリアルの三目並べを題材に、React の組み込み hook である useContext と useReducer を使ったグローバルな状態管理を実現する方法を解説します。
最終的な成果物はこちらです。
useContext と useReducer
useContext と useReducer は「はじめに」でも述べたように、React の組み込み hook です。これらを組み合わせることで、ライブラリを使わずにグローバルな状態管理を実現することができます。
useContext
useContext は親コンポーネントが明示的に props を渡さずとも、それ以下の任意のコンポーネントがデータを読み出せるようにする hook です。
React では通常、親コンポーネントから子コンポーネントへのデータの共有は props を介して行われます。しかし、コンポーネントのネストが深くなってくると、親コンポーネントから遠く離れたコンポーネントにデータを共有する場合、間にある中間コンポーネントは自身は使わない無駄なデータを受け取ることになります。これは「Props の穴掘り作業(props drilling)」と呼ばれ、非常にコードの見通しが悪くなってしまいます。
このような状況を解決するために React には Context という概念があります。これを使用するとデータを一元的に管理でき、useContext hook によって任意のコンポーネントからいつでもデータを読み出せるようになります。
詳しい解説は公式ドキュメントに記載がありますので、必要に応じて参照してください。
useReducer
useReducer は状態の更新ロジックを集約するための hook です。
state は通常、ユーザーの操作を起点に更新され、その更新ロジックはイベントハンドラ内に記載されることが多いですが、useReducer を使うことで、この更新ロジックをコンポーネントから分離することができます。
これにより、コンポーネントの複雑さが増してきても、
- 表示の処理はコンポーネント
- 状態の更新ロジックは reducer
と役割を明確に分離でき、コードの可読性や保守性が向上します。
詳しい解説は公式ドキュメントに記載がありますので、必要に応じて参照してください。
事前準備
ではさっそく、開発環境のセットアップから始めていきます。
Node.js のバージョンは、23.7.0
を使用しています。
開発環境のセットアップ
npm create vite@latest tic-tac-toe -- --template react-ts
cd tic-tac-toe
npm install
npm run dev
後で TypeScript でリファクタリングするため、template は react-ts を選択します。
npm run dev
までコマンドを実行後、表示された URL にアクセスして以下のような画像が表示されれば開発環境のセットアップは完了です。
リファクタリング
ソースコードをコピー & ペースト
- React チュートリアルの「チュートリアルで作成するもの」セクションに記載されているコードの [Fork] ボタンをクリックし、チュートリアルのソースコードを表示させます。
ソースコードを表示させることができれば、先ほど Vite で作成したプロジェクトにコピー & ペーストしていきます。各ファイルの対応する場所は以下の通りになります。
-
App.js
→src/App.tsx
-
styles.css
→index.css
また、今回使わない以下のディレクトリは削除しておきます。
public
src/assets
コンポーネントの分割、TypeScript を導入
現在、すべてのコンポーネントが App.tsx
にまとめられているので、実装のしやすさの観点から src/components
ディレクトリを作成し、コンポーネントを分割します。
ゲームの勝者を計算する calculateWinner()
は src/utils/calculateWinner.ts
に配置します。
また、コードの可読性や保守性の観点から、TypeScript での書き換えを行います。
この時点での成果物は以下になります。
useContext の導入
HistoryContext
, CurrentMoveContext
を作成
Game
コンポーネントは history
、currentMove
という二つの state があるので、これらを保持する Context を作成します。
まず、前準備としてsrc/Game/types.ts
を作成し、history
と currentMove
の型を定義しておきます。
/** `history` の型 */
export type History = (string | null)[][];
/** `currentMove` の型 */
export type CurrentMove = number;
次に、src/context
ディレクトリを作成し、Context を定義します。
import { createContext } from 'react';
import type { History } from '../components/Game/types';
/** `history` を保持する Context */
export const HistoryContext = createContext<History | null>(null);
import { createContext } from 'react';
import type { CurrentMove } from '../components/Game/types';
/** `currentMove` を保持する Context */
export const CurrentMoveContext = createContext<CurrentMove | null>(null);
HistoryContext
, CurrentMoveContext
を提供する Provider を作成
Context を配下のコンポーネントに提供するための Provider を作成します。
src/provider
ディレクトリを作成し、HistoryContext
、CurrentMoveContext
を提供する Provider コンポーネントを定義します。
作成した Provider でラップされたコンポーネントからは、どんなに深い位置にコンポーネントがあっても Context を参照できるようになります。
import { JSX, ReactNode } from 'react';
import { HistoryContext } from '../context/HistoryContext';
import type { History } from '../components/Game/types';
type Props = {
children: ReactNode;
};
/** HistoryContext を提供する Provider */
export const HistoryProvider = ({ children }: Props): JSX.Element => {
/** `history` のデフォルト値 */
const defaultHistory: History = [Array(9).fill(null)];
return <HistoryContext.Provider value={defaultHistory}>{children}</HistoryContext.Provider>;
};
import { JSX, ReactNode } from 'react';
import { CurrentMoveContext } from '../context/CurrentMoveContext';
import type { CurrentMove } from '../components/Game/types';
type Props = {
children: ReactNode;
};
/** CurrentMoveContext を提供する Provider */
export const CurrentMoveProvider = ({ children }: Props): JSX.Element => {
/** `currentMove` のデフォルト値 */
const defaultCurrentMove: CurrentMove = 0;
return <CurrentMoveContext.Provider value={defaultCurrentMove}>{children}</CurrentMoveContext.Provider>;
};
次に、Provider 配下のコンポーネントから Context を参照できるように src/App.tsx
の Game
コンポーネントを Provider でラップします。
import Game from './components/Game';
import { CurrentMoveProvider } from './provider/CurrentMoveProvider';
import { HistoryProvider } from './provider/HistoryProvider';
export default function App() {
return (
<HistoryProvider>
<CurrentMoveProvider>
<Game />
</CurrentMoveProvider>
</HistoryProvider>
);
}
HistoryContext
, CurrentMoveContext
を参照するカスタムフックを作成
src/hooks
ディレクトリを作成し、HistoryContext
、CurrentMoveContext
を参照するカスタムフックを定義します。
Provider 配下のコンポーネントからはカスタムフック経由で Context を参照するようにします。
こうすることで、HistoryContext
または CurrentMoveContext
と useContext
をインポートせずに直接 Context を参照できるようになり、取り回しが良くなります。
import { useContext } from 'react';
import { HistoryContext } from '../context/HistoryContext';
import type { History } from '../components/Game/types';
/** HistoryContext を提供する Custom Hook */
export const useHistory = (): History => {
const history = useContext(HistoryContext);
if (history === null) {
throw new Error('HistoryProvider でラップしてください');
}
return history;
};
import { useContext } from 'react';
import { CurrentMoveContext } from '../context/CurrentMoveContext';
import type { CurrentMove } from '../components/Game/types';
/** CurrentMoveContext を提供する Custom Hook */
export const useCurrentMove = (): CurrentMove => {
const currentMove = useContext(CurrentMoveContext);
if (currentMove === null) {
throw new Error('CurrentMoveProvider でラップしてください');
}
return currentMove;
};
- この時点での成果物は以下になります。
useReducer の導入
historyReducer
, currentMoveReducer
を作成
useContext だけでは state を任意のコンポーネントから参照できるだけであり、更新は行えません。そこで、state の更新も行えるように Reducer を導入します。
まずは前準備として src/components/types.ts
に history
と currentMove
を更新する dispatch 関数の引数の型を定義しておきます。
... 中略 ...
/** `history` を更新する dispatch 関数の引数の型 */
export type HistoryActionType = {
/** タイプ */
type: 'play';
/** 次の盤面の状態 */
nextSquares: (string | null)[];
/** 現在の手番 */
currentMove: CurrentMove;
};
/** `currentMove` を更新する dispatch 関数の引数の型 */
export type CurrentMoveActionType =
| {
/** タイプ */
type: 'play';
/** 次の手番 */
nextMove: number;
}
| {
/** タイプ */
type: 'jump';
/** 次の手番 */
nextMove: number;
};
次に、src/reducer
ディレクトリを作成し、history
と currentMove
を更新する Reducer を定義します。
import type { History, HistoryActionType } from '../components/Game/types';
/**
* `history` を更新する Reducer
*
* @param history 現在の `history` を保持する state
* @param action アクション
*
* @returns 更新後の `history`
*/
export const historyReducer = (history: History, action: HistoryActionType): History => {
switch (action.type) {
case 'play':
return [...history.slice(0, action.currentMove + 1), action.nextSquares];
default:
return history;
}
};
import type { CurrentMove, CurrentMoveActionType } from '../components/Game/types';
/**
* `currentMove` を更新する Reducer
*
* @param currentMove 現在の `currentMove` を保持する state
* @param action アクション
*
* @returns 更新後の `currentMove`
*/
export const currentMoveReducer = (currentMove: CurrentMove, action: CurrentMoveActionType): CurrentMove => {
switch (action.type) {
case 'play':
return action.nextMove;
case 'jump':
return action.nextMove;
default:
return currentMove;
}
};
HistoryProvider
, CurrentMoveProvider
を修正
Provider を修正し、配下のコンポーネントから Reducer 経由で history
、currentMove
を更新できるようにします。
まず、history
、currentMove
を更新する dispatch 関数を配下のコンポーネントに提供する HistoryDispatchContext
、CurrentMoveDispatchContext
を作成します。
import { createContext, Dispatch } from 'react';
import type { HistoryActionType } from '../components/Game/types';
/** `history` を更新する dispatch 関数を保持する Context */
export const HistoryDispatchContext = createContext<Dispatch<HistoryActionType> | null>(null);
import { createContext, Dispatch } from 'react';
import type { CurrentMoveActionType } from '../components/Game/types';
/** `currentMove` を更新する dispatch 関数を保持する Context */
export const CurrentMoveDispatchContext = createContext<Dispatch<CurrentMoveActionType> | null>(null);
次に、HistoryProvider
、CurrentMoveProvider
を修正し、Provider が dispatch 関数も提供できるようにします。
import { JSX, ReactNode, useReducer } from 'react';
import { HistoryContext } from '../context/HistoryContext';
import type { History } from '../components/Game/types';
import { historyReducer } from '../reducer/historyReducer';
import { HistoryDispatchContext } from '../context/HistoryDispatchContext';
type Props = {
children: ReactNode;
};
/** HistoryContext を提供する Provider */
export const HistoryProvider = ({ children }: Props): JSX.Element => {
/** `history` のデフォルト値 */
const defaultHistory: History = [Array(9).fill(null)];
const [history, historyDispatch] = useReducer(historyReducer, defaultHistory);
return (
<HistoryContext.Provider value={history}>
<HistoryDispatchContext.Provider value={historyDispatch}>{children}</HistoryDispatchContext.Provider>
</HistoryContext.Provider>
);
};
import { JSX, ReactNode, useReducer } from 'react';
import { CurrentMoveContext } from '../context/CurrentMoveContext';
import type { CurrentMove } from '../components/Game/types';
import { currentMoveReducer } from '../reducer/currentMoveReducer';
import { CurrentMoveDispatchContext } from '../context/CurrentMoveDispatchContext';
type Props = {
children: ReactNode;
};
/** CurrentMoveContext を提供する Provider */
export const CurrentMoveProvider = ({ children }: Props): JSX.Element => {
/** `currentMove` のデフォルト値 */
const defaultCurrentMove: CurrentMove = 0;
const [currentMove, currentMoveDispatch] = useReducer(currentMoveReducer, defaultCurrentMove);
return (
<CurrentMoveContext.Provider value={currentMove}>
<CurrentMoveDispatchContext.Provider value={currentMoveDispatch}>{children}</CurrentMoveDispatchContext.Provider>
</CurrentMoveContext.Provider>
);
};
historyDispatch
, currentMoveDispatch
を参照するカスタムフックを作成
src/hooks
内に history
と currentMove
を更新する dispatch 関数である historyDispatch
、currentMoveDispatch
を参照するカスタムフックを定義します。
Context と同様に Provider 配下のコンポーネントからはカスタムフック経由で dispatch 関数を参照するようにします。
import { Dispatch, useContext } from 'react';
import { HistoryDispatchContext } from '../context/HistoryDispatchContext';
import type { HistoryActionType } from '../components/Game/types';
/** HistoryDispatchContext を提供する Custom Hook */
export const useHistoryDispatch = (): Dispatch<HistoryActionType> => {
const historyDispatch = useContext(HistoryDispatchContext);
if (historyDispatch === null) {
throw new Error('HistoryDispatchProvider でラップしてください');
}
return historyDispatch;
};
import { Dispatch, useContext } from 'react';
import { CurrentMoveDispatchContext } from '../context/CurrentMoveDispatchContext';
import type { CurrentMoveActionType } from '../components/Game/types';
/** CurrentMoveDispatchContext を提供する Custom Hook */
export const useCurrentMoveDispatch = (): Dispatch<CurrentMoveActionType> => {
const currentMoveDispatch = useContext(CurrentMoveDispatchContext);
if (currentMoveDispatch === null) {
throw new Error('CurrentMoveDispatchProvider でラップしてください');
}
return currentMoveDispatch;
};
- この時点での成果物は以下になります。
history
, currentMove
を参照、更新しているコンポーネントの修正
最後に、history
、currentMove
を更新するコンポーネントを修正します。
現在、Game
コンポーネントと Board
コンポーネントは以下のようになっていいます。
Game コンポーネントと Board コンポーネント
import { JSX, useState } from 'react';
import Board from '../Board';
/** `Game` コンポーネント */
export default function Game(): JSX.Element {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
function handlePlay(nextSquares: (string | null)[]) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
function jumpTo(nextMove: number) {
setCurrentMove(nextMove);
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
<ol>{moves}</ol>
</div>
</div>
);
}
import { JSX } from 'react';
import calculateWinner from '../../utils/calculateWinner';
import Square from '../Square';
type Props = {
/** 次の着手が X かどうか */
xIsNext: boolean;
/** 盤面の状態 */
squares: (string | null)[];
/** マス目をクリックした後の新しい盤面の状態を `Game` コンポーネントに通知するハンドラ */
onPlay: (nextSquares: (string | null)[]) => void;
};
/** `Board` コンポーネント */
export default function Board({ xIsNext, squares, onPlay }: Props): JSX.Element {
function handleClick(i: number) {
if (calculateWinner(squares) || squares[i]) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = 'X';
} else {
nextSquares[i] = 'O';
}
onPlay(nextSquares);
}
const winner = calculateWinner(squares);
let status;
if (winner) {
status = 'Winner: ' + winner;
} else {
status = 'Next player: ' + (xIsNext ? 'X' : 'O');
}
return (
<>
<div className="status">{status}</div>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
<Square value={squares[1]} onSquareClick={() => handleClick(1)} />
<Square value={squares[2]} onSquareClick={() => handleClick(2)} />
</div>
<div className="board-row">
<Square value={squares[3]} onSquareClick={() => handleClick(3)} />
<Square value={squares[4]} onSquareClick={() => handleClick(4)} />
<Square value={squares[5]} onSquareClick={() => handleClick(5)} />
</div>
<div className="board-row">
<Square value={squares[6]} onSquareClick={() => handleClick(6)} />
<Square value={squares[7]} onSquareClick={() => handleClick(7)} />
<Square value={squares[8]} onSquareClick={() => handleClick(8)} />
</div>
</>
);
}
Game
コンポーネントで xIsNext
などの各種データやマス目をクリックした際のハンドラである handlePlay
を管理し、それらを Board
コンポーネントに props 経由で渡しています。
ですが、今や Context と Reducer を導入したことにより、Board
コンポーネントは state の参照, 更新ができるため、Game
コンポーネントは props を渡す必要はないので、修正します。
修正後の Game
コンポーネントと Board
コンポーネントは以下のようになります。
import { JSX } from 'react';
import Board from '../Board';
import { useHistory } from '../../hooks/useHistory';
import { useCurrentMoveDispatch } from '../../hooks/useCurrentMoveDispatch';
/** `Game` コンポーネント */
export default function Game(): JSX.Element {
const history = useHistory();
const currentMoveDispatch = useCurrentMoveDispatch();
function jumpTo(nextMove: number) {
currentMoveDispatch({ type: 'jump', nextMove });
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<ol>{moves}</ol>
</div>
</div>
);
}
import { JSX } from 'react';
import calculateWinner from '../../utils/calculateWinner';
import Square from '../Square';
import { useHistory } from '../../hooks/useHistory';
import { useHistoryDispatch } from '../../hooks/useHistoryDispatch';
import { useCurrentMove } from '../../hooks/useCurrentMove';
import { useCurrentMoveDispatch } from '../../hooks/useCurrentMoveDispatch';
/** `Board` コンポーネント */
export default function Board(): JSX.Element {
const history = useHistory();
const historyDispatch = useHistoryDispatch();
const currentMove = useCurrentMove();
const currentMoveDispatch = useCurrentMoveDispatch();
const xIsNext = currentMove % 2 === 0;
const squares = history[currentMove];
function handleClick(i: number) {
if (calculateWinner(squares) || squares[i]) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = 'X';
} else {
nextSquares[i] = 'O';
}
historyDispatch({ type: 'play', nextSquares, currentMove });
currentMoveDispatch({ type: 'play', nextMove: currentMove + 1 });
}
const winner = calculateWinner(squares);
let status;
if (winner) {
status = 'Winner: ' + winner;
} else {
status = 'Next player: ' + (xIsNext ? 'X' : 'O');
}
return (
<>
<div className="status">{status}</div>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
<Square value={squares[1]} onSquareClick={() => handleClick(1)} />
<Square value={squares[2]} onSquareClick={() => handleClick(2)} />
</div>
<div className="board-row">
<Square value={squares[3]} onSquareClick={() => handleClick(3)} />
<Square value={squares[4]} onSquareClick={() => handleClick(4)} />
<Square value={squares[5]} onSquareClick={() => handleClick(5)} />
</div>
<div className="board-row">
<Square value={squares[6]} onSquareClick={() => handleClick(6)} />
<Square value={squares[7]} onSquareClick={() => handleClick(7)} />
<Square value={squares[8]} onSquareClick={() => handleClick(8)} />
</div>
</>
);
}
Game
コンポーネントから Board
に渡す Props を削除でき、本来渡すはずだった props は Board
コンポーネントが随時必要な時に計算するようになっています。
これにより、Board
コンポーネントはどこからデータを取得するのかではなく何を表示するのかに集中できるようになりました。
以上より、useContext と useReducer によるグローバルな状態管理を実現することができました。
最終的な成果物はこちらになります。
Discussion