この文章は zustand v4.3.8 に基づいています
WeChat プッシュ Zhihu リンク
zustand の 4.3.8 タグリンク、
zustand の ドキュメントアドレス
使用方法#
zustand
は、パブリッシュ・サブスクライブモデルに基づいて実装された状態管理ライブラリであり、react
プロジェクトに限定されず使用できますが、react
のサポートは公式に実装されており、非常にシンプルに使用できます。使用例は以下の通りです。
// jsプロジェクトで使用、型は不要
import { create } from "zustand";
const initStateCreateFunc = (set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
});
const useBearStore = create(initStateCreateFunc);
// tsプロジェクト、型のヒントが必要
import { create } from "zustand";
interface BearState {
bears: number;
increase: (by: number) => void;
}
const initStateCreateFunc = (set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
});
const useBearStore = create<BearState>()(initStateCreateFunc);
上記のコードでは、create
関数を呼び出すと、useStore
のフックが生成されます。このフックの基本的な使用方法は、redux
のuseSelector
とまったく同じです。
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <h1>{bears} ここにいます...</h1>;
}
function Controls() {
const increase = useBearStore((state) => state.increase);
return <button onClick={increase}>一つ増やす</button>;
}
注意深い方は、js と ts の使用に違いがあることに気づくかもしれません。ts は create
<BearState>()(initStateCreateFunc)
です。その理由は後述します。
ソースコードの主な流れ#
zustand
のコアは、外部のstore
とコンポーネントのview
の相互作用です。相互作用のコアプロセスは以下の図の通りです。
まず、create
関数を使用して、注入されたinitStateCreateFunc
に基づいてクロージャのstore
を作成し、対応するsubscribe
、setState
、getState
、(この API は削除される予定です)などのdestory
API
を公開します。
react
が提供するuseSyncExternalStoreWithSelector
を利用することで、store
とview
層を結びつけ、外部のstore
を使用してページの表示を制御できます。
zustand
はmiddleware
の機能もサポートしており、create(middleware(...args))
の形式で対応するmiddleware
を使用できます。
コアコードの詳細解説#
この部分では、最も重要なcreate
とuseSyncExternalStoreWithSelector
関数について説明します。
create 関数による store の生成#
読みやすくするために、コードは省略されています。
前提知識の紹介#
create
関数によって生成されるstore
はクロージャであり、API
を公開することでstore
へのアクセスを実現します。
コアコードはvanilla.ts
とreact.ts
の 2 つのファイルにあります。vanilla.ts
では、pub-sub
機能を持つ完全なstore
が実装されており、react
に依存せずに使用できます。
react.ts
では、useSyncExternalStoreWithSelector
に基づいてuseStore
のフックが実装されており、コンポーネント内でcreate
が返す関数を呼び出すと、store
とコンポーネントが結びつきます。この結びつきはuseStore
によって実現されます。このuseSyncExternalStoreWithSelector
については次のセクションで説明します。
create の実行フロー#
create
関数が呼び出されると、まずvanilla.ts
からエクスポートされたcreateStore
を使用してstore
を生成し、次にuseBoundStore
関数を定義します。返り値はuseStore(api, selector, equalityFn)
であり、createStore
から返されたAPI
をuseBoundStore
に注入し、useBoundStore
を返します。このuseBoundStore
の使用方法はuseSelector
とまったく同じです。
注釈付きの簡略化されたソースコード#
コードを見ると、
createStore
とcreate
の 2 つの関数はどちらも(createState) => createState ? createStoreImpl(createState) : createStoreImpl
の形式であることがわかります。公式ドキュメントのts ガイドを参照すると、公式の ts プロジェクトでの呼び出し方は次のようになります:create()(...args)
ドキュメントのコード例の下に、TypeScript/issues/10571を処理するために実装されたワークアラウンドについての説明があります。これが上記の ts と js の使用方法の不一致の理由です。
// storeのクロージャを生成し、APIを返す
// createStateは使用者がstoreを作成する際に渡す関数
const createStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>;
type Listener = (state: TState, prevState: TState) => void;
// ここでのstateはstoreであり、クロージャで、公開されたAPIを通じてアクセスされる
let state: TState;
const listeners: Set<Listener> = new Set();
// setStateのpartialパラメータはオブジェクトと関数をサポートし、replaceはstoreを全量置き換えるかマージするかを示します
// 更新は浅い比較です
const setState = (partial, replace) => {
const nextState = typeof partial === "function" ? partial(state) : partial;
// 等しい場合にのみ更新し、listenerをトリガーします
if (!Object.is(nextState, state)) {
const previousState = state;
state =
replace ?? typeof nextState !== "object"
? (nextState as TState)
: Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
const getState = () => state;
const subscribe = (listener) => {
listeners.add(listener);
// 購読解除
return () => listeners.delete(listener);
};
// destoryは後で削除されるため、見る必要はありません
const destroy: StoreApi<TState>["destroy"] = () => {
if (import.meta.env?.MODE !== "production") {
console.warn(
"[DEPRECATED] `destroy`メソッドは将来のバージョンでサポートされなくなります。代わりにsubscribeから返されたunsubscribe関数を使用してください。storeがガーベジコレクトされると、すべてがガーベジコレクトされます。"
);
}
listeners.clear();
};
const api = { setState, getState, subscribe, destroy };
// ここが公式の例にあるset,get,apiです
state = createState(setState, getState, api);
return api as any;
};
// createStoreを呼び出すとき、理論的にはcreateState関数は必ず存在します
// ただし、ts型定義のため、createStore<T>()(()=>{})があるため、空値を手動で呼び出す場合があります
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore;
export function useStore<TState, StateSlice>(
api: WithReact<StoreApi<TState>>,
selector: (state: TState) => StateSlice = api.getState as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
selector,
equalityFn
);
useDebugValue(slice);
return slice;
}
const createImpl = (createState) => {
if (
import.meta.env?.MODE !== "production" &&
typeof createState !== "function"
) {
console.warn(
"[DEPRECATED] バニラストアを渡すことは将来のバージョンでサポートされなくなります。代わりに`import { useStore } from 'zustand'`を使用してください。"
);
}
// カスタムストアを直接注入するとAPIが注入されず、注入されたストア内で自分で実装する必要があります
const api =
typeof createState === "function" ? createStore(createState) : createState;
const useBoundStore: any = (selector?: any, equalityFn?: any) =>
useStore(api, selector, equalityFn);
Object.assign(useBoundStore, api);
return useBoundStore;
};
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create;
useSyncExternalStoreWithSelector の解析#
zustand
のコアコードがこれほどシンプルである大きな理由は、useSyncExternalStoreWithSelector
を使用しているからです。これはreact
公式のuse-sync-external-store/shim/with-selector
パッケージです。このパッケージが作成された理由は、react
がuseSyncExternalStoreというフックを提案した後、react v18
バージョンで再実装を行い、破壊的な更新があったためです。互換性を考慮してこのパッケージが作成されました。
多くを語らず、ソースコードを見てみましょう。
この実装は、公式のuseSyncExternalStore
に基づいて行われたラッパーであり、公式のフックはselector
を渡すことができませんが、ラッパー後はselector
とisEqual
をサポートしています。
useSyncExternalStore
には、subscribe
とgetSnapshot
の 2 つの関数を渡す必要があり、返り値はgetSnapshot
の返り値です。react
はsubscribe
にコールバック関数を注入し、外部のstore
が変化したときには必ず手動でコールバックを呼び出してreact
に外部のstore
が変化したことを通知し、再度getSnapshot
を呼び出して最新の状態を取得する必要があります。状態が変わった場合はre-render
をトリガーし、そうでなければre-render
しません。
useSyncExternalStoreWithSelector
の最適化は、1 つの大きなstore
からコンポーネントが使用する部分を取り出すことを許可し、同時にisEqual
を利用してre-render
の回数を減らすことです。
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (() => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: void | null | (() => Snapshot),
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {
// レンダリングされたスナップショットを追跡するために使用します。
const instRef = useRef<
| {
hasValue: true,
value: Selection,
}
| {
hasValue: false,
value: null,
}
| null,
>(null);
let inst;
if (instRef.current === null) {
inst = {
hasValue: false,
value: null,
};
instRef.current = inst;
} else {
inst = instRef.current;
}
/**
* zustandを使用する際はuseStore(selector)の形式を採用し、毎回re-render時に新しいselectorを取得します
* そのため、re-render後のgetSelectionは常に新しいですが、instRef.currentおよびisEqualがあるため
* isEqualのときはinstRef.currentにキャッシュされた値を返し、getSelectionの返り値が変わらない
* 再度re-renderされず、re-renderの回数が減ります
*/
const [getSelection, getServerSelection] = useMemo(() => {
// このメモ化されたgetSnapshot関数のインスタンスにローカルなクロージャ変数を使用してメモ化された状態を追跡します。
// useRefフックを意図的に使用していないのは、その状態がフック/コンポーネントのすべての同時コピーで共有されるためです。
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection: Selection;
const memoizedSelector = (nextSnapshot: Snapshot) => {
if (!hasMemo) {
// フックが初めて呼び出されたとき、メモ化された結果はありません。
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined) {
// セレクタが変わった場合でも、現在レンダリングされている選択が新しい選択と等しい可能性があります。
// 可能であれば現在の値を再利用し、下流のメモ化を保持します。
if (inst.hasValue) {
const currentSelection = inst.value;
if (isEqual(currentSelection, nextSelection)) {
memoizedSelection = currentSelection;
return currentSelection;
}
}
}
memoizedSelection = nextSelection;
return nextSelection;
}
// 前回の呼び出しの結果を再利用できるかもしれません。
const prevSnapshot: Snapshot = (memoizedSnapshot: any);
const prevSelection: Selection = (memoizedSelection: any);
if (is(prevSnapshot, nextSnapshot)) {
// スナップショットが前回と同じです。前の選択を再利用します。
return prevSelection;
}
// スナップショットが変わったので、新しい選択を計算する必要があります。
const nextSelection = selector(nextSnapshot);
// カスタムisEqual関数が提供されている場合、それを使用してデータが変わったかどうかを確認します。
// 変わっていなければ、前の選択を返します。それはReactに選択が概念的に等しいことを示し、
// レンダリングを中止することができます。
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
// Flowが変更できないことを知っているように、これを定数に割り当てます。
const maybeGetServerSnapshot =
getServerSnapshot === undefined ? null : getServerSnapshot;
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
const getServerSnapshotWithSelector =
maybeGetServerSnapshot === null
? undefined
: () => memoizedSelector(maybeGetServerSnapshot());
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(
subscribe,
getSelection,
getServerSelection,
);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}