クロージャとスコープを学ぶ中で、Zustandはクロージャをどう活用しているかを示す絶好の実例だと気づいた。そこで自らソースコードを解析してみることにした。
コア構造 - vanilla.ts
Zustandの本質は vanilla.ts にある約100行のコードに集約されている。残りはReactやミドルウェアと連携するための「ラッパー」に近い。
可読性のために型を取り除いたコードを見ていこう。
// vanilla.ts
const createStoreImpl = (createState) => {
// --- クロージャの開始 ---
let state;
const listeners = new Set();
const getState = () => state;
const setState = (partial, replace) => {
// partial は関数またはオブジェクトになり得る
const nextState = typeof partial === 'function' ? partial(state) : partial;
// 前の状態と次の状態が異なる場合のみ更新・通知
if (!Object.is(nextState, state)) {
const previousState = state;
// 新しい状態で置き換え(オブジェクトはマージ、それ以外は上書き)
state =
replace ?? (typeof nextState !== 'object' || nextState === null)
? nextState
: Object.assign({}, state, nextState);
// すべての購読者(listener)に変更を通知
listeners.forEach((listener) => listener(state, previousState));
}
};
const subscribe = (listener) => {
listeners.add(listener);
// 購読解除関数を返す
return () => listeners.delete(listener);
};
const destroy = () => listeners.clear();
// 外部に公開する API オブジェクト
const api = { setState, getState, subscribe, destroy };
// 初期化! createState を実行して初期状態を設定
state = createState(setState, getState, api);
return api;
// --- クロージャの終わり ---
};
export const createStore = (createState) =>
createState ? createStoreImpl(createState) : createStoreImpl;
createStoreImpl
が実行されると、その内部で宣言された state
と listeners
は通常のローカル変数のようには消えない。クロージャのおかげでメモリ上に保持され続ける。
返される api
オブジェクトに含まれる getState
、setState
、subscribe
といった関数は、生成時の環境(スコープ)を覚えている。そのスコープには state
と listeners
が含まれている。
- カプセル化:
state
変数はcreateStoreImpl
のスコープの中に「閉じ込められて」いる。外部からはgetState
やsetState
のようなAPIメソッド経由でしかアクセスできない。- 状態の持続:
createStoreImpl
の呼び出しは一度きりだが、その結果として生まれたstate
とlisteners
はアプリの実行中ずっと維持される。APIメソッドがそれらを参照し続けるからだ。
React 統合 - react.ts
次に、バニラストアをどのようにReactと統合しているかを見ていく。同様に型は取り除いてある。
import React from 'react';
import { createStore } from './vanilla.js';
// バニラストアとReactをつなぐブリッジフック
export function useStore(api, selector) {
const slice = React.useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
);
return slice;
}
const createImpl = (createState) => {
// 1. バニラストアの API を生成(ここで state と listeners がクロージャにより生成)
const api = createStore(createState);
// 2. このストアにバインドされた専用フックを作成
const useBoundStore = (selector) => useStore(api, selector);
// 3. フック自体に API メソッド(setState など)を付与
Object.assign(useBoundStore, api);
return useBoundStore;
};
export const create = (createState) =>
createState ? createImpl(createState) : createImpl;
- バニラストアを生成する。
useBoundStore
を通じてuseSyncExternalStore
を返すフックを作る。- 返されたフックに API メソッド(
setState
,getState
など)をバインドする。
useSyncExternalStore の役割
useSyncExternalStore
は React 18 で導入されたフックで、外部ストアとReactを同期させる。ここでは外部データソース(Zustand のストア)を購読し、変更時にリレンダーを発生させる役目を担う。
// useSyncExternalStore の内部(簡略化)
useEffect(() => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({inst});
}
const handleStoreChange = () => {
if (checkIfSnapshotChanged(inst)) {
// 強制リレンダー
forceUpdate({inst});
}
};
// ストアを購読し、クリーンアップ関数を返す
return subscribe(handleStoreChange);
}, [subscribe]);
useSyncExternalStore
では、値を比較してコンポーネント更新を強制する handleStoreChange
がリスナーとして登録される。これにより、setState
がリスナーを呼び出すと handleStoreChange
が実行され、コンポーネントのリレンダーがトリガーされる。
DevTools で確かめる
const useBearStore = create((set, get) => ({
bears: 0,
increase: () => set(state => ({ bears: state.bears + 1 })),
}));
function Component() {
const bears = useBearStore(state => state.bears);
return <h1>{bears} bears</h1>;
}
上のようなコンポーネントを実際に動かし、DevTools の Sources → Scope タブを見ると、すべてがクロージャでキャプチャされている様子が次のように確認できる。
ステップごとの流れ
// 1. ストアフックを作成
const useBearStore = create((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
}));
// 2. コンポーネントでストアを使用
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <h1>{bears} bears</h1>;
}
// 3. 状態を更新
function Controls() {
const increase = useBearStore((state) => state.increase);
return <button onClick={increase}>one up</button>;
}
ステップ1: ストアの作成(create
)
create
が呼び出されるとcreateStoreImpl
が実行される。- このとき、
useBearStore
専用のstate
とlisteners
がクロージャスコープに生成される。 state
は{ bears: 0, increase: [Function] }
に初期化される。increase
はクロージャによってsetState
を覚えている。useBearStore
という名前のカスタムフックが返される。このフックはsetState
やgetState
といったメソッドも併せ持つ。
ステップ2: コンポーネントのレンダーと購読(useBearStore
)
BearCounter
コンポーネントが初回レンダーされると、useBearStore
フックが実行される。useSyncExternalStore
が呼ばれ、ステップ1で作られたlisteners
にリレンダーを引き起こす関数がsubscribe
経由で登録される。getSnapshot
コールバック() => state.bears
が実行され、初期値0
を取得してbears
変数に代入し、画面に「0 bears」を描画する。
ステップ3: 状態更新(increase
)
- ユーザーがボタンをクリックすると、
Controls
コンポーネントで取得したincrease
が実行される。 increase
はクロージャで保持していたsetState
を呼ぶ。setState
はstate
を{ bears: 1 }
に更新する。setState
はlisteners
を走査して登録済みの関数をすべて呼び出す。BearCounter
のuseSyncExternalStore
が登録した「リレンダー関数」が呼ばれる。- React は
BearCounter
をリレンダーし、getSnapshot
を再実行して新しい値1
を取得し、画面を更新する。
まとめ
Zustand はクロージャの強力さと実用性を示す好例だ。わずか約100行(型を除けば約40行)で次の目的を達成している。
- カプセル化: 状態を外部から直接アクセスできないよう保護する。
- 購読パターン:
Set
とuseSyncExternalStore
により状態変更を効率よく伝播し、必要なコンポーネントだけを正確にリレンダーする。 - 単純さ: 複雑なリデューサやアクション型、ディスパッチャなしに直感的なAPIを提供する。
Zustandの分析を通して、腹落ちしづらい概念であるクロージャも、適切に活用すれば強力な抽象を作れることを実感した。こうしたパターンを理解すれば、より良い状態管理ソリューションを作れたり、既存ライブラリをより深く理解できるはずだ。