Read OSS

combineReducers: Reducerの合成、バリデーション、そして参照等価性トリック

中級

前提知識

  • 第1〜2記事
  • dispatchがルートReducerを呼び出す仕組みの理解

combineReducers: Reducerの合成、バリデーション、そして参照等価性トリック

第2記事では、dispatchcurrentReducer(currentState, action) を呼び出す様子を確認しました。この単一の関数がステートツリー全体を受け取り、次のステートを返します。実用的なアプリでは、この「単一の関数」は実際には combineReducers で合成された小さなReducerのツリーです。Reduxが掲げる「設定より合成(composition over configuration)」の哲学が、ここで具体的な形をとります。

combineReducers が特に優れているのは2つの点です。生成時に積極的なバリデーションを行ってエラーを即座に表面化させること、そして何も変化がない場合に新しいステートオブジェクトの生成を避ける参照等価性チェックでホットパスを最適化することです。順を追って見ていきましょう。

生成時のバリデーション: assertReducerShape

combineReducers({ todos: todosReducer, filters: filtersReducer }) を呼び出しても、合成されたReducerはすぐには返ってきません。まず、ReduxはすべてのスライスファReducerを検査して、正しく動作するかを確認します。

src/combineReducers.ts#L62-L94

flowchart TD
    A["assertReducerShape(reducers)"] --> B["For each reducer..."]
    B --> C["Call reducer(undefined, { type: ActionTypes.INIT })"]
    C --> D{"Returns undefined?"}
    D -- Yes --> E["throw Error:<br/>'returned undefined during initialization'"]
    D -- No --> F["Call reducer(undefined, { type: PROBE_UNKNOWN_ACTION() })"]
    F --> G{"Returns undefined?"}
    G -- Yes --> H["throw Error:<br/>'returned undefined for unknown action'"]
    G -- No --> I["✓ Reducer is valid"]

2段階のプローブ戦略は、それぞれ異なる種類のミスを検出します。

  1. INITプローブ: reducer(undefined, INIT)undefined を返す場合、そのReducerにはデフォルトのステートがありません。「default ケースに初期ステートを書き忘れた」という典型的なバグを検出します。

  2. PROBE_UNKNOWN_ACTIONプローブ: ランダムなアクションタイプを発火させます(第2記事で登場した PROBE_UNKNOWN_ACTION() は、呼び出すたびに新しいランダム文字列を生成する関数です)。未知のアクションに対してReducerが undefined を返す場合、@@redux/INIT を明示的に処理してそれ以外は undefined を返しているか、あるいはすべてを undefined で返すキャッチオールを使っていることを意味します。

なぜランダムな文字列を使うのでしょうか。固定の文字列を使うと、悪意あるReducerがその文字列にマッチしてテストをパスしつつ、真に未知なアクションに対しては失敗し続けることができてしまうからです。

ヒント: このプローブは combineReducers の呼び出し時に一度だけ実行されます。ユーザーのアクションがdispatchされる前に設定ミスを検出してくれます。バリデーションがエラーを投げた場合、そのエラーはキャプチャされ、以降のすべてのdispatchで再スローされます。遅延初期化を行っていても、問題はすぐに表面化します。

合成されたReducer関数

バリデーションを通過すると、combineReducerscombination 関数を返します。これがdispatchのたびに呼ばれる実際のReducerです。

src/combineReducers.ts#L157-L201

実行時のロジックはシンプルなループです。

flowchart TD
    A["combination(state, action)"] --> B["hasChanged = false"]
    B --> C["For each key in finalReducerKeys"]
    C --> D["previousStateForKey = state[key]"]
    D --> E["nextStateForKey = reducer(previousStateForKey, action)"]
    E --> F{"nextStateForKey === undefined?"}
    F -- Yes --> G["throw Error"]
    F -- No --> H["nextState[key] = nextStateForKey"]
    H --> I["hasChanged = hasChanged OR<br/>nextStateForKey !== previousStateForKey"]
    I --> C
    C -- Done --> J{"hasChanged OR<br/>key count changed?"}
    J -- Yes --> K["return nextState"]
    J -- No --> L["return state ← same reference!"]

参照等価性トリックは195〜199行目に登場します。

src/combineReducers.ts#L195-L199

すべてのスライスReducerが===の参照比較で前のステートをそのまま返し、かつキーの数に変化がない場合、combineReducers元のステートオブジェクトをそのまま返します。これが「何も変わっていない」ときの高速パスです。

