Zustand is a state-management solution for React that is lightweight, performant and minimalistic. It provides an intuitive type-safe API to read and update state without the complexities of a library like React-Redux.
In Zustand, declaring and consuming a store is as simple as:
type CounterStore = {
count: number;
increment: () => void;
};
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function CountSpan() {
const count = useCounterStore((x) => x.count);
return <span style={{ fontSize: "1.75em" }}>Count: {count}</span>;
}
function CountIncrement() {
const increment = useCounterStore((x) => x.increment);
return <button type="button" onClick={increment}>+ Increment</button>;
}
Why Zustand over React’s useState and Context APIs
Due to how the useState hook works in React, whenever you mutate a state passed from a parent component, that parent recursively re-renders (or diffs) its children, regardless of whether that component consumes the state or not. So when you have a global context as shown below, the component tree starting from the CounterProvider component (as it contains the useState to count) recursively renders till the last node.
While frameworks like SolidJS and Svelte 5 solve this by utilizing fine-grained reactivity (signals, property access proxies) and compile-time optimizations, React sticks to its all-runtime approach to manage state and render. However, there is an effort to provide compile-time optimizations from the React team, but that is still in an alpha state. [1]
Hence, libraries like Zustand and React-Redux exist. These libraries leverage different mechanisms to reduce the renders in the component tree that are not reliant on a store, and provide functionality to optimize rerenders by selectively subscribing to specific parts of the state.
type CounterContextType = {
count: number;
increment: () => void;
};
const CounterContext = createContext<CounterContextType | undefined>(undefined);
function useCounter() {}
function CounterProvider({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0);
const increment = () => setCount((prev) => prev + 1);
return (
<CounterContext.Provider value={{ count, increment}}>
{children}
</CounterContext.Provider>
);
}
export default function ContextRoute() {
return (
<CounterProvider>
<CountSpan />
<CountIncrement />
</CounterProvider>
);
}
function CountSpan() {
const { count } = useCounter();
return <span style={{ fontSize: "1.75em" }}>Count: {count}</span>;
}
function CountIncrement() {
const { increment } = useCounter();
return <button type="button" onClick={increment}>+ Increment</button>;
}
How Does Zustand Work?
Zustand relies on React’s built-in useSyncExternalStore hook to synchronize state between the source and listeners (components or callers that subscribe to changes in a store). For an example, look at the reference implementation below. It creates a set to list all the listeners, create helper functions to get/set data, and return a function that calls the useSyncExternalStore hook with a desired selector.
The selector ‘selects’ the data from the returned store, checks if the last value differs from the current value, and triggers the re-rendering process. This eliminates the redundant rerenders of both the component tree (as the state lives outside of it), and the component that is subscribed to a specific part of the data. E.g. CountSpan selects the state.count, which selectively subscribes to the changes that only occur to the state.count variable within the store.
You can find an interactive example here.
type Get<T> = () => T;
type SetPrev<T> = (prev: T) => Partial<T>;
type Set<T> = (partial: T | SetPrev<T>) => void;
type StateParam<T> = (set: Set<T>, get: Get<T>) => T;
type Selector<T, V> = (state: T) => V;
type Listener<T> = (state: T) => void;
function create<T>(stateParam: StateParam<T>) {
let state: T;
const listeners = new Set<Listener<T>>();
// Adds a listener that calls a function with the new data
// and returns a function to unsubscribe
const subscribe = (listener: Listener<T>) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const get: Get<T> = () => state;
// Sets the new state, calls every listener subscribed notifying the change
const set: Set<T> = (partial) => {
if (Object.is(partial, state)) return;
const next = typeof partial === "function" ? (partial as SetPrev<T>).call(undefined, get()) : partial;
state = Object.assign({}, state, next);
listeners.forEach((listener) => listener(state));
};
const initialState = (state = stateParam(set, get));
// Uses react's useSyncExternalStore
// Read: https://react.dev/reference/react/useSyncExternalStore
return <U extends unknown>(selector: Selector<T, U>) =>
useSyncExternalStore(
subscribe,
() => selector(state), // <- called every time the function returned by subscribe is called
() => selector(initialState), // <- Server state
);
}
type CounterStore = {
count: number;
increment: () => void;
};
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
When Should You Use Zustand?
While Zustand is simple and flexible, it should not be used as a replacement for useState everywhere. Zustand excels in use cases like global state, where the state is accessed and mutated deep within the component tree. Below are a few use cases where Zustand would come in handy:
- Theme / color scheme toggle
- Shared player state (e.g. https://www.echodaft.com/)
- Authenticated session state (note: NEVER solely rely on a state like this for authentication. Always check for a valid session every fetch request to a backend or a protected page)
- Cart implementation in an ecommerce site.
These use cases typically live as part of the global state in an application without much of the site relying on changes to and from these states.
Using Zustand (CSR, and in SSR contexts)
The usage of Zustand depends on where you are using it. For fully client side applications (e.g. SPAs) which do not use any sort of server side rendering, it is as easy as creating a store and using the returned hook within a component.
type CounterStore = {
count: number;
increment: () => void;
};
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function CountSpan() {
const count = useCounterStore((x) => x.count);
return <span style={{ fontSize: "1.75em" }}>Count: {count}</span>;
}
function CountIncrement() {
const increment = useCounterStore((x) => x.increment);
return <button type="button" onClick={increment}>+ Increment</button>;
}
SSR contexts like NextJS or Remix
However, for server rendered apps, as a Zustand store lives as part of the global state, it is recommended to create a store every request to the server. In a server context, global state could be shared within multiple requests to unrelated consumers, which could potentially result in private data being leaked. [2] [3]
An implementation for this use-case could be found here in the official guide.
Sources
- React Compiler: https://react.dev/learn/react-compiler
- Using Zustand in React Server Components - misguided misinformation and misuse?: https://github.com/pmndrs/zustand/discussions/2200?sort=top
- Reading writing state outside of components - zustand: https://github.com/pmndrs/zustand?tab=readme-ov-file#readingwriting-state-and-reacting-to-changes-outside-of-components
For further reading
- Virtual Dom - Million: https://old.million.dev/blog/virtual-dom
- React as a Runtime - Dan Abramov: https://overreacted.io/react-as-a-ui-runtime/
- Interactive example of zustand, react useState/context, and a reference implementation of zustand: https://zustand-experiments.mozilla-iit.org/