Zustandの動作原理を理解する(Closure とともに)

profile image

Zustandのソースコードを読み解き、クロージャを活用した状態管理の中核となる原理を掘り下げ、約100行で実装された強力な状態管理ライブラリの秘密に迫る。

この記事は Jetbrains's Coding Agent Junie junie logoによって翻訳されました。誤訳があれば教えてください!

クロージャとスコープを学ぶ中で、Zustandはクロージャをどう活用しているかを示す絶好の実例だと気づいた。そこで自らソースコードを解析してみることにした。

コア構造 - vanilla.ts

Zustandの本質は vanilla.ts にある約100行のコードに集約されている。残りはReactやミドルウェアと連携するための「ラッパー」に近い。

可読性のために型を取り除いたコードを見ていこう。

javascript
// 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 が実行されると、その内部で宣言された statelisteners は通常のローカル変数のようには消えない。クロージャのおかげでメモリ上に保持され続ける

返される api オブジェクトに含まれる getStatesetStatesubscribe といった関数は、生成時の環境(スコープ)を覚えている。そのスコープには statelisteners が含まれている。

  • カプセル化: state 変数は createStoreImpl のスコープの中に「閉じ込められて」いる。外部からは getStatesetState のようなAPIメソッド経由でしかアクセスできない。
  • 状態の持続: createStoreImpl の呼び出しは一度きりだが、その結果として生まれた statelisteners はアプリの実行中ずっと維持される。APIメソッドがそれらを参照し続けるからだ。

React 統合 - react.ts

次に、バニラストアをどのようにReactと統合しているかを見ていく。同様に型は取り除いてある。

javascript
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;
  1. バニラストアを生成する。
  2. useBoundStore を通じて useSyncExternalStore を返すフックを作る。
  3. 返されたフックに API メソッド(setState, getState など)をバインドする。

useSyncExternalStore の役割

useSyncExternalStore は React 18 で導入されたフックで、外部ストアとReactを同期させる。ここでは外部データソース(Zustand のストア)を購読し、変更時にリレンダーを発生させる役目を担う。

javascript
// useSyncExternalStore の内部(簡略化)
useEffect(() => {
  if (checkIfSnapshotChanged(inst)) {
    forceUpdate({inst});
  }
  const handleStoreChange = () => {
    if (checkIfSnapshotChanged(inst)) {
      // 強制リレンダー
      forceUpdate({inst});
    }
  };
  // ストアを購読し、クリーンアップ関数を返す
  return subscribe(handleStoreChange);
}, [subscribe]);

useSyncExternalStore では、値を比較してコンポーネント更新を強制する handleStoreChange がリスナーとして登録される。これにより、setState がリスナーを呼び出すと handleStoreChange が実行され、コンポーネントのリレンダーがトリガーされる。

DevTools で確かめる

javascript
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 タブを見ると、すべてがクロージャでキャプチャされている様子が次のように確認できる。

scope-dev-tools.webp

ステップごとの流れ

javascript
// 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

  1. create が呼び出されると createStoreImpl が実行される。
  2. このとき、useBearStore 専用の statelisteners がクロージャスコープに生成される。
  3. state{ bears: 0, increase: [Function] } に初期化される。increase はクロージャによって setState を覚えている。
  4. useBearStore という名前のカスタムフックが返される。このフックは setStategetState といったメソッドも併せ持つ。

ステップ2: コンポーネントのレンダーと購読(useBearStore

  1. BearCounter コンポーネントが初回レンダーされると、useBearStore フックが実行される。
  2. useSyncExternalStore が呼ばれ、ステップ1で作られた listeners にリレンダーを引き起こす関数が subscribe 経由で登録される。
  3. getSnapshot コールバック () => state.bears が実行され、初期値 0 を取得して bears 変数に代入し、画面に「0 bears」を描画する。

ステップ3: 状態更新(increase

  1. ユーザーがボタンをクリックすると、Controls コンポーネントで取得した increase が実行される。
  2. increase はクロージャで保持していた setState を呼ぶ。
  3. setStatestate{ bears: 1 } に更新する。
  4. setStatelisteners を走査して登録済みの関数をすべて呼び出す。
  5. BearCounteruseSyncExternalStore が登録した「リレンダー関数」が呼ばれる。
  6. React は BearCounter をリレンダーし、getSnapshot を再実行して新しい値 1 を取得し、画面を更新する。

まとめ

Zustand はクロージャの強力さと実用性を示す好例だ。わずか約100行(型を除けば約40行)で次の目的を達成している。

  1. カプセル化: 状態を外部から直接アクセスできないよう保護する。
  2. 購読パターン: SetuseSyncExternalStore により状態変更を効率よく伝播し、必要なコンポーネントだけを正確にリレンダーする。
  3. 単純さ: 複雑なリデューサやアクション型、ディスパッチャなしに直感的なAPIを提供する。

Zustandの分析を通して、腹落ちしづらい概念であるクロージャも、適切に活用すれば強力な抽象を作れることを実感した。こうしたパターンを理解すれば、より良い状態管理ソリューションを作れたり、既存ライブラリをより深く理解できるはずだ。