Concept
The beginner framing: Zustand is a small, external store, state lives outside React entirely, in a plain JavaScript object, and components opt into exactly the pieces of it they care about via a selector function.
import { create } from "zustand";
const useStore = create((set) => ({
count: 0,
user: { name: "Ada" },
incrementCount: () => set((s) => ({ count: s.count + 1 })),
renameUser: (name) => set({ user: { name } }),
}));
function Counter() {
const count = useStore((s) => s.count); // subscribes to ONLY this slice
return <button onClick={() => useStore.getState().incrementCount()}>{count}</button>;
}No <Provider> wraps the tree, create() returns a hook backed by a module-level store, usable directly from any component that imports it. This is a structural difference from Context, which requires a provider component wrapping everything that needs access.
const useStore = create((set) => ({count: 0,user: { name: 'Ada' },incrementCount: () => set((s) => ({ count: s.count + 1 })),renameUser: (name) => set({ user: { name } }),}));
Two components, each subscribed to a DIFFERENT slice of the same store via an explicit selector function. Neither has rendered yet in this walkthrough.
The core mechanic: selectors, and what they actually buy you
function CountDisplay() {
const count = useStore((s) => s.count);
return <span>{count}</span>;
}
function UserDisplay() {
const user = useStore((s) => s.user);
return <span>{user.name}</span>;
}Confirmed by running this exact store and watching each selector's subscription independently: calling incrementCount() notifies only CountDisplay's subscription, UserDisplay's subscription to s.user is never triggered at all, since the user slice genuinely didn't change. This is the entire value proposition of an external, selector-based store: each component's re-render boundary is exactly as narrow as its selector.
Contrast with Context: the exact opposite re-render behavior
Context (covered in Context API) re-renders every consumer whenever the provided value changes at all, even a consumer that only reads one field of a large context value re-renders on changes to fields it never touches, unless the context is manually split into several smaller providers. Zustand's selector model gives you that same granularity by default, with a single store, no manual splitting required.
The object-selector gotcha
// ❌ selects a NEW object literal every single call:
const { count, user } = useStore((s) => ({ count: s.count, user: s.user }));Confirmed: (s) => ({ count: s.count, user: s.user }) constructs a brand-new object on every invocation, and Zustand's default equality check is reference equality (===), a new object reference every time means this selector's consumer re-renders on every store update, defeating the entire point of selecting.
import { useShallow } from "zustand/react/shallow";
const { count, user } = useStore(useShallow((s) => ({ count: s.count, user: s.user })));Confirmed present in the installed package: useShallow performs a shallow comparison of the selected object's own properties instead of reference equality, this is the fix, specifically for selecting multiple fields at once without losing selective re-rendering.
Try It
Predict the outcome before checking the solution.
const useStore = create((set) => ({
a: 1,
b: 2,
bumpA: () => set((s) => ({ a: s.a + 1 })),
}));
function ComponentB() {
const b = useStore((s) => s.b);
console.log("ComponentB rendered");
return <span>{b}</span>;
}
useStore.getState().bumpA();Does ComponentB log a render after bumpA() is called?
Solution
No. ComponentB's selector, (s) => s.b, only subscribes to the b slice. bumpA() only changes a. Confirmed by the underlying subscription mechanism: a component's selector is the precise boundary of what it reacts to, changing a completely unrelated field in the same store produces zero re-renders for a component that never selected that field.
Implement It Yourself
Build a minimal selector-based external store, to see the actual mechanism Zustand wraps in a nicer API:
function createMiniStore(initializer) {
let state;
const listeners = new Set();
const setState = (partial) => {
state = { ...state, ...(typeof partial === "function" ? partial(state) : partial) };
listeners.forEach((listener) => listener(state));
};
const getState = () => state;
state = initializer(setState, getState);
return {
getState,
This is the essential shape of what Zustand does internally, a selector function paired with a reference-equality check on each notification, deciding per-subscriber whether a given update is actually relevant.
Under the Hood
The selective re-rendering confirmed here is a direct contrast to the baseline behavior established in Context API, every consumer, every change, and to Rendering Lifecycle's general model of what triggers a component render in the first place. Zustand doesn't change React's rendering rules; it changes what triggers a setState call for a given component by subscribing outside React's own state system entirely.
Common Mistakes
1. Selecting a fresh object/array literal without useShallow
const filtered = useStore((s) => s.items.filter((i) => i.active)); // ❌ NEW array every callConfirmed: any selector that constructs a new object/array on each invocation defeats reference-equality-based re-render skipping, this includes derived/computed values, not just multi-field object selections.
2. Assuming Zustand needs a Provider like Context does
<StoreProvider> {/* ❌ unnecessary, Zustand's default store has no Provider requirement */}
<App />
</StoreProvider>The default create() pattern is a module-level singleton store, usable directly from any importing component, no wrapping provider needed (multi-instance/scoped stores are possible but are an opt-in pattern, not the default).
3. Calling useStore() with no selector at all
const state = useStore(); // ❌ subscribes to the ENTIRE store, re-renders on ANY changeOmitting a selector subscribes to the whole store object, re-creating exactly the "re-render on every change" behavior a selector exists to avoid.
Best Practices
- Always pass a selector, even for a single field,
(s) => s.countis what actually enables selective re-rendering; calling the hook bare defeats the entire model. - Use
useShallowfor any multi-field or derived-object selection, the reference-equality default is correct for primitives and direct store values, wrong for freshly-constructed objects/arrays. - Keep actions (like
incrementCount) inside the store definition rather than scatteringsetStatecalls across components, keeps the store's API surface self-contained and easy to reason about.
Performance Tips
- Selector-based subscriptions mean a large store with many independent pieces of state doesn't force unrelated components to re-render on every update, this scales meaningfully better than a single large Context value as the amount of independent state grows.
useShallow's comparison cost is proportional to the selected object's own key count, negligible for typical UI state, worth being mindful of only for very large selected objects.
