`use-zustand` Uses Zustand Without `useSyncExternalStore`

The Special Behavior of `useReducer` That Cannot Be Done With `useState`

Hi,

In my previous post, I discussed using useEffect for subscription, while useSyncExternalStore is the recommended approach. Jotai uses useEffect, whereas Zustand uses useSyncExternalStore. But can we use useEffect for Zustand? That is exactly what the library use-zustand does.

Let's look at the code.

📖 Reference: use-zustand v0.2.0

import { useEffect, useReducer } from 'react';

export function useZustand(store, selector, areEqual = Object.is) {
  const state = store.getState();
  const [[sliceFromReducer, storeFromReducer], rerender] = useReducer(
    (prev, fromSelf) => {
      if (fromSelf) {
        return fromSelf;
      }
      const nextState = store.getState();
      if (Object.is(prev[2], nextState) && prev[1] === store) {
        return prev;
      }
      const nextSlice = selector(nextState);
      if (areEqual(prev[0], nextSlice) && prev[1] === store) {
        return prev;
      }
      return [nextSlice, store, nextState];
    },
    undefined,
    () => [selector(state), store, state],
  );
  useEffect(() => {
    const unsubscribe = store.subscribe(() => rerender());
    rerender();
    return unsubscribe;
  }, [store]);
  if (storeFromReducer !== store) {
    const slice = selector(state);
    rerender([slice, store, state]);
    return slice;
  }
  return sliceFromReducer;
}

You might not have seen code like this unless you have experience developing libraries. Even I am not 100% sure if there are still bugs in edge cases where this might fail.

There are two points I would like to discuss in this post. The first is about the reducer function. The second is about calling rerender in the hook body.

Understanding the Reducer Function

The reducer function is a little complicated. Here is a simplified version:

    (prev) => {
      const nextState = store.getState();
      if (Object.is(prev[2], nextState) && prev[1] === store) {
        return prev;
      }
      const nextSlice = selector(nextState);
      return [nextSlice, store, nextState];
    }

Some capabilities have been removed, but the key point is that the reducer function accesses store and selector, which are closure variables. Concurrent React can have multiple values for a single variable, but thanks to the closure, this works well with Concurrent React. If you directly access store in the hook body, it may not work correctly because store is a mutable state.

Why rerender Is Called in the Hook Body

This approach almost works well with useReducer, but since we have a useEffect that re-subscribes if store changes, we need a small optimization. The relevant code is:

  if (storeFromReducer !== store) {
    const slice = selector(state);
    rerender([slice, store, state]);
    return slice;
  }

As I just mentioned, this is an optimization. Even without it, the code works, but it would cause an extra render and commit.

What this code does is ensure that when store changes, we intentionally trigger a re-render before firing the effect. This guarantees that the hook always provides the latest value, prevents zombie child issues, and results in only one commit. Calling rerender in the hook body is allowed, just like calling setState with useState. However, this is something we typically only need in library code.

Final Thoughts

That is pretty much everything I wanted to share today. I am starting to regret picking this topic because it is difficult to explain. One reason is that I am not 100% confident in my explanation, especially since React has evolved in recent versions, and things may have changed. Nevertheless, I know this code works well, so at least I am confident in that.

I hope this helps you understand something, even if it is just the complexity of the problem. Hopefully, I can pick a less complicated topic next time.

Happy coding.

Reply

or to participate.