Read OSS

createStore の内側:クロージャ、リスナー、そしてディスパッチサイクル

中級

前提知識

  • 第1回:アーキテクチャとプロジェクト構成
  • JavaScriptのクロージャと高階関数
  • オブザーバーパターン(subscribe/unsubscribe)

createStore の内側:クロージャ、リスナー、そしてディスパッチサイクル

前回の記事では、Reduxがクラス階層ではなく、クロージャをベースにした状態コンテナであることを確認しました。今回はそのクロージャの中身を掘り下げます。createStore.ts はおよそ500行ありますが、その半分はJSDocコメントと型のオーバーロードです。実際のランタイムロジックは驚くほどコンパクトで、どの行にも明確な意図があります。

ここでは完全なライフサイクルを順に追っていきます。引数の検証と整理から始まり、エンハンサーパターンによるストア生成の短絡処理を経て、dispatch による状態遷移とリスナー通知の仕組みを解説します。最後に、ディスパッチ中のサブスクリプション変更を安全に扱うデュアルMapスナップショットのメカニズムに踏み込みます。

関数シグネチャと引数の整理

createStore(reducer, preloadedState?, enhancer?) という形で2〜3個の引数を受け取ります。ただし、初期状態が不要な場合にエンハンサーを第2引数として渡すショートハンドが一般的です。この場合、関数内で単純な型チェックによって引数を振り分けます。

src/createStore.ts#L115-L129

flowchart TD
    A["createStore(reducer, arg2?, arg3?)"] --> B{"typeof arg2 === 'function'<br/>AND arg3 === undefined?"}
    B -- Yes --> C["enhancer = arg2<br/>preloadedState = undefined"]
    B -- No --> D{"Multiple enhancers<br/>detected?"}
    D -- Yes --> E["throw Error:<br/>'compose them together'"]
    D -- No --> F["enhancer = arg3<br/>preloadedState = arg2"]
    C --> G["Continue to enhancer check"]
    F --> G

この設計は実用的で、呼び出し側が createStore(reducer, undefined, enhancer) と書かずに済みます。ただし、その分だけ関数内の処理は複雑になります。また、複数のエンハンサーを compose() でまとめるべきところを別々の引数として渡してしまうミスも、ここでガードしています。

非推奨の扱いはTypeScriptの @deprecated JSDocタグによって createStore に付与されており、legacy_createStore は同一の実装に対してそのタグを外しただけのものです。

src/createStore.ts#L487-L499

内部では同じ引数で createStore をそのまま呼び出しています。IDEに表示される警告があるかないか — 違いはそれだけです。

6つのクロージャ変数

第1回で触れたとおり、ストアのプライベートな状態は6つの let 変数に格納されています。

src/createStore.ts#L146-L153

変数 役割
currentReducer Reducer<S, A> 現在のルートreducer。replaceReducer で差し替えられる
currentState S | PreloadedState | undefined 現在の状態ツリー
currentListeners Map<number, ListenerCallback> | null dispatch中に使用するリスナーのスナップショット
nextListeners Map<number, ListenerCallback> subscribe/unsubscribeの変更が加えられる作業コピー
listenerIdCounter number リスナーを一意に識別するための単調増加ID
isDispatching boolean reduce中のdispatch呼び出しを防ぐ再入ガード

システム全体で変化しうる状態はこの6変数だけです。返されるストアオブジェクトのすべてのメソッド — dispatchsubscribegetStatereplaceReducer — は、クロージャを通じてこれら6つの変数を読み書きします。

補足: currentState の型が単なる S ではなく S | PreloadedState | undefined になっているのは、INIT dispatchが実行される前の状態が未加工のプリロード値(Partial<S> のような形状を取りうる)である場合があるためです。INIT以降はreducerによって S 型が保証されます。getState() が返り値を S にキャストしているのは、使い勝手のために意図的に行っている小さな妥協です。

エンハンサーによる短絡処理

エンハンサーが渡されると、createStore はすぐに処理を返し、ストアの生成をエンハンサーに委ねます。

src/createStore.ts#L131-L144

