Reducerの純粋性(Immutability)とReact内部の生存戦略
こんにちは。日々、コンポーネントツリーの再レンダリング地獄とメモリリークの温床を冷徹にコードレビューしているフロントエンド・チーフアーキテクトだ。
今日もコードベースを眺めていたら、`useReducer`の内部で平然とミューテーション(破壊的変更)を行っているコードに遭遇した。開発者は「だって動くし、スプレッド構文書くより速そうだから」と悪びれもしない。しかし、その甘えがReactのリアクティブ・エンジンを根底から破壊し、いつか本番環境で原因不明の「幽霊バグ」を引き起こす起爆剤になることを、彼らはまだ知らない。
今回は、Reactの心臓部である状態管理、特に`useReducer`における「reducer関数における純粋性(Immutability)の絶対ルール」について、ブラウザのメモリモデルやReactのレンダリング・スケジューリングの内部挙動を踏まえながら、徹底的に深掘りしていこう。
—
なぜ、Reducerは「純粋関数(Pure Function)」でなければならないのか?
Reactの`useReducer(reducer, initialState)`において、`reducer`関数は以下のシグネチャを持つ。
(prevState: State, action: Action) => newState: State
この関数に課された絶対的な戒律、それが「純粋性(Pureness)」だ。同じ入力(`prevState`と`action`)を与えられたら、何回実行しようとも、100%同じ出力(`newState`)を返さなければならない。そして、関数外のスコープに影響を与えてはならないし、引数で渡された `prevState` や `action` を直接書き換えても(Mutation)ならない。
なぜ、ここまで厳格に縛られるのか? 理由は大きく分けて3つある。
1. タイムトラベルと予測可能性:状態の変遷がアクションの純粋な関数として記録されるため、デバッグ時に状態の巻き戻しや再現が容易になる。
2. Concurrent Mode(並行レンダリング)との調和:React 18以降の並行機能では、レンダリングが中断・再開・破棄される。reducerが不純(外部のミュータブルな変数に依存するなど)であると、中断の前後で結果が変わり、UIの整合性が完全に崩壊する。
3. 参照等価性(Reference Equality)による無駄な再レンダリングの阻止:Reactは、状態の変更をオブジェクトの「参照(メモリアドレス)」が変わったかどうか(`Object.is`)で判定する。ミューテーションを行うと、中身が変わっても参照アドレスが同じままになるため、Reactが「状態は変わっていない」と誤認し、UIが更新されなくなる致命的なバグが生まれる。
—
現場で頻発する「やってはいけない」アンチパターン
まずは、実務のコードレビューでよく見かける「やってはいけない実装」を見てみよう。一見、何の問題もないように見えるのがタチの悪いところだ。
// ❌ 絶対にやってはいけないアンチパターン(ミューテーションの嵐)
function badReducer(state, action) {
switch (action.type) {
case ‘ADD_TODO’:
// 1. 配列を直接 push している(ミューテーション)
state.todos.push(action.payload);
return state; // 参照が変わっていないため、Reactは再レンダリングをスキップしてしまうことがある
case ‘UPDATE_USER_NAME’:
// 2. オブジェクトのプロパティを直接書き換えている
state.user.name = action.payload;
return state;
default:
return state;
}
}
このコードの何がヤバいか?
JavaScriptのオブジェクトや配列は「参照渡し」だ。`state`をそのままイジると、React内部で保持している「前回の状態(`prevState`)」と「今回の状態(`newState`)」のポインタが完全に一致してしまう。
Reactは `Object.is(prevState, newState)` を使って状態が更新されたかを判定しているため、中身が書き換わっていようとも、ポインタが同じであれば「おっ、変更なしだな!スキップ!」と判断し、画面が更新されない。運良く更新されたとしても、状態の履歴管理やUndo/Redo、メモ化(`useMemo` / `React.memo`)のキャッシュ機構が完全にぶっ壊れる。
—
堅牢なイミュータビリティ(不変性)の担保とメモリ効率のバランス
では、どのように実装すべきか。基本は「スプレッド構文(`…`)を用いた浅いコピー(Shallow Copy)」だ。
// ⭕️ 正しいイミュータブルな実装
function goodReducer(state, action) {
switch (action.type) {
case ‘ADD_TODO’:
return {
…state,
// 新しい配列を生成して結合する
todos: […state.todos, action.payload]
};
case ‘UPDATE_USER_NAME’:
return {
…state,
// ネストしたオブジェクトも同様に新しいオブジェクトに置き換える
user: {
…state.user,
name: action.payload
}
};
default:
return state;
}
}
ここで、パフォーマンスに敏感なシニアエンジニアならこう疑問に思うはずだ。
「おいおい、毎回スプレッド構文でオブジェクトや配列を展開してコピーしていたら、メモリの割り当て(Allocation)とガーベパブコレクション(GC)の負荷がハンパないんじゃないか? 特に巨大なツリー構造の状態管理ではボトルネックになるのでは?」
その通り。ナイーブなスプレッドの乱用は、V8エンジンのガベージコレクターに余計な負荷をかける。
ここで、高度なアーキテクチャの選択肢が登場する。
1. Immerの導入(開発者体験とイミュータビリティの両立)
実務で複雑なネスト状態を扱う場合、私たちはよく Immer を採用する。Immerは、`Draft` というプロキシオブジェクトを介して「あたかもミューテーションしているかのように書きながら、内部では完全にイミュータブルなツリーを構造的共有(Structural Sharing)で構築する」という変態的な(褒め言葉だ)ライブラリだ。
import { produce } from ‘immer’;
// Immerを使ったreducerの極限までクリーンな実装
const immerReducer = produce((draft, action) => {
switch (action.type) {
case ‘ADD_TODO’:
// 直接pushしているように書けるが、Immerが安全にイミュータブルな新状態を生成する
draft.todos.push(action.payload);
break;
case ‘UPDATE_USER_NAME’:
draft.user.name = action.payload;
break;
}
});
Immerの内部では「構造的共有(Structural Sharing)」が行われており、変更されなかったブランチのメモリ参照は維持されるため、メモリ効率とGCの負荷を最小限に抑えつつ、安全性を担保できる。
2. 状態のフラット化(Normalization)
Redux時代の遺物と侮るなかれ。Reactの`useReducer`でも、巨大なネスト構造をそのまま持たず、リレーショナルデータベースのようにIDでフラットに正規化(Normalize)することが、メモリ効率とレンダリング最適化の王道だ。
ネストが深ければ深いほど、スプレッド地獄になり、バグの温床となる。状態は可能な限り浅く保つこと。これが大規模アプリを支える鉄則だ。
—
副作用(Side Effects)の正しい逃がし先
reducerの純粋性を語る上で避けて通れないのが「副作用(Side Effects)」の排除だ。
APIリクエスト、タイムアウトの設定(`setTimeout`)、LocalStorageへの書き込み、現在時刻の取得(`Date.now()`)やランダムな値の生成(`Math.random()`)は、すべてreducerの外部、あるいは専用のミドルウェアや`useEffect`、カスタムフックに追放しなければならない。
よくあるバグの温床:
// ❌ 悪夢のアンチパターン:reducer内で副作用や非決定的な処理を行っている
function unstableReducer(state, action) {
switch (action.type) {
case ‘LOGIN_SUCCESS’:
// ローカルストレージに書き込んでいる(副作用!)
localStorage.setItem(‘token’, action.payload.token);
return {
…state,
// Date.now()は実行するたびに値が変わるため、純粋関数ではない!
lastLogin: Date.now(),
user: action.payload
};
default:
return state;
}
}
もしreducer内で`Date.now()`や`Math.random()`を使ったり、APIを叩いたりすると、ReactのConcurrent Rendering(レンダリングの再試行や中断)が発生した際に、予期せぬ不整合が起きる。画面に描画される前にreducerが複数回実行され、タイムスタンプが狂うといった不可解な現象に悩まされることになる。
副作用は、イベントハンドラ内、または `useEffect` の領域で行うこと。
アクションを発火する(Dispatchする)前の段階、あるいは状態が更新された後の副作用として処理するのがReactのアーキテクチャにおける正しい流儀だ。
—
チーフアーキテクトからの提言
Reactの`useReducer`における純粋性とイミュータビリティの維持は、単なる「お作法」や「綺麗なおコードを書きましょう」というお遊戯ではない。
ブラウザのメモリ空間、V8エンジンのガベージコレクション、そしてReactファイバーアーキテクチャのスケジューリングメカニズムを深く理解した上で、「予測可能で、壊れない状態管理の要塞を築くため」の極めて工学的なアプローチだ。
コードを書くときは常に自問してほしい。
「このreducer関数は、同じ引数を渡せば、未来永劫全く同じ結果を返すと言い切れるか?」
「このオブジェクトのコピーは、本当にReactの参照等価性メカニズムを正しくトリガーできているか?」
この問いに淀みなく「Yes」と答えられるコードベースこそが、数百万ユーザーを抱えるプロダクトを平穏無事に支え続ける、真に堅牢なシステムなのだ。
さあ、エディタを開いて、君のプロジェクトのreducerを見直してみよう。ミューテーションの臭いがプンプンするコードがあったら、今すぐ美しく書き換えてやってくれ。

コメント