この挙動はReact-Reduxにとって非常に重要です。useSelector=== で前後のステートを比較する際、ステートが変わっていなければ再レンダリングは発生しません。combineReducers が常に新しいオブジェクトを生成していたとしたら、すべてのdispatchがすべての接続済みコンポーネントを再レンダリングさせてしまいます。

197〜198行目には細かな配慮があります。hasChangedfinalReducerKeys.length !== Object.keys(state).length もチェックします。これは、ステートにReducerマップに存在しない余分なキーがある場合を検出するためです。replaceReducer でスライス数の少ないReducerに差し替えたときに、まさにこのケースが発生します。

キャッシュを活用した開発モードの警告

開発環境では、combination はステートオブジェクト内にどのReducerにも対応しないキーが含まれていないかを追加でチェックします。

src/combineReducers.ts#L14-L60

145〜148行目の unexpectedKeyCache オブジェクトにより、同じ予期しないキーへの警告は一度しか表示されません。このキャッシュがなければ、dispatchのたびに同じ警告が繰り返されてしまいます。キャッシュは combineReducers のクロージャにスコープされているため、新しい合成Reducerを作成すると警告はリセットされます。

50行目のガード処理にも注目してください。アクションタイプが ActionTypes.REPLACE の場合、警告は抑制されます。replaceReducer はステートの形状と新しいReducerマップの間に一時的な不一致を正当な理由で引き起こすためです。

これらのコードはすべて process.env.NODE_ENV !== 'production' のチェックで囲まれており、プロダクションビルドでは完全に除去されます。

compose: 関数型プログラミングの基本要素

次の記事でミドルウェアチェーンを扱う前に、compose を理解しておく必要があります。ミドルウェアチェーンを実現する、わずか16行の関数です。

src/compose.ts#L46-L61

これは右から左への関数合成です。compose(f, g, h)(...args) => f(g(h(...args))) を生成します。実装は Array.reduce を使って関数を畳み込んでいます。

flowchart LR
    A["compose(f, g, h)"] --> B["reduce: (a, b) => (...args) => a(b(...args))"]
    B --> C["Result: (...args) => f(g(h(...args)))"]

    subgraph "Execution order"
        H["h(...args)"] --> G["g(result)"] --> F["f(result)"]
    end

3つの特殊なケースがあります。

  • 引数なし: 恒等関数 (arg) => arg を返す
  • 引数1つ: 関数自体をそのまま返す — ラップしない
  • 引数2つ以上: reduce を使って右から左に合成する

型のオーバーロード(13〜44行目)は最大4つの関数に対して正確な戻り値の型を提供し、それ以上の場合は (...args: any[]) => R にフォールバックします。可変長ジェネリクスでは関係を表現できない場合によく使われるTypeScriptのパターンです。

compose は複数のエンハンサーを組み合わせるためにも使います。createStore(reducer, compose(applyMiddleware(thunk), devTools())) のように。また applyMiddleware 内部でもミドルウェアチェーンの構築に使われています — 詳細は第4記事で説明します。

bindActionCreators: dispatchのバインディング

Reduxで最もシンプルな合成ユーティリティが bindActionCreators です。アクションクリエーターを自動的にdispatchするようにラップします。

src/bindActionCreators.ts#L58-L83

実装は2つの形式に対応しています。単一の関数(バインドされた単一の関数を返す)とオブジェクト(バインドされた関数のオブジェクトを返す)です。内部のヘルパー関数はたった4行です。

src/bindActionCreators.ts#L9-L16

.apply(this, args)this バインディングを保持しています。アクションクリエーターがコンポーネントインスタンスを参照するクラスベースコンポーネントでは、この細部が重要になります。this: any パラメーターは this のコンテキスト型を宣言するためのTypeScript構文です。

ヒント: bindActionCreators は主に、Reduxの存在を知らせたくないコンポーネントにアクションクリエーターを渡すために存在します。フックを使ったモダンなReact-Reduxでは、useDispatch + dispatch(actionCreator()) の直接呼び出しがこのパターンをほぼ代替しています。ただし、dispatchとアクション生成を切り離したいReact以外のコンテキストでは今でも有効です。

次回は

Reducerを縦方向(ステートのスライスごと)に合成する combineReducers と、関数を横方向に合成する compose ——2つの主要な合成ユーティリティを見てきました。次の記事では、composeapplyMiddleware の内部で活用する方法を掘り下げます。Reduxで最もエレガントな25行のコードを題材に、ミドルウェア関数がどのようにdispatchパイプラインへと組み上げられるかを丁寧に追っていきます。