- Daishi Kato's Read the Code
- Posts
- The Unique Part of Zustand API Design
The Unique Part of Zustand API Design
What Is Unique and What Feels Ironic, in My Humble Opinion
Hi,
Zustand's API is pretty minimal for the vanilla implementation. The React implementation is also minimal, but that is not today's focus. By the way, since Zustand v5, the library entry point has been simplified to combine zustand/vanilla
and zustand/react
.
Reference: Zustand v5 Entry Point
// zustand/esm/index.mjs
export * from 'zustand/vanilla';
export * from 'zustand/react';
Back to today's topic, my understanding is that Zustand is just a subscribable global variable. It does not have to be truly "global," but in most use cases, it is used for global state management. So my interest is in which API design decisions make Zustand what it is.
Let's look at the code. Based on Zustand v5 Vanilla Implementation, here is a slightly simplified version:
export const createStore = (createState) => {
let state;
const listeners = 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;
};
The first interesting part, as I see it, is that createStore
passes api
to createState
. Not only api
, it also passes setState
and getState
, which are part of the api
. The rationale is that the major use cases only depend on setState
, often referred to as set
, while very few use cases depend on getState
. Depending on api
is rare and is mainly for advanced use cases such as middleware.
This subtle design choice in the createState
signature enables a simple usage pattern like this:
const store = createStore((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}));
Another important capability in the example above is that set
takes an update function that only returns part of the state. To enable this, the state object is shallow-merged. Here is the relevant code:
state = (replace != null ? replace : typeof nextState !== "object" || nextState === null)
? nextState
: Object.assign({}, state, nextState);
In principle, we want to avoid such conditions for simplicity. Ideally, we would prefer:
state = nextState;
This would make the library code simpler and even eliminate the replace
flag. However, it would require users to write state updates like this:
const store = createStore((set) => ({
count: 0,
inc: () => set((state) => ({ ...state, count: state.count + 1 })),
}));
We seriously considered this API design, especially since it aligns with the useState
API. However, since Zustand is used for global state management, and having functions in the state object is an established use case (enabled by the createState
signature as described), we believe that shallow merging is a necessary feature in the library. In short, we disliked the need for ...state
.
Looking at it from a different perspective, Zustand's API is unique in that it allows functions (often called actions) to be included in the state object alongside state values. This is enabled by the set
function passed to createState
. Ironically, the set
function itself requires shallow merging of the state.
I am not entirely sure if I have been able to explain the irony I see in this, but I feel this is the unique part of Zustand's design and what differentiates it from other subscribable global state implementations.
Happy coding.
Reply