Read OSS

Reduxを1,575行で読む:アーキテクチャ概観とプロジェクトナビゲーション

中級

前提知識

  • JavaScriptの基礎とES modulesの知識
  • Reduxをユーザーとして使った経験(actions、reducers、store)

Reduxを1,575行で読む:アーキテクチャ概観とプロジェクトナビゲーション

Reduxは、一世代のJavaScript開発者の状態管理に対する考え方を根本から変えてきました。しかし、toolkitやmiddleware、dev toolsといったエコシステムの裏側にあるコアライブラリは、わずか17のソースファイルに収まり、実行時に公開するエクスポートはたった9つです。この17ファイルを読み解くことは単なるコード考古学ではなく、クロージャーによるカプセル化や関数合成、意図的に絞り込まれたAPI設計の力といった、Reduxをはるかに超えて通用する設計原則がここに凝縮されています。

この記事では、その全体像を俯瞰します。すべての公開エクスポートを確認し、ファイルとAPIの対応関係を追います。型システムの階層構造を整理し、マルチフォーマットのビルド設定を解説したうえで、Reduxを動かす核心的なアーキテクチャの考え方にたどり着きます。

公開APIの全体像

reduxからインポートできるものはすべて、1つのバレルファイルに宣言されています。全体を見てみましょう。

src/index.ts#L1-L50

実行時のエクスポートは9つ。それだけです。カテゴリ別に整理してみましょう。

エクスポート カテゴリ 用途
createStore コア ストアを作成する(RTKのconfigureStoreを推奨するため非推奨)
legacy_createStore コア 同じ実装だが、エディターで非推奨警告が表示されない
combineReducers コンポジション スライスreducerのマップを一つのルートreducerにまとめる
applyMiddleware コンポジション middleware関数群からstore enhancerを生成する
compose ユーティリティ 右から左への関数合成
bindActionCreators ユーティリティ action creatorをラップして自動dispatchさせる
isAction ガード 型ガード:値がRedux actionかどうかを確認する
isPlainObject ガード 値がプレーンな{}オブジェクトかどうかを確認する
__DO_NOT_USE__ActionTypes エスケープハッチ 内部のaction type。DevTools専用で公開されている

__DO_NOT_USE__ActionTypesという名前は意図的に攻撃的です。これは、このエクスポートがRedux DevTools拡張機能のためだけに存在しているというシグナルです。DevToolsはランダム化された@@redux/INIT@@redux/REPLACEといったaction type文字列を知る必要があります。アプリケーションのコードがこれを参照することは絶対にあってはなりません。

legacy_createStoreというエイリアスも注目に値します。ReduxチームがユーザーをRedux Toolkitへ誘導するためにcreateStoreを非推奨にした際、IDEの取り消し線警告を回避したいユーザーへの手段が必要でした。その解決策が、まったく同じ実装を指す2つ目の名前付きエクスポートです。

ヒント: 見慣れないコードベースを読むときは、バレルエクスポートから始めましょう。そこには、ライブラリが「使ってほしい」と考えているものが示されており、それはライブラリが「内包しているもの」のほんの一部に過ぎません。

ディレクトリ構成とファイルとAPIの対応

Reduxのソースファイルと公開API関数は、珍しい1対1の対応関係を持っています。間接的な抽象化も、factory-of-factoriesのような入れ子構造もありません。各ファイルが単一の責務を担っています。

