chenyuan

chenyuan

twitter

Zustand Source Code Interpretation - Part 1

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.
zustand

First, use the create function to create a closure store based on the injected initStateCreateFunc, and expose the corresponding subscribe, setState, getState, destroy (this API will be removed) APIs.

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 and create 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;
}
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.