72. Zustand: a state manager in 20 lines, and useSyncExternalStore
Zustand is one of the most-used React state libraries. This is its entire
core, the published esm/vanilla.mjs of v5.0.15, unabridged:
const createStoreImpl = (createState) => {
let state;
const listeners = /* @__PURE__ */ new Set();
const setState = (partial, replace) => {
const nextState = typeof partial === "function" ? partial(state) : partial;
if (!Object.is(nextState, state)) {
const previousState = state;
state = (replace != null ? replace : typeof nextState !== "object" || nextState === null) ? nextState : Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
const getState = () => state;
const getInitialState = () => initialState;
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const api = { setState, getState, getInitialState, subscribe };
const initialState = state = createState(setState, getState, api);
return api;
};
Everything in it is from Part I:
- A closure (§6) holds
stateandlisteners. There is no class and nothis, so you can destructure the API freely. - Immutable updates by default.
Object.assign({}, state, nextState)shallow-merges into a new object, soObject.is(prev, next)is a valid change check for React. /* @__PURE__ */tells bundlers that thisnew Set()has no side effects and can be dropped if unused (tree-shaking, §16).initialStateis declared afterapibut used in a closure above it. That's legal becausegetInitialStateruns later, after the TDZ has ended (§4).
The React binding (esm/react.mjs) is about as short:
function useStore(api, selector = identity) {
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;
}
useSyncExternalStore is React's official way to read from stores that
live outside React. React calls subscribe, and on each notification it
calls the snapshot function and re-renders only if the result changed by
Object.is. That is why selectors matter:
useStore(s => s.items.length) re-renders when the count changes, not
when any other part of the store does. It also prevents tearing: with
concurrent rendering, React could otherwise render half the tree with the
old state and half with the new.
Pitfall.
useStore(s => ({ a: s.a, b: s.b }))returns a new object on every call, soObject.isfails every time. That means an infinite re-render loop in v5, or a render on every store change in older versions. Select primitives, or wrap the selector with Zustand'suseShallow.
Middleware is function composition
persist, devtools and immer are functions that take a state creator
and return one, decorating set on the way. A working mini version:
const logger = (config) => (set, get, api) =>
config((...args) => { const before = get(); set(...args); console.log("[log]", before, "→", get()); }, get, api);
const persist = (config, { name, storage }) => (set, get, api) => {
const saved = storage.get(name);
const initial = config((...args) => { set(...args); storage.set(name, get()); }, get, api);
return saved ? { ...initial, ...saved } : initial;
};
const cart = createStore(logger(persist((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}), { name: "cart", storage })));
// What useStore does, without React: notify only when the selected slice changes
const select = (store, selector, cb) => {
let prev = selector(store.getState());
return store.subscribe((s) => { const next = selector(s); if (!Object.is(next, prev)) cb((prev = next)); });
};
select(cart, (s) => s.items.length, (n) => console.log("badge:", n));
cart.getState().add("keyboard"); // badge: 1 [log] {"items":[]} → {"items":["keyboard"]}
cart.getState().add("mouse"); // badge: 2 …
cart.setState((s) => s); // same object: Object.is bail-out, nobody notified
// a fresh store with the same persist() options starts with ["keyboard", "mouse"]