こんにちは。チームのコードレビューをしていて、「あ、またここでやっちゃってるな」と夜な夜な赤入れをするのが日課のシニアアーキテクトだ。
今回は、Reactの`useReducer`、そしてその心臓部である「reducer関数における純粋性の担保とイミュータビリティ(不変性)」について話をしよう。
「状態管理といえばとりあえずRedux ToolkitかZustandでしょ?」と思うかもしれない。しかし、Reactの標準機能である`useReducer`の本質を理解し、reducerを正しく「純粋関数」として書けるかどうかは、中級からワンランク上のシニアエンジニアへステップアップするための決定的な分水嶺だ。ここが怪しいと、大規模なアプリケーションで「なぜか画面が再レンダリングされない」「状態がバグるのに原因が特定できない」という悪夢に深夜まで悩まされることになる。
しっかりと、ブラウザの裏側の動きを含めて叩き込んでいこう。
—
なぜ reducer は「純粋関数」でなければならないのか?
まず大前提だ。reducer関数は、絶対に「純粋関数(Pure Function)」でなければならない。
純粋関数とは何か? 定義はシンプルだ。
1. 同じ入力(引数)を与えれば、必ず同じ出力(戻り値)を返すこと。
2. 関数外の世界に影響を与えないこと(副作用の排除)。
Reactの`useReducer(reducer, initialState)`において、reducerの引数は `(state, action)` だ。つまり、現在の状態とアクションが決まれば、次にどのような状態になるのかが一意に、かつ確実に計算される必要がある。
ブラウザの裏側で何が起きているか?
Reactの内部エンジンや、将来的なConcurrent Mode(同時レンダリング)の文脈を思い出してほしい。Reactはパフォーマンスを最適化するために、状態の更新をバッチ処理(まとめて処理)したり、裏側でコンポーネントの描画を中断・再開したりする。
もしreducerの中で、以下のような「お行儀の悪いこと」をしていたらどうなるだろう?
- `Math.random()` や `new Date()` を使って予測不可能な値を混ぜる
- APIリクエストを飛ばす(非同期処理)
- 引数で渡ってきた `state` や `action` のプロパティを直接書き換える(ミューテーション)
Reactは「あれ? 前回の状態と今回の状態の参照が同じだから、変更なしとみなして再レンダリングをスキップしよう」と判断する。しかし、君たちが裏でコソッとオブジェクトの中身を書き換えていたら、Reactは変更に気づけず、画面が古いままフリーズしたり、データが狂ったりする。
これが、現場でよくある「幽霊バグ」の正体だ。
—
やってはいけない!アンチパターンコード
百聞は一見に如かず。まずは「絶対にやってはいけない、現場で嫌われるreducerの書き方」を見てみよう。
// 【NGな例】絶対にマネしてはいけないアンチパターン
function badReducer(state, action) {
switch (action.type) {
case ‘ADD_TODO’:
// 1. 既存の配列を直接 push で書き換えている(ミューテーション)
state.todos.push(action.payload);
state.count += 1;
// 2. 関数内で副作用(Date.now()など)を使っている
state.lastUpdated = Date.now();
// 3. 書き換えた同じ参照をそのまま返している
return state;
default:
return state;
}
}
このコードのどこが地雷なのだろうか?
`state.todos.push()` は、元の配列そのものを書き換えている。JavaScriptのオブジェクトや配列は「参照」でやり取りされるため、Reactは `state` の中身が変わったことに気づけない。さらに、`Date.now()` のような非決定的な値が混ざることで、テストも極めて書きにくくなる。
—
実務で使える!イミュータビリティを死守する正しい実装
では、プロとしてどう書くべきか。答えは「元の状態を一切汚さず、スプレッド構文などを使って新しいオブジェクト(または配列)をまるごと新しく作って返す」ことだ。
以下のコードを見てほしい。そのままコピーして、今日の君のプロジェクトで使えるクオリティのサンプルだ。
import React, { useReducer } from ‘react’;
// 1. 初期状態の定義
const initialState = {
todos: [],
count: 0,
lastUpdated: null,
};
// 2. 完全に純粋な関数として実装された reducer
function todoReducer(state, action) {
switch (action.type) {
case ‘ADD_TODO’:
return {
…state, // 既存のプロパティを展開(シャローコピー)
todos: [
…state.todos, // 既存の配列を展開しつつ
{ id: action.payload.id, text: action.payload.text, completed: false } // 新しい要素を追加した「新しい配列」を作る
],
count: state.count + 1,
lastUpdated: action.meta?.timestamp || Date.now(), // タイムスタンプは可能ならアクション側(外側)から注入するのがベスト
};
case ‘TOGGLE_TODO’:
return {
…state,
// mapメソッドを使って、該当する要素だけを新しいオブジェクトに置き換えた「新しい配列」を生成する
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { …todo, completed: !todo.completed } // 該当タスクのコピーを作り、フラグを反転
: todo // 該当しないものはそのまま参照を維持
),
};
case ‘DELETE_TODO’:
return {
…state,
// filterメソッドで特定の要素を除外した「新しい配列」を作る
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
count: state.count – 1,
};
default:
// 未知のアクションが来たときは、そのまま既存のstateを返すのが鉄則
return state;
}
}
export default function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const handleAdd = () => {
// 副作用(ID生成やタイムスタンプの取得)は、reducerの外側(イベントハンドラなど)で行うのがプロの作法
const newTodo = {
id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
text: `タスク ${state.count + 1}`,
};
dispatch({ type: ‘ADD_TODO’, payload: newTodo });
};
return (
実務向け reducer サンプル
合計タスク数: {state.count}
-
{state.todos.map((todo) => (
- dispatch({ type: ‘TOGGLE_TODO’, payload: { id: todo.id } })}
style={{
textDecoration: todo.completed ? ‘line-through’ : ‘none’,
cursor: ‘pointer’,
}}
>
{todo.text}
))}
);
}
—
シニアからの実践的なアドバイス(Best Practices)
このコードをベースに、現場でさらに一歩進んだ知見をいくつかシェアしておこう。
1. 深いネスト(Deep Nesting)は避ける
状態が何段階も深くネストしていると、スプレッド構文でのイミュータビリティの維持が地獄のように面倒になる(例: `state.user.profile.address.zipCode` の書き換えなど)。状態の正規化(フラットな構造にする)を意識するか、必要に応じて `Immer` などのライブラリ導入をチームで検討しよう。ただし、基本はフラットな状態設計が正義だ。
2. 副作用は reducer の「外」で済ませる
APIコール、乱数生成、現在時刻の取得、ローカルストレージへの保存などは、すべてイベントハンドラや `useEffect` の役割だ。reducerはあくまで「渡された材料(stateとaction)から、次の状態を計算する純粋な計算機」として徹しさせよう。
3. defaultケースの重要性
予期せぬアクションタイプが飛んできたときに、`undefined` を返してしまうとコンポーネントがクラッシュする。必ず `return state;` を書く癖をつけてほしい。TypeScriptを使っているなら、never型を使った網羅性チェック(Exhaustive Check)を導入するとさらに堅牢になる。
—
まとめ
reducerの純粋性とイミュータビリティの担保は、単なる「お作法」や「お説教」ではない。Reactという巨大なUIライブラリに正しく仕事をさせるための契約(コントラクト)だ。
この原則を守ることで、アプリケーションの予測可能性が劇的に上がり、バグの温床を綺麗にマッサージして取り除くことができる。
さあ、今日の業務コードを開いて、`push` や直接代入をしてしまっている箇所がないか、こっそり見直してみようじゃないか。君の書くコードが、チーム全体の品質を底上げする強力な武器になることを期待している。

コメント