This article is based on zustand v4.3.8
WeChat Push Zhihu Link
zustand's 4.3.8 tags link,
zustand's documentation address
Usage#
zustand
is a state management library implemented based on the publish-subscribe model, which can be used not only in react
projects, but its support for react
is officially implemented, making it very simple to use. An example of usage is as follows:
// Used in a js project, no types needed
import { create } from "zustand";
const initStateCreateFunc = (set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
});
const useBearStore = create(initStateCreateFunc);
// In a ts project, type hints are needed
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);
As shown in the code above, after calling the create
function, a useStore
hook is generated, which is used in the same way as redux
's useSelector
.
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <h1>{bears} around here...</h1>;
}
function Controls() {
const increase = useBearStore((state) => state.increase);
return <button onClick={increase}>one up</button>;
}
If you pay close attention, you may notice that the usage of js and ts is different; ts uses create
<BearState>()(initStateCreateFunc)
, the reason will be explained later.
Source Code Main Process#
The core of zustand
is the interaction between the external store
and the component view
. The core process of interaction is shown in the following diagram.
First, use the create
function to create a closure store
based on the injected initStateCreateFunc
, and expose the corresponding subscribe
, setState
, getState
, (this API will be removed) APIs.destroy
With the help of react
's official useSyncExternalStoreWithSelector
, the store
can be bound to the view
layer, thus allowing the use of an external store
to control the display of the page.
zustand
also supports the ability of middleware
, which can be used in the form of create(middleware(...args))
.
Core Code Explanation#
This section explains the core create
and useSyncExternalStoreWithSelector
functions.
create Function Generates Store#
For ease of reading, the code has been shortened.
Pre-knowledge Introduction#
The store
generated by the create
function is a closure, which allows access to the store
through exposed api
.
The core code is in the vanilla.ts
and react.ts
files. vanilla.ts
implements a complete store
with pub-sub
capabilities, which can be used without relying on react
.
In react.ts
, a useStore
hook is implemented based on useSyncExternalStoreWithSelector
. When calling the function returned by create
in a component, it binds the store
to the component, and this binding is implemented by useStore
. This useSyncExternalStoreWithSelector
will be discussed in the next section.
create Execution Process#
When the create
function is called, it first uses createStore
exported from vanilla.ts
to generate the store
, then defines a useBoundStore
function, which returns useStore(api, selector, equalityFn)
, and injects the api
returned by createStore
into useBoundStore
, then returns useBoundStore
. The usage of useBoundStore
is exactly the same as useSelector
.
Simplified Source Code with Comments#
Looking at the code, you will find that both
createStore
andcreate
functions are of the form(createState) => createState ? createStoreImpl(createState) : createStoreImpl
. Referring to the official documentation's ts guide, you will find that the official calling method in ts projects is:create()(...args)
.
There is an explanation below the documentation code example that this is a workaround implemented to handle TypeScript/issues/10571, which also answers the inconsistency in usage between ts and js mentioned above.
// Generate store closure and return api
// createState is a function passed by the user when creating the store
const createStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>;
type Listener = (state: TState, prevState: TState) => void;
// Here, state is the store, which is a closure, accessed through the exposed api
let state: TState;
const listeners: Set<Listener> = new Set();
// The partial parameter of setState supports both objects and functions, replace indicates whether to fully replace the store or merge
// Updates are shallow comparisons
const setState = (partial, replace) => {
const nextState = typeof partial === "function" ? partial(state) : partial;
// Only update when equal, then trigger 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);
// Unsubscribe
return () => listeners.delete(listener);
};
// destroy will be removed later, no need to look at
const destroy: StoreApi<TState>["destroy"] = () => {
if (import.meta.env?.MODE !== "production") {
console.warn(
"[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected."
);
}
listeners.clear();
};
const api = { setState, getState, subscribe, destroy };
// Here are the set, get, api from the official example
state = createState(setState, getState, api);
return api as any;
};
// When calling createStore, the createState function is theoretically always present
// However, for ts type definitions, createStore<T>()(()=>{}) may lead to manual calls with null values
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] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`."
);
}
// Directly injecting a custom store will not inject api, it needs to be implemented in the injected store
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 Analysis#
The core code of zustand
is so concise, one major reason is the use of useSyncExternalStoreWithSelector
, which is a package released by react
called use-sync-external-store/shim/with-selector
. The reason for this package is that after react
proposed the useSyncExternalStore hook, it was re-implemented in react v18
, which had breaking changes. This package was created for compatibility.
Without further ado, here is the source code.
This implementation is actually a wrapper around the official useSyncExternalStore
, which does not support passing a selector
. After wrapping, it supports selector
and isEqual
.
useSyncExternalStore
requires passing in two functions: subscribe
and getSnapshot
, and the return value is the result of getSnapshot
. react
will inject a callback
function into subscribe
, and when the external store
changes, it must manually call callback
to notify react
that the external store
has changed, requiring it to re-call getSnapshot
to get the latest state. If the state changes, it triggers a re-render
, otherwise it does not re-render
.
The optimization of useSyncExternalStoreWithSelector
mainly allows extracting the parts used by the component from a large store
, while using isEqual
to reduce the number of re-renders
.
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 {
// Use this to track the rendered snapshot.
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;
}
/**
* When using zustand, the form is useStore(selector), and each re-render will get a new selector
* Therefore, getSelection is new after re-render, but due to instRef.current and isEqual
* When isEqual returns, the cached value of instRef.current is returned, which means the return value of getSelection remains unchanged
* This reduces the number of re-renders.
*/
const [getSelection, getServerSelection] = useMemo(() => {
// Track the memoized state using closure variables that are local to this
// memoized instance of a getSnapshot function. Intentionally not using a
// useRef hook, because that state would be shared across all concurrent
// copies of the hook/component.
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection: Selection;
const memoizedSelector = (nextSnapshot: Snapshot) => {
if (!hasMemo) {
// The first time the hook is called, there is no memoized result.
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined) {
// Even if the selector has changed, the currently rendered selection
// may be equal to the new selection. We should attempt to reuse the
// current value if possible, to preserve downstream memoizations.
if (inst.hasValue) {
const currentSelection = inst.value;
if (isEqual(currentSelection, nextSelection)) {
memoizedSelection = currentSelection;
return currentSelection;
}
}
}
memoizedSelection = nextSelection;
return nextSelection;
}
// We may be able to reuse the previous invocation's result.
const prevSnapshot: Snapshot = (memoizedSnapshot: any);
const prevSelection: Selection = (memoizedSelection: any);
if (is(prevSnapshot, nextSnapshot)) {
// The snapshot is the same as last time. Reuse the previous selection.
return prevSelection;
}
// The snapshot has changed, so we need to compute a new selection.
const nextSelection = selector(nextSnapshot);
// If a custom isEqual function is provided, use that to check if the data
// has changed. If it hasn't, return the previous selection. That signals
// to React that the selections are conceptually equal, and we can bail
// out of rendering.
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
// Assigning this to a constant so that Flow knows it can't change.
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;
}