sequenceDiagram
    participant App
    participant createStore
    participant enhancer
    participant createStore2 as createStore (round 2)

    App->>createStore: (reducer, enhancer)
    createStore->>enhancer: enhancer(createStore)
    enhancer->>createStore2: createStore(reducer, preloadedState)
    Note over createStore2: No enhancer this time —<br/>runs the full function
    createStore2-->>enhancer: base store
    enhancer-->>App: enhanced store

enhancer(createStore)(reducer, preloadedState) という呼び出しでは、エンハンサーが引数として createStore 自体を受け取ります。エンハンサーはそれを使ってベースとなるストアを取得し、変更を加えた上で返します。これが applyMiddleware を支える根本的なメカニズムです。

再帰的な構造に注目してください。createStore は2回呼ばれます — アプリからの呼び出し(エンハンサーあり)と、エンハンサーからの呼び出し(エンハンサーなし)です。2回目の呼び出しが関数本体の全処理を実行し、実際のストアを生成します。

dispatch():コアサイクル

状態が実際に変化するのは dispatch 関数の中です。順を追って見ていきましょう。

src/createStore.ts#L280-L319

sequenceDiagram
    participant Caller
    participant dispatch
    participant isPlainObject
    participant Reducer
    participant Listeners

    Caller->>dispatch: dispatch(action)
    dispatch->>isPlainObject: isPlainObject(action)?
    alt Not a plain object
        isPlainObject-->>dispatch: false
        dispatch-->>Caller: throw Error
    end
    dispatch->>dispatch: typeof action.type === 'string'?
    dispatch->>dispatch: isDispatching === false?

    dispatch->>dispatch: isDispatching = true
    dispatch->>Reducer: currentReducer(currentState, action)
    Reducer-->>dispatch: nextState
    dispatch->>dispatch: isDispatching = false
    dispatch->>dispatch: currentListeners = nextListeners
    dispatch->>Listeners: forEach(listener => listener())
    dispatch-->>Caller: return action

バリデーションは厳格です。actionはプレーンオブジェクト(クラスインスタンス、Promise、その他の特殊な型は不可)でなければならず、type プロパティを持ち、その型は文字列である必要があります。Redux v5ではこの最後の条件が強化されました。以前のバージョンではシンボルなど他の型も受け付けていました。

isPlainObject チェックも確認しておきましょう。

src/utils/isPlainObject.ts#L5-L16

プロトタイプチェーンをルートまでたどり、オブジェクトの直接のプロトタイプと照合します。これにより、Object.create(null)(プロトタイプなし)や標準的な {} オブジェクトは正しく識別される一方、クラスインスタンス、配列、その他の複合オブジェクトは弾かれます。

isDispatching ガードは、reducer実行中に dispatch が呼ばれること(無限ループにつながる再入処理)を防ぎます。try/finally によって、reducerが例外を投げてもフラグは確実にリセットされます。

reducerが戻り値を返した後、const listeners = (currentListeners = nextListeners) という一行がリスナーリストをアトミックにスナップショットしてイテレートします。ここから createStore の最も巧妙な部分に入ります。

リスナースナップショット:デュアルMapパターン

Reduxが解決しなければならない難しい問題があります。リスナーが通知ループ中に自分自身(または他のリスナー)をunsubscribeするケースです。リストをイテレートしながら要素を削除すると、リスナーがスキップされたりインデックスエラーが発生したりします。

解決策は、2つの Map インスタンスを使ったコピーオンライト方式です。

src/createStore.ts#L150-L151

currentListeners はdispatch中に使われるスナップショットです。nextListeners はsubscribe/unsubscribeによる変更が加えられる作業コピーです。ensureCanMutateNextListeners 関数は、変更が必要になったタイミングで currentListeners を新しい Map に遅延コピーします。

src/createStore.ts#L162-L169

flowchart TD
    A["subscribe(listener)"] --> B{"nextListeners === currentListeners?"}
    B -- Yes --> C["Copy currentListeners to new Map"]
    B -- No --> D["Already diverged, safe to mutate"]
    C --> D
    D --> E["nextListeners.set(id, listener)"]

    F["dispatch(action)"] --> G["currentListeners = nextListeners"]
    G --> H["Iterate currentListeners"]
    H --> I["Listeners may call subscribe/unsubscribe"]
    I --> J["Mutations go to nextListeners<br/>(diverged from currentListeners)"]

