🌏

React チュートリアルの三目並べに useContext, useReducer によるグローバルな状態管理を導入する

2025/02/11に公開

はじめに

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.jssrc/App.tsx
  • styles.cssindex.css

また、今回使わない以下のディレクトリは削除しておきます。

  • public
  • src/assets

コンポーネントの分割、TypeScript を導入

現在、すべてのコンポーネントが App.tsx にまとめられているので、実装のしやすさの観点から src/components ディレクトリを作成し、コンポーネントを分割します。
ゲームの勝者を計算する calculateWinner()src/utils/calculateWinner.ts に配置します。
また、コードの可読性や保守性の観点から、TypeScript での書き換えを行います。
この時点での成果物は以下になります。

useContext の導入

HistoryContext, CurrentMoveContext を作成

Game コンポーネントは historycurrentMove という二つの state があるので、これらを保持する Context を作成します。
まず、前準備としてsrc/Game/types.ts を作成し、historycurrentMove の型を定義しておきます。

src/Game/types.ts
/** `history` の型 */
export type History = (string | null)[][];

/** `currentMove` の型 */
export type CurrentMove = number;

次に、src/context ディレクトリを作成し、Context を定義します。

src/context/HistoryContext.ts
import { createContext } from 'react';
import type { History } from '../components/Game/types';

/** `history` を保持する Context */
export const HistoryContext = createContext<History | null>(null);
src/context/CurrentMoveContext.ts
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 ディレクトリを作成し、HistoryContextCurrentMoveContext を提供する Provider コンポーネントを定義します。
作成した Provider でラップされたコンポーネントからは、どんなに深い位置にコンポーネントがあっても Context を参照できるようになります。

src/provider/HistoryProvider.tsx
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>;
};
src/provider/CurrentMoveProvider.tsx
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.tsxGame コンポーネントを Provider でラップします。

src/App.tsx
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 ディレクトリを作成し、HistoryContextCurrentMoveContext を参照するカスタムフックを定義します。
Provider 配下のコンポーネントからはカスタムフック経由で Context を参照するようにします。
こうすることで、HistoryContext または CurrentMoveContextuseContext をインポートせずに直接 Context を参照できるようになり、取り回しが良くなります。

src/hooks/useHistory.ts
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;
};
src/hooks/useCurrentMove.ts
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.tshistorycurrentMove を更新する dispatch 関数の引数の型を定義しておきます。

src/components/types.ts
... 中略 ...

/** `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 ディレクトリを作成し、historycurrentMove を更新する Reducer を定義します。

src/reducer/historyReducer.ts
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;
  }
};
src/reducer/currentMoveReducer.ts
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 経由で historycurrentMove を更新できるようにします。
まず、historycurrentMove を更新する dispatch 関数を配下のコンポーネントに提供する HistoryDispatchContextCurrentMoveDispatchContext を作成します。

src/context/HistoryDispatchContext.ts
import { createContext, Dispatch } from 'react';
import type { HistoryActionType } from '../components/Game/types';

/** `history` を更新する dispatch 関数を保持する Context */
export const HistoryDispatchContext = createContext<Dispatch<HistoryActionType> | null>(null);
src/context/CurrentMoveDispatchContext.ts
import { createContext, Dispatch } from 'react';
import type { CurrentMoveActionType } from '../components/Game/types';

/** `currentMove` を更新する dispatch 関数を保持する Context */
export const CurrentMoveDispatchContext = createContext<Dispatch<CurrentMoveActionType> | null>(null);

次に、HistoryProviderCurrentMoveProvider を修正し、Provider が dispatch 関数も提供できるようにします。

src/provider/HistoryProvider.tsx
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>
  );
};
src/provider/CurrentMoveProvider.tsx
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 内に historycurrentMove を更新する dispatch 関数である historyDispatchcurrentMoveDispatch を参照するカスタムフックを定義します。
Context と同様に Provider 配下のコンポーネントからはカスタムフック経由で dispatch 関数を参照するようにします。

src/hooks/useHistoryDispatch.ts
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;
};
src/hooks/useCurrentMoveDispatch.ts
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 を参照、更新しているコンポーネントの修正

最後に、historycurrentMove を更新するコンポーネントを修正します。
現在、Game コンポーネントと Board コンポーネントは以下のようになっていいます。

Game コンポーネントと Board コンポーネント
src/components/Game/index.tsx
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>
  );
}
src/components/Board/index.tsx
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 コンポーネントは以下のようになります。

src/components/Game/index.tsx
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>
  );
}
src/components/Board/index.tsx
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