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つのバレルファイルに宣言されています。全体を見てみましょう。
実行時のエクスポートは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プロパティのみを要求します。
UnknownActionはインデックスシグネチャ[extraProps: string]: unknownを付け加えてこれを拡張し、非推奨のAnyActionは代わりにanyを使います。Reducer型はactionとstateを受け取ります。
Storeインターフェースはすべてを統合します——dispatch、getState、subscribe、replaceReducer、そしてobservableプロトコルです。
頂点に位置するのがStoreEnhancerです。ExtとStateExtというジェネリクスによって、型安全な拡張の合成が可能になります。この詳細はシリーズ第5回で解説します。
パッケージエクスポートとビルドフォーマット
Reduxは単一のソースから4種類の異なるバンドルフォーマットを提供しています。package.jsonのexportsフィールドとレガシーフィールドが、各コンシューマーに渡すフォーマットを制御しています。
| フィールド | ファイル | フォーマット | 対象 |
|---|---|---|---|
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はcreateStoreやcombineReducersなど不要なコードをすべて除去できます。
ヒント: ブラウザバンドルはビルド時に
process.env.NODE_ENVを"production"にハードコードし、ミニファイされます。つまり、開発専用のコードパス(詳細なエラーメッセージ、kindOfによる型名の表示、予期しないstateキーの警告)はすべて除去されます。標準のESMおよびCJSビルドでは、process.env.NODE_ENVのチェックはそのまま残され、コンシューマー側のbundlerが処理します。
アーキテクチャの核心:クラスではなくクロージャー
Reduxを使ったことがあれば、ストアがクラスのインスタンスだと思っているかもしれません。しかし、そうではありません。createStoreはプレーンオブジェクトを返す関数であり、ストアの「プライベートな状態」はthisではなくクロージャー変数の中に存在します。
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
返されるオブジェクトのメソッド——dispatch、subscribe、getState、replaceReducer、そしてobservableアクセサー——は、これらの変数をクロージャーとして参照します。プロトタイプチェーンもthisバインディングの問題もなく、外部のコードは定義されたインターフェースを通じてしかプライベートな状態にアクセスできません。
この設計上の選択は、具体的な利点をもたらします。
thisバインディングの問題がない。const { dispatch, getState } = storeのように分割代入しても、メソッドはそのまま動作します。thisではなくクロージャー変数を参照しているからです。- 真のカプセル化。 プライベートクラスフィールド(devtoolsで検査したり、ハックで回避できる)とは異なり、クロージャー変数は本当にアクセス不能です。
- Enhancerがすっきりと合成できる。 ストアがプレーンオブジェクトなので、enhancerはサブクラス化せずにスプレッドして個々のメソッドをオーバーライドできます:
{ ...store, dispatch: enhancedDispatch }。
このパターン——ミュータブルな変数をクロージャーとして持つメソッドをプレーンオブジェクトとして返すこと——は、Reduxにおいて最も重要な設計上の決断です。このパターンがenhancerとmiddlewareシステム全体を支えており、次の記事から詳しく掘り下げていきます。
次回予告
全体像を把握しました。17のファイル、9つのエクスポート、階層化された型システム、マルチフォーマットのビルド、クロージャーベースのアーキテクチャです。次の記事では、ライブラリの中核であるcreateStore.tsを深く掘り下げます。Reduxを動かす500行の関数を一行ずつ追いながら、引数の振り分けやdispatchサイクル、デュアルMapによるlistenerのスナップショットパターン、middlewareを可能にするenhancerのショートサーキットを解説します。