Understanding How Zustand Works (with Closure)

profile image

By analyzing Zustand’s source code, we uncover the core principles of state management powered by closures—and the secrets behind a powerful state library implemented in roughly 100 lines.

This post has been translated by Jetbrains's Coding Agent Junie junie logoPlease let me know if there are any mistranslations!

While studying closures and scope, I realized Zustand is a great real-world example of how closures are leveraged. So I decided to dive into the source code myself.

Core Structure - vanilla.ts

The essence of Zustand is the ~100 lines of code in vanilla.ts. The rest is mostly “wrapping” for React and middleware integration.

For readability, let’s look at the code with types removed.

javascript
// vanilla.ts
const createStoreImpl = (createState) => {
  // --- start of closure ---
  let state;
  const listeners = new Set();

  const getState = () => state;

  const setState = (partial, replace) => {
    // partial can be a function or an object
    const nextState = typeof partial === 'function' ? partial(state) : partial;

    // update and notify only if previous and next states differ
    if (!Object.is(nextState, state)) {
      const previousState = state;
      // replace with the new state (merge objects, overwrite otherwise)
      state =
        replace ?? (typeof nextState !== 'object' || nextState === null)
          ? nextState
          : Object.assign({}, state, nextState);

      // notify all subscribers (listeners)
      listeners.forEach((listener) => listener(state, previousState));
    }
  };

  const subscribe = (listener) => {
    listeners.add(listener);
    // return an unsubscribe function
    return () => listeners.delete(listener);
  };

  const destroy = () => listeners.clear();

  // API object exposed to the outside
  const api = { setState, getState, subscribe, destroy };

  // initialize! run createState to set the initial state
  state = createState(setState, getState, api);

  return api;
  // --- end of closure ---
};

export const createStore = (createState) =>
  createState ? createStoreImpl(createState) : createStoreImpl;

When createStoreImpl runs, the variables state and listeners declared inside it do not disappear like ordinary local variables—thanks to the closure, they remain alive in memory.

The functions included in the returned api object—getState, setState, and subscribe—remember the environment (scope) at the time they were created. That scope includes state and listeners.

  • Encapsulation: The state variable is “trapped” inside the scope of createStoreImpl. From the outside, it can only be accessed through API methods like getState or setState.
  • State Persistence: Although createStoreImpl is invoked only once, the resulting state and listeners persist as long as the application runs, because the API methods keep referencing them.

React Integration - react.ts

Now let’s see how the vanilla store is integrated with React. Again, types are removed.

javascript
import React from 'react';
import { createStore } from './vanilla.js';

// a bridge hook that connects the vanilla store to React
export function useStore(api, selector) {
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState())
  );
  return slice;
}

const createImpl = (createState) => {
  // 1. create the vanilla store API (state and listeners are created here via closure)
  const api = createStore(createState);

  // 2. create a dedicated hook bound to this store
  const useBoundStore = (selector) => useStore(api, selector);

  // 3. attach api methods (setState, etc.) onto the hook itself
  Object.assign(useBoundStore, api);

  return useBoundStore;
};

export const create = (createState) =>
  createState ? createImpl(createState) : createImpl;
  1. Create the vanilla store.
  2. Build a hook that returns useSyncExternalStore via useBoundStore.
  3. Bind API methods (setState, getState, etc.) to the returned hook.

The role of useSyncExternalStore

useSyncExternalStore is a hook introduced in React 18 that synchronizes external stores with React. It tells React to subscribe to an external data source (the Zustand store here) and re-render when changes occur.

javascript
// inside useSyncExternalStore (simplified)
useEffect(() => {
  if (checkIfSnapshotChanged(inst)) {
    forceUpdate({inst});
  }
  const handleStoreChange = () => {
    if (checkIfSnapshotChanged(inst)) {
      // force re-render
      forceUpdate({inst});
    }
  };
  // subscribe to the store and return a cleanup function
  return subscribe(handleStoreChange);
}, [subscribe]);

In useSyncExternalStore, a function called handleStoreChange—which compares values and forces a component update—is registered as a listener. Through this mechanism, when setState invokes listeners, handleStoreChange runs and triggers a re-render of the component.

Inspecting in DevTools

javascript
const useBearStore = create((set, get) => ({
  bears: 0,
  increase: () => set(state => ({ bears: state.bears + 1 })),
}));

function Component() {
  const bears = useBearStore(state => state.bears);

  return <h1>{bears} bears</h1>;
}

If you actually run a component like the above and check the Sources → Scope tab in DevTools, you can see everything captured by closures like this:

scope-dev-tools.webp

Step-by-step flow

javascript
// 1. Create the store hook
const useBearStore = create((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
}));

// 2. Use the store in a component
function BearCounter() {
  const bears = useBearStore((state) => state.bears);
  return <h1>{bears} bears</h1>;
}

// 3. Update state
function Controls() {
  const increase = useBearStore((state) => state.increase);
  return <button onClick={increase}>one up</button>;
}

Step 1: Create store (create)

  1. Calling create runs createStoreImpl.
  2. At this time, a unique state and listeners for useBearStore are created in the closure scope.
  3. state is initialized as { bears: 0, increase: [Function] }. The increase function remembers setState through the closure.
  4. A custom hook named useBearStore is returned. This hook also carries methods like setState and getState.

Step 2: Component render and subscription (useBearStore)

  1. When the BearCounter component first renders, the useBearStore hook runs.
  2. useSyncExternalStore is called, and a re-rendering function is registered in the listeners created in Step 1 via subscribe.
  3. The getSnapshot callback () => state.bears runs, obtains the initial value 0, assigns it to bears, and renders "0 bears" on the screen.

Step 3: State update (increase)

  1. When the user clicks the button, the increase function obtained in the Controls component runs.
  2. increase calls setState, which it remembers via closure.
  3. setState updates state to { bears: 1 }.
  4. setState iterates over listeners and calls every registered function.
  5. The 're-render function' registered by useSyncExternalStore in BearCounter is called.
  6. React re-renders the BearCounter component, runs getSnapshot again, obtains the new value 1, and updates the screen.

Wrap-up

Zustand feels like a perfect example of the power and practicality of closures. In just ~100 lines of code (about 40 without types), it achieves the following:

  1. Encapsulation: Protects state from direct external access.
  2. Publish/Subscribe pattern: Propagates state changes efficiently via Set and useSyncExternalStore, and re-renders exactly the components that need it.
  3. Simplicity: Provides an intuitive API without complex reducers, action types, or dispatchers.

Through this analysis of Zustand, I learned that a concept like closure—sometimes hard to grasp—can be used to build powerful abstractions. Understanding these patterns can help you create better state management solutions or gain deeper insight into existing libraries.