ソースファイル 公開エクスポート 行数
src/createStore.ts createStore, legacy_createStore ~500
src/combineReducers.ts combineReducers ~200
src/applyMiddleware.ts applyMiddleware ~77
src/compose.ts compose ~61
src/bindActionCreators.ts bindActionCreators ~83
src/types/actions.ts 型定義 ~75
src/types/reducers.ts 型定義 ~115
src/types/store.ts 型定義 ~233
src/types/middleware.ts 型定義 ~31
src/utils/actionTypes.ts __DO_NOT_USE__ActionTypes ~17
src/utils/isAction.ts isAction ~10
src/utils/isPlainObject.ts isPlainObject ~16
src/utils/kindOf.ts 内部のみ ~70
src/utils/warning.ts 内部のみ ~18
src/utils/symbol-observable.ts 内部のみ ~10
src/utils/formatProdErrorMessage.ts 内部のみ ~13
graph TD
    subgraph "Public API (src/)"
        CS[createStore.ts]
        CR[combineReducers.ts]
        AM[applyMiddleware.ts]
        CO[compose.ts]
        BA[bindActionCreators.ts]
    end

    subgraph "Types (src/types/)"
        TA[actions.ts]
        TR[reducers.ts]
        TS[store.ts]
        TM[middleware.ts]
    end

    subgraph "Utils (src/utils/)"
        AT[actionTypes.ts]
        IA[isAction.ts]
        IP[isPlainObject.ts]
        KO[kindOf.ts]
        WA[warning.ts]
        SO[symbol-observable.ts]
        FP[formatProdErrorMessage.ts]
    end

    CS --> AT
    CS --> IP
    CS --> KO
    CS --> SO
    CR --> AT
    CR --> IP
    CR --> WA
    CR --> KO
    AM --> CO
    BA --> KO
    IA --> IP

utils/ディレクトリは内部専用のレイヤーです。kindOf(エラーメッセージ向けの詳細な型説明を生成する関数)やwarning(デバッガーに優しいthrowを伴うconsole.errorのラッパー)といった関数は、外部には一切公開されません。中でもformatProdErrorMessageは特筆すべきユーティリティです——ビルド時の置換ターゲットとしてのみ存在しており、このシリーズの最終回でその仕組みを詳しく掘り下げます。

型システムの階層構造

ReduxのTypeScript型は、4つのファイルにまたがる依存チェーンとして整理されており、各ファイルは前のファイルの上に成り立っています。

flowchart LR
    A["actions.ts<br/><i>Action, UnknownAction</i>"] --> B["reducers.ts<br/><i>Reducer, ReducersMapObject</i>"]
    B --> C["store.ts<br/><i>Store, StoreEnhancer, Dispatch</i>"]
    C --> D["middleware.ts<br/><i>Middleware, MiddlewareAPI</i>"]

基盤となるのはAction<T>です。これは(TypeScriptの構造的な理由から)interfaceではなくtypeとして定義されており、type: stringプロパティのみを要求します。

src/types/actions.ts#L19-L21

UnknownActionはインデックスシグネチャ[extraProps: string]: unknownを付け加えてこれを拡張し、非推奨のAnyActionは代わりにanyを使います。Reducer型はactionとstateを受け取ります。

src/types/reducers.ts#L30-L34

Storeインターフェースはすべてを統合します——dispatchgetStatesubscribereplaceReducer、そしてobservableプロトコルです。

src/types/store.ts#L81-L165

頂点に位置するのがStoreEnhancerです。ExtStateExtというジェネリクスによって、型安全な拡張の合成が可能になります。この詳細はシリーズ第5回で解説します。

パッケージエクスポートとビルドフォーマット

Reduxは単一のソースから4種類の異なるバンドルフォーマットを提供しています。package.jsonexportsフィールドとレガシーフィールドが、各コンシューマーに渡すフォーマットを制御しています。

package.json#L26-L36

フィールド ファイル フォーマット 対象
exports["."].import dist/redux.mjs ESM モダンなbundler(Vite、webpack 5、Rollup)
exports["."].default dist/cjs/redux.cjs CJS Node.jsのrequire()、古いツール
module dist/redux.legacy-esm.js Legacy ESM (.js) Webpack 4(moduleフィールドを読むが.mjsを認識しない)
(build only) dist/redux.browser.mjs Browser ESM(ミニファイ済み) <script type="module">での直接利用
flowchart TD
    SRC["src/index.ts"] --> TSUP["tsup / esbuild"]
    TSUP --> ESM["redux.mjs<br/><i>ESM, dev+prod guards</i>"]
    TSUP --> LEGACY["redux.legacy-esm.js<br/><i>ESM for Webpack 4</i>"]
    TSUP --> BROWSER["redux.browser.mjs<br/><i>ESM, minified, prod-only</i>"]
    TSUP --> CJS["cjs/redux.cjs<br/><i>CommonJS</i>"]

