Architecture
リポジトリ: pmndrs/zustand 分析日: 2026-02-20
概要
zustand のアーキテクチャを「vanilla/react の分離」「ミドルウェア合成」「useSyncExternalStore 統合」の 3 軸で分析した。わずか 16 ソースファイル・約 1,200 行で、フレームワーク非依存のコアストアに React バインディングとミドルウェアチェーンを型安全に積層するレイヤー設計が実現されている。特筆すべきは、declare module による開放型レジストリと再帰型 Mutate<S, Ms> を用いたミドルウェア型合成であり、プラグイン拡張を完全に型推論で追跡できる点が他のライブラリにない独自性を持つ。
背景にある原則
フレームワーク非依存コアの分離: 状態管理のコアロジック(
Set<Listener>+ クロージャ)は React に一切依存しない。src/vanilla.tsはreactを import せず、React バインディングは別ファイルsrc/react.tsが担う。これにより、vanilla コアは Node.js, Vue, Svelte 等あらゆるランタイムで利用可能になる。根拠:src/vanilla.tsに React import が存在しない、peerDependenciesでreactがoptional: true(package.json:168-170)。ミドルウェアはストア API の monkey-patch である: 各ミドルウェアは
StateCreatorを受け取りStateCreatorを返す高階関数であり、実行時にはapi.setState/api.subscribeを自身の関数で上書き(monkey-patch)することで振る舞いを拡張する。新しいラッパーオブジェクトを生成せず、同一のapiオブジェクトを破壊的に修正するため、関数合成がゼロアロケーションで行われる。根拠:src/middleware/devtools.ts:216でapi.setState = ...、src/middleware/persist.ts:231でapi.setState = ...、src/middleware/immer.ts:77でstore.setState = ...。型は declare module で開放し、再帰で積層する: ミドルウェアが
StoreApiに追加するプロパティ(dispatch,persist,devtools等)はdeclare module '../vanilla'でStoreMutatorsインターフェースを拡張することで表現される。型レベルでの積層は再帰型Mutate<S, Ms>が担い、ミドルウェアの適用順序と型変換を正確に追跡する。根拠:src/vanilla.ts:20-26のMutate型定義、src/middleware/redux.ts:28-31のdeclare module。React 統合は薄い橋渡しに徹する:
src/react.tsはuseSyncExternalStoreを呼ぶだけの 37 行のファイルであり、独自の状態管理ロジックを一切持たない。React の Concurrent Mode やサーバーサイドレンダリングへの対応は React 自身の API (useSyncExternalStoreの第 3 引数getServerSnapshot) に委譲している。根拠:src/react.ts:30-34。
実例と分析
レイヤー分離とエントリーポイント戦略
zustand のソースは明確な 3 層構造を持つ:
- Vanilla 層 (
src/vanilla.ts):createStore—StoreApi<T>を返す。React 不要。 - React 層 (
src/react.ts):create— 内部でcreateStoreを呼び、useSyncExternalStoreでフックに変換。UseBoundStore<S>を返す。 - Middleware 層 (
src/middleware/*.ts):StateCreator -> StateCreatorの変換子。層に依存しない。
src/index.ts は vanilla.ts と react.ts の両方を re-export する。ビルド時には rollup.config.mjs で各エントリーポイントを個別バンドルに分割し、package.json の exports フィールドで "zustand", "zustand/vanilla", "zustand/middleware", "zustand/shallow" 等を独立したサブパスとして公開している。これにより、React を使わないプロジェクトは zustand/vanilla だけを import でき、React 依存が tree-shake される。
ミドルウェア合成の実行時メカニズム
ミドルウェアの実行時合成は「内側から外側へ」関数が呼ばれ、各ミドルウェアが api オブジェクトのメソッドを順に上書きする:
create<T>()(devtools(persist(immer(initializer))))実行順序:
createStoreImplがapi = { setState, getState, getInitialState, subscribe }を生成immerのStateCreatorが実行され、store.setStateを immer のproduceでラップpersistのStateCreatorが実行され、api.setStateをストレージ書き込み付きでラップdevtoolsのStateCreatorが実行され、api.setStateを Redux DevTools 送信付きでラップ
各ミドルウェアは set (ローカル引数) と api.setState の両方が渡されるが、monkey-patch は api.setState に対して行われる。set は createStoreImpl が生成したオリジナルの setState を指し、ミドルウェアが内部的に「生の set」にアクセスする手段として機能する。
useSyncExternalStore による React 統合
src/react.ts での React 統合は 3 つの引数を useSyncExternalStore に渡す最小構成:
subscribe:api.subscribeをそのまま渡すgetSnapshot:useCallback(() => selector(api.getState()), [api, selector])でメモ化getServerSnapshot:useCallback(() => selector(api.getInitialState()), [api, selector])で SSR 対応
getServerSnapshot に getInitialState を渡すことで、サーバーサイドでは常に初期状態が返され、ハイドレーション不整合を防ぐ。テスト (tests/ssr.test.tsx) でハイドレーション中の setState がエラーを起こさないことが検証されている。
カリー化オーバーロードによる型推論の改善
create と createStore は「引数ありで即実行」「引数なしでカリー化」の 2 つのオーバーロードを持つ:
// 型推論が効く(ミドルウェア使用時)
const useStore = create<MyState>()(devtools(persist(...)))
// 型推論が効く(ミドルウェアなし)
const useStore = create<MyState>((set) => ({ ... }))create<T>() のように空引数で呼ぶと型パラメータ T が明示され、返された関数にミドルウェアを渡すことで Mos(出力 mutator リスト)が自動推論される。これは TypeScript が「部分的な型パラメータ推論」をサポートしないことへの回避策であり、T を手動指定しつつ Mos を推論させる。
コード例
// src/vanilla.ts:60-97 — コアストア実装(クロージャベース、Reactに依存しない)
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>;
type Listener = (state: TState, prevState: TState) => void;
let state: TState;
const listeners: Set<Listener> = new Set();
const setState: StoreApi<TState>["setState"] = (partial, replace) => {
const nextState = typeof partial === "function"
? (partial as (state: TState) => TState)(state)
: partial;
if (!Object.is(nextState, state)) {
const previousState = state;
state = (replace ?? (typeof nextState !== "object" || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
const getState: StoreApi<TState>["getState"] = () => state;
const getInitialState: StoreApi<TState>["getInitialState"] = () => initialState;
const subscribe: StoreApi<TState>["subscribe"] = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const api = { setState, getState, getInitialState, subscribe };
const initialState = (state = createState(setState, getState, api));
return api as any;
};// src/react.ts:27-37 — useSyncExternalStore による最小 React バインディング
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
);
React.useDebugValue(slice);
return slice;
}// src/vanilla.ts:20-26 — 再帰型によるミドルウェア型積層
export type Mutate<S, Ms> = number extends Ms["length" & keyof Ms] ? S
: Ms extends [] ? S
: Ms extends [[infer Mi, infer Ma], ...infer Mrs] ? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: never;// src/middleware/immer.ts:74-86 — monkey-patch パターンによるミドルウェア実装
const immerImpl: ImmerImpl = (initializer) => (set, get, store) => {
type T = ReturnType<typeof initializer>;
store.setState = (updater, replace, ...args) => {
const nextState = (
typeof updater === "function" ? produce(updater as any) : updater
) as ((s: T) => T) | T | Partial<T>;
return set(nextState, replace as any, ...args);
};
return initializer(store.setState, get, store);
};// src/middleware/redux.ts:28-31 — declare module による型レジストリ拡張
declare module "../vanilla" {
interface StoreMutators<S, A> {
"zustand/redux": WithRedux<S, A>;
}
}パターンカタログ
Decorator パターン (分類: 構造)
- 解決する問題: ストア API に追加の振る舞い(永続化、DevTools 連携、Immer 統合等)を動的に付加したい
- 適用条件: コアインターフェース (
StoreApi) が安定しており、拡張が直交的に組み合わせ可能な場合 - コード例:
src/middleware/persist.ts:229-234でapi.setStateをストレージ書き込み付きで上書き - 注意点: 古典的な Decorator はラッパーオブジェクトを生成するが、zustand は同一オブジェクトの monkey-patch で実現している。これにより参照の一貫性が保たれ、
useSyncExternalStoreに渡すapi.subscribeが常に同一参照になる
Observer パターン (分類: 振る舞い)
- 解決する問題: 状態変更をリスナーに通知する
- 適用条件: 1 対多の通知が必要で、リスナーの登録・解除が動的に行われる場合
- コード例:
src/vanilla.ts:64,79,88-92—Set<Listener>による subscribe/unsubscribe - 注意点:
Setを使うことで O(1) の追加・削除と重複排除を同時に実現している
Strategy パターン (分類: 振る舞い)
- 解決する問題: 状態の同値比較ロジックを差し替えたい(
Object.is/shallow/ カスタム) - 適用条件: 比較ロジックが利用側によって異なる場合
- コード例:
src/traditional.ts:36—useSyncExternalStoreWithSelectorにequalityFnを注入 - 注意点: デフォルトの
useStore(src/react.ts)はObject.is(React のデフォルト)を使い、useStoreWithEqualityFnで Strategy を挿入可能にする 2 段構成
- 解決する問題: 状態の同値比較ロジックを差し替えたい(
Good Patterns
- 最小インターフェースで統合ポイントを定義する:
StoreApi<T>はsetState,getState,getInitialState,subscribeの 4 メソッドのみ。useSyncExternalStoreが要求するsubscribeとgetSnapshotに直接マッピングできるため、React 統合が 7 行で完結する。
// src/react.ts:12-14 — React 側が要求する最小インターフェース
type ReadonlyStoreApi<T> = Pick<
StoreApi<T>,
"getState" | "getInitialState" | "subscribe"
>;Set<Listener>による軽量 pub/sub:Mapや配列ではなくSetを使うことで、リスナーの追加 O(1)・削除 O(1)・重複防止を組み込み関数だけで実現。unsubscribe はSet.deleteを返すだけのワンライナー。
// src/vanilla.ts:88-92
const subscribe: StoreApi<TState>["subscribe"] = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};Object.isによるスキップ付き setState: 状態が変わらなければリスナーを呼ばない。NaN === NaN問題もObject.isで正しく処理される(テスト:tests/vanilla/basic.test.ts:99-107)。
// src/vanilla.ts:73
if (!Object.is(nextState, state)) {- declare module による開放型レジストリ: 各ミドルウェアが
StoreMutatorsインターフェースをdeclare moduleで拡張することで、新しいミドルウェアを追加しても既存コードの変更が不要。サードパーティが独自ミドルウェアを型安全に追加できる。
// src/middleware/redux.ts:28-31
declare module "../vanilla" {
interface StoreMutators<S, A> {
"zustand/redux": WithRedux<S, A>;
}
}Anti-Patterns / 注意点
- ミドルウェア内で
setとapi.setStateを混同する: ミドルウェアは(set, get, api) => ...の 3 引数を受け取るが、setは上位ミドルウェアが差し替え済みの関数であり、api.setStateは直前の monkey-patch 結果を指す。間違ってapi.setStateを直接呼ぶと、自分自身の patch をバイパスしたり無限ループを起こしたりする。
// Bad: ミドルウェア内で api.setState を直接呼び、自身の patch をバイパス
const badMiddleware = (fn) => (set, get, api) => {
api.setState = (state) => {
api.setState(state); // 無限再帰
};
return fn(set, get, api);
};
// Better: 保存した元の関数を呼ぶ
const goodMiddleware = (fn) => (set, get, api) => {
const originalSetState = api.setState;
api.setState = (state, replace) => {
// 追加処理
originalSetState(state, replace);
};
return fn(api.setState, get, api);
};- useSyncExternalStore に渡す selector をインラインで書き毎 render 新規生成する:
useStoreのuseCallbackはselector参照が変わると再生成される。毎 render でインライン関数を渡すとuseSyncExternalStoreWithSelectorなしでは不要な再計算が走る。zustand v5 のデフォルトuseStoreはuseCallbackでgetSnapshotをメモ化しているが、selector 自体が毎 render 新規だとメモ化が無効になる。
// Bad: 毎 render で新しい selector 参照
function Component() {
const count = useStore(store, (s) => s.count); // 毎回新しい関数
return <div>{count}</div>;
}
// Better: selector をコンポーネント外またはメモ化して定義
const selectCount = (s: State) => s.count;
function Component() {
const count = useStore(store, selectCount);
return <div>{count}</div>;
}- ミドルウェアの積層順序を誤る: 型レベルでは
Mutate<S, Ms>が左から右へ適用されるが、実行時は内側(最後に書いたミドルウェア)から外側へ実行される。devtools(persist(immer(...)))ではimmerが最初にapi.setStateを上書きし、次にpersist、最後にdevtoolsが上書きする。devtoolsを最内側に書くと、persistやimmerの変更が DevTools に記録されない。
// Bad: devtools が最内側 — persist の状態変更が DevTools に送信されない
create()(persist(devtools(immer(initializer)), { name: "store" }));
// Better: devtools を最外側に配置
create()(devtools(persist(immer(initializer), { name: "store" })));導出ルール
[MUST]フレームワーク非依存のコアロジックを純粋な vanilla モジュールに分離し、フレームワークバインディングは薄いアダプタ層として別モジュールにする- 根拠: zustand は
src/vanilla.ts(101 行)にコアを、src/react.ts(37 行)にアダプタを置くことで、React なし環境でも利用可能にし、バインディング層のコード量を最小化している
- 根拠: zustand は
[MUST]外部ストアを React に統合する場合、useSyncExternalStoreを使い、getServerSnapshot引数で SSR 時の初期値を明示的に返す- 根拠: zustand は
api.getInitialState()をgetServerSnapshotに渡すことでハイドレーション不整合を防いでおり、tests/ssr.test.tsxでハイドレーション中のsetStateがエラーを起こさないことを検証している
- 根拠: zustand は
[SHOULD]プラグイン/ミドルウェアシステムを設計する際、declare module+ 空インターフェースによる開放型レジストリと再帰型で型合成を追跡する- 根拠: zustand の
StoreMutators<S, A>は空インターフェースとして宣言され、各ミドルウェアがdeclare moduleで拡張する。Mutate<S, Ms>再帰型がミドルウェアリストを左から順に型変換し、4 つのミドルウェアを積んでも型が正しく推論される (tests/middlewareTypes.test.tsx:614-652)
- 根拠: zustand の
[SHOULD]高階関数による拡張で既存オブジェクトを monkey-patch する場合、patch 前の元関数を保存してから上書きし、元関数を内部で呼び出す- 根拠:
src/middleware/persist.ts:229-233はconst savedSetState = api.setStateで保存後に上書きし、src/middleware/devtools.ts:258-263はisRecordingフラグで元関数呼び出しを制御している
- 根拠:
[SHOULD]TypeScript で部分的な型パラメータ推論が必要な場合、「引数なしで型パラメータを固定、返された関数で残りを推論」のカリー化オーバーロードを使う- 根拠:
src/vanilla.ts:43-51のCreateStore型とsrc/react.ts:44-51のCreate型が、create<T>()でTを固定しつつMosを推論させるパターンを実現している
- 根拠:
[SHOULD]pub/sub の listener コレクションにはSetを使い、unsubscribe はSet.deleteを返す- 根拠:
src/vanilla.ts:64,88-92—Setは O(1) の追加・削除・重複防止を提供し、unsubscribe 関数がワンライナーで表現できる
- 根拠:
[AVOID]ライブラリ公開時に単一エントリーポイントからフレームワーク依存・非依存コードを混在 export すること(サブパス exports で分離する)- 根拠: zustand は
package.jsonのexportsで".","./*"のサブパスを定義し、zustand/vanillaは React を含まないバンドルとして独立している。rollup.config.mjsで 9 つの個別ビルドを生成してツリーシェイク可能にしている
- 根拠: zustand は
適用チェックリスト
- [ ] 状態管理ライブラリを設計する場合、コアストアをフレームワーク非依存モジュールに分離しているか
- [ ] React バインディングは
useSyncExternalStoreを使い、getServerSnapshotで SSR 初期値を返しているか - [ ] ミドルウェア/プラグインシステムで API を拡張する場合、
declare module+ 空インターフェースの開放型レジストリを検討したか - [ ] ミドルウェアの monkey-patch は元関数を保存してから上書きし、内部で元関数を呼び出しているか
- [ ] TypeScript の部分的型推論が必要な場面でカリー化オーバーロードを検討したか
- [ ] pub/sub パターンで
Set<Listener>を使い、unsubscribe をdeleteのクロージャで返しているか - [ ]
package.jsonのexportsフィールドでサブパスを定義し、フレームワーク依存コードを分離しているか - [ ] 状態比較に
Object.isを使い、NaNや-0のエッジケースを正しく処理しているか