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.

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

or to participate.