package.jsonの84行目にあるsideEffects: falseの宣言は非常に重要です——これはbundlerに対して、すべてのエクスポートが安全にtree-shakeできることを伝えます。composeだけをインポートすれば、bundlerはcreateStorecombineReducersなど不要なコードをすべて除去できます。

ヒント: ブラウザバンドルはビルド時にprocess.env.NODE_ENV"production"にハードコードし、ミニファイされます。つまり、開発専用のコードパス(詳細なエラーメッセージ、kindOfによる型名の表示、予期しないstateキーの警告)はすべて除去されます。標準のESMおよびCJSビルドでは、process.env.NODE_ENVのチェックはそのまま残され、コンシューマー側のbundlerが処理します。

アーキテクチャの核心:クラスではなくクロージャー

Reduxを使ったことがあれば、ストアがクラスのインスタンスだと思っているかもしれません。しかし、そうではありません。createStoreはプレーンオブジェクトを返す関数であり、ストアの「プライベートな状態」はthisではなくクロージャー変数の中に存在します。

src/createStore.ts#L146-L153

6つのlet宣言。それがReduxストアのプライベートな状態のすべてです。

flowchart TD
    subgraph "Closure Scope (private)"
        CR[currentReducer]
        CST[currentState]
        CL[currentListeners]
        NL[nextListeners]
        LIC[listenerIdCounter]
        ID[isDispatching]
    end

    subgraph "Returned Object (public)"
        D["dispatch()"]
        S["subscribe()"]
        G["getState()"]
        R["replaceReducer()"]
        O["[Symbol.observable]()"]
    end

    D --> CR
    D --> CST
    D --> ID
    D --> CL
    D --> NL
    G --> CST
    S --> NL
    S --> LIC
    R --> CR

返されるオブジェクトのメソッド——dispatchsubscribegetStatereplaceReducer、そしてobservableアクセサー——は、これらの変数をクロージャーとして参照します。プロトタイプチェーンもthisバインディングの問題もなく、外部のコードは定義されたインターフェースを通じてしかプライベートな状態にアクセスできません。

この設計上の選択は、具体的な利点をもたらします。

  1. thisバインディングの問題がない。 const { dispatch, getState } = storeのように分割代入しても、メソッドはそのまま動作します。thisではなくクロージャー変数を参照しているからです。
  2. 真のカプセル化。 プライベートクラスフィールド(devtoolsで検査したり、ハックで回避できる)とは異なり、クロージャー変数は本当にアクセス不能です。
  3. Enhancerがすっきりと合成できる。 ストアがプレーンオブジェクトなので、enhancerはサブクラス化せずにスプレッドして個々のメソッドをオーバーライドできます:{ ...store, dispatch: enhancedDispatch }

このパターン——ミュータブルな変数をクロージャーとして持つメソッドをプレーンオブジェクトとして返すこと——は、Reduxにおいて最も重要な設計上の決断です。このパターンがenhancerとmiddlewareシステム全体を支えており、次の記事から詳しく掘り下げていきます。

次回予告

全体像を把握しました。17のファイル、9つのエクスポート、階層化された型システム、マルチフォーマットのビルド、クロージャーベースのアーキテクチャです。次の記事では、ライブラリの中核であるcreateStore.tsを深く掘り下げます。Reduxを動かす500行の関数を一行ずつ追いながら、引数の振り分けやdispatchサイクル、デュアルMapによるlistenerのスナップショットパターン、middlewareを可能にするenhancerのショートサーキットを解説します。