Redux v5ではリスナーの格納先が配列から Map に変更されました。配列には微妙なバグがありました。dispatch中にリスナーがunsubscribeされると、配列の splice によってインデックスがずれ、別のリスナーがスキップされる可能性があったのです。Map.forEach はエントリを挿入順に訪問し、削除もきれいに扱いますが、そもそもReduxはそれに頼る必要すらありません。変更は常にもう一方の Mapに加えられるからです。

このふるまいはテストで明示的に検証されています。

test/createStore.spec.ts#L290-L344

「dispatch中にunsubscribeされても、すべてのサブスクライバーに通知する」というテストでは、3つのリスナーを用意し、listener2がdispatch中に3つ全員をunsubscribeします。それでも3つすべてがその回のdispatchでは呼ばれます — スナップショットに含まれていたからです。そして次のdispatchではどれも呼ばれません。

補足: unsubscribe関数の中で currentListeners = null とセットしているのは(251行目)、メモリの最適化です。unsubscribe後は古いスナップショットをdispatchが使うことはないため、参照をnullにしてガベージコレクションを促します。

ランダム化された内部アクションタイプ

createStore の初期化時、初期状態を構築するために @@redux/INIT という特殊なactionがdispatchされます。しかし、ユーザーのreducerに "@@redux/INIT" の分岐があったとしたら?内部actionを意図せず横取りしてしまうバグが生まれます。

その解決策がランダム化です。

src/utils/actionTypes.ts#L1-L17

INIT@@redux/INIT3.h.j.2.k のような、モジュールのロードごとに異なる値になります。PROBE_UNKNOWN_ACTION はさらに徹底されており、呼び出しのたびに新しいランダムなサフィックスを生成する関数です。これは combineReducers がスライスreducerの未知のaction処理を検証するために使われます(詳細は第3回で)。

randomString() に付いている /* #__PURE__ */ アノテーションは、この呼び出しに副作用がないことをminifierに伝え、デッドコードの除去を助けます。

replaceReducer とObservableプロトコル

ストアAPIを締めくくるのは2つのメソッドです。replaceReducer はルートreducerを差し替え、@@redux/REPLACE actionをdispatchして状態を再初期化します。

src/createStore.ts#L330-L346

これによりホットモジュールリプレースメントが実現します。bundlerがreducerモジュールをホットスワップした際に、ストアは状態を失わずに新しいreducerを取り込めます。

observable メソッドは Symbol.observable を通じてTC39 Observableプロトコルを実装しており、RxJSや他のリアクティブライブラリとの相互運用を可能にします。

src/createStore.ts#L354-L390

sequenceDiagram
    participant RxJS
    participant Store Observable
    participant subscribe
    participant getState

    RxJS->>Store Observable: subscribe(observer)
    Store Observable->>getState: getState()
    Store Observable->>RxJS: observer.next(initialState)
    Store Observable->>subscribe: subscribe(observeState)
    Note over subscribe: On every dispatch...
    subscribe->>getState: getState()
    subscribe->>RxJS: observer.next(newState)

$$observable シンボルはインポート時に symbol-observable.ts から解決されます。ネイティブの Symbol.observable サポートがあればそれを使い、なければ @@observable という文字列表現にフォールバックします。

最後に、ストアオブジェクトはプレーンなオブジェクトリテラルとして組み立てられて返されます。

src/createStore.ts#L392-L405

クラスのインスタンス化はなく、new もありません。6つのミュータブルな変数をクロージャで抱えた、5つのメソッドを持つオブジェクトがあるだけです。

次回予告

createStore の完全なライフサイクル — 引数の解析からdispatchサイクル、リスナー通知まで — を追い終えました。次回は combineReducers を取り上げます。スライスreducerを生成時にどう検証するか、実行時の不要なオブジェクト生成を防ぐ参照等価性のトリック、そしてミドルウェアシステム全体を支える compose ユーティリティについて深掘りします。