- Daishi Kato's Read the Code
- Posts
- `use-zustand` Uses Zustand Without `useSyncExternalStore`
`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