Zustand: The Design Philosophy of Minimal State Management
The Core Insight: Store as Closure, Not Context
Zustand begins with a question that sounds almost too simple: why does state need to live inside React?
Redux keeps state in a Store object and injects it into the React tree via react-redux's Provider. MobX needs some mechanism to inject observable state into components. Early Context-based solutions embedded state directly into React's rendering machinery. All of these approaches share a hidden assumption: state belongs to React.
Zustand rejects this assumption. Its store is an ordinary JavaScript closure โ a module-level object that exists entirely independently of React. React components subscribe to this external store via useSyncExternalStore (React 18+). When state changes, only the components that subscribed to the specific slice of state that actually changed are re-rendered. Not all consumers. Not the component tree. Just the ones that care.
import { create } from 'zustand';
// The store is the return value of a plain function call
// No React APIs involved in its creation
const useBearStore = create<{
bears: number;
increase: () => void;
reset: () => void;
}>()(set => ({
bears: 0,
increase: () => set(state => ({ bears: state.bears + 1 })),
reset: () => set({ bears: 0 }),
}));
// Usage looks just like a regular Hook
function BearCounter() {
// This component re-renders only when `bears` changes
const bears = useBearStore(state => state.bears);
return <h1>{bears} bears around here...</h1>;
}
function Controls() {
// `increase` is a stable function reference โ this component
// never re-renders when the bear count changes
const increase = useBearStore(state => state.increase);
return <button onClick={increase}>Add a bear</button>;
}
The selector pattern is the key. Controls only subscribes to the increase function. Since function references in Zustand are stable (they don't change between state updates unless you explicitly recreate them), Controls never re-renders when bears changes. This is precisely what Context cannot do.
The create() API and TypeScript Integration
Zustand's TypeScript integration is carefully designed, but there's one aspect that trips up newcomers: create() uses curried invocation โ two sets of parentheses:
// Correct TypeScript pattern (note the double parentheses)
const useStore = create<StoreState>()(/* initializer */);
// Why currying? TypeScript doesn't support partial type argument application.
// Without currying, you'd have to choose between:
// 1. Specifying ALL type params (including internal ones Zustand manages)
// 2. Specifying NONE and losing type safety
// The curried form lets TS accept the explicit type param first,
// then infer the remaining params from the runtime argument.
interface UserStore {
user: User | null;
isLoggedIn: boolean;
token: string | null;
setUser: (user: User, token: string) => void;
logout: () => void;
}
const useUserStore = create<UserStore>()(set => ({
user: null,
isLoggedIn: false,
token: null,
setUser: (user, token) => set({ user, token, isLoggedIn: true }),
logout: () => set({ user: null, token: null, isLoggedIn: false }),
}));
// TypeScript infers the return type of selectors automatically
const user = useUserStore(state => state.user); // User | null
const isAdmin = useUserStore(state => state.user?.role === 'admin'); // boolean | undefined
The set function accepts either a partial state object or an updater function. The updater form is important for state that depends on the current state (like incrementing counters or pushing to arrays), because it avoids stale closure issues.
Selectors: Derived State and Performance Control
Selectors are Zustand's primary performance mechanism. Every time you call useStore(selector), Zustand compares the selector's return value to the previous return value using Object.is. A re-render only triggers when the comparison returns false.
// Fine-grained subscriptions
const firstName = useUserStore(state => state.user?.firstName);
const isAdmin = useUserStore(state => state.user?.role === 'admin');
// Even if the user object changes, firstName only triggers re-render if firstName changed
// Computed/derived state via selectors
const useCartStore = create<{
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}>()(set => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
}));
function CartSummary() {
// These computations only run when `items` changes
const total = useCartStore(state =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const itemCount = useCartStore(state => state.items.length);
return (
<div>
<span>{itemCount} items</span>
<span>Total: ${total.toFixed(2)}</span>
</div>
);
}
When a selector returns an object or array, Object.is will always return false (new reference each render), causing infinite re-renders. The solution is useShallow:
import { useShallow } from 'zustand/react/shallow';
// WRONG: returns a new object every render, Object.is always false
const { bears, increase } = useBearStore(state => ({
bears: state.bears,
increase: state.increase,
})); // Causes infinite re-renders!
// CORRECT: shallow comparison checks each property individually
const { bears, increase } = useBearStore(
useShallow(state => ({ bears: state.bears, increase: state.increase }))
);
// Re-renders only when bears or increase actually changes
For performance-critical scenarios, you can also memoize selectors outside the component using a library like reselect, or simply define them as module-level constants:
// Module-level selector โ stable reference, zero overhead
const selectCartTotal = (state: CartStore) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
function CartTotal() {
const total = useCartStore(selectCartTotal);
return <span>${total.toFixed(2)}</span>;
}
The Middleware System
Zustand's middleware is where its design philosophy shines. Middleware is function composition, not configuration objects โ you can stack them in any order, and each one has a single, clear responsibility.
The immer Middleware
Updating nested state immutably is notoriously tedious:
// Without immer: manually spread every nested level
set(state => ({
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: 'San Francisco',
},
},
},
}));
// With immer: mutate the draft directly, Proxy tracks changes
import { immer } from 'zustand/middleware/immer';
const useUserStore = create<UserStore>()(
immer((set) => ({
user: null as User | null,
updateCity: (city: string) => set(state => {
if (state.user?.profile?.address) {
state.user.profile.address.city = city; // Direct mutation, safe
}
}),
}))
);
The immer middleware wraps your state initializer so that every call to set passes a Proxy-wrapped draft of the current state. Immer tracks every mutation on the draft and produces a new immutable state object at the end โ without you ever needing to spread.
The devtools Middleware
import { devtools } from 'zustand/middleware';
const useTaskStore = create<TaskStore>()(
devtools(
(set) => ({
tasks: [],
addTask: (task) => set(
state => ({ tasks: [...state.tasks, task] }),
false, // don't replace โ merge
'tasks/addTask' // action name in Redux DevTools
),
}),
{
name: 'TaskStore', // Store name in DevTools panel
enabled: process.env.NODE_ENV === 'development',
}
)
);
The devtools middleware integrates with Redux DevTools Extension, giving you a full action history, state diff viewer, and time-travel debugging โ for free, with no Redux required.
The persist Middleware
import { persist, createJSONStorage } from 'zustand/middleware';
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light' as 'light' | 'dark',
language: 'en-US',
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'user-settings',
storage: createJSONStorage(() => localStorage), // default
// Only persist specific fields
partialize: (state) => ({
theme: state.theme,
language: state.language,
}),
// Handle schema migrations when your store structure changes
version: 2,
migrate: (persistedState: unknown, version: number) => {
if (version === 1) {
// v1 used `darkMode: boolean`, v2 uses `theme: 'light' | 'dark'`
const old = persistedState as { darkMode: boolean };
return {
theme: old.darkMode ? 'dark' : 'light',
language: 'en-US',
};
}
return persistedState as Partial<SettingsStore>;
},
}
)
);
Composing Multiple Middleware
const useAppStore = create<AppStore>()(
devtools(
persist(
immer((set, get) => ({
// Your store definition with immer mutations,
// persisted to localStorage,
// visible in Redux DevTools
})),
{ name: 'app-store' }
),
{ name: 'AppStore' }
)
);
Middleware composes from inside out: immer wraps your initializer first, then persist wraps that, then devtools wraps everything. The TypeScript types flow through correctly at each layer.
Zustand Internals: How useSyncExternalStore Powers It
To understand why Zustand performs so well, you need to look at its internals. At its core, Zustand creates an object that satisfies the "external store" interface โ three functions: getState, setState, and subscribe.
// Conceptual implementation of Zustand's createStore
// (simplified for clarity, actual source differs in details)
function createStore<T>(initializer: (set: SetState<T>, get: GetState<T>) => T) {
let state: T;
const listeners = new Set<() => void>();
const setState: SetState<T> = (partial) => {
const next = typeof partial === 'function'
? Object.assign({}, state, partial(state))
: Object.assign({}, state, partial);
if (!Object.is(state, next)) {
state = next;
// Notify all listeners that state changed
// Each listener will compare its selector output โ only re-renders if needed
listeners.forEach(l => l());
}
};
const getState: GetState<T> = () => state;
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener); // Unsubscribe
};
state = initializer(setState, getState);
return { setState, getState, subscribe };
}
// The React Hook layer
function useStore<T, S>(
store: ReturnType<typeof createStore<T>>,
selector: (state: T) => S
): S {
// useSyncExternalStore is the React 18 API for external stores
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState()), // server-side snapshot
);
}
useSyncExternalStore was introduced in React 18 specifically to solve a problem that plagued external state libraries in Concurrent Mode: tearing. In Concurrent Mode, React can pause and resume rendering, potentially reading external state at different points in time. Without useSyncExternalStore, different components subscribing to the same external store could read different versions of the state during a single render pass โ visual inconsistency.
useSyncExternalStore prevents tearing by forcing synchronous reads and ensuring that once a render begins, every component in that render sees the same snapshot of external state.
The Slices Pattern for Large Applications
As applications grow, putting all state in one create() call becomes unwieldy. The slices pattern splits state by domain while maintaining a single store โ preserving the ability for cross-domain state access.
// authSlice.ts
import type { SetState, GetState } from 'zustand';
export interface AuthSlice {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
export const createAuthSlice = (
set: SetState<RootStore>,
get: GetState<RootStore>
): AuthSlice => ({
user: null,
login: async (credentials) => {
const { user } = await authAPI.login(credentials);
set({ user });
},
logout: () => {
set({ user: null });
// Cross-slice access: call cartSlice's method
get().clearCart();
},
});
// cartSlice.ts
export interface CartSlice {
cart: CartItem[];
addToCart: (item: CartItem) => void;
clearCart: () => void;
}
export const createCartSlice = (
set: SetState<RootStore>
): CartSlice => ({
cart: [],
addToCart: (item) => set(state => ({ cart: [...state.cart, item] })),
clearCart: () => set({ cart: [] }),
});
// store.ts
type RootStore = AuthSlice & CartSlice;
const useRootStore = create<RootStore>()((...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
}));
// Export domain-specific hooks with shallow comparison
export const useAuth = () => useRootStore(
useShallow(state => ({
user: state.user,
login: state.login,
logout: state.logout,
}))
);
export const useCart = () => useRootStore(
useShallow(state => ({
cart: state.cart,
addToCart: state.addToCart,
}))
);
Performance Comparison: Zustand vs Context
Here's the numbers quantified with a concrete implementation:
// Test: 50 subscriber components, counter updates every 100ms
// Context approach โ all 50 re-render on every update
const CountContext = createContext(0);
function ContextSubscriber() {
const count = useContext(CountContext);
return <div>{count}</div>;
}
// Measured: ~12ms per update (50 renders ร ~0.24ms each)
// Zustand approach โ only changed-slice subscribers re-render
const useCounterStore = create<{
count: number;
noise: string;
increment: () => void;
}>()(set => ({
count: 0,
noise: 'irrelevant data',
increment: () => set(state => ({ count: state.count + 1 })),
}));
function ZustandSubscriber() {
// Only subscribes to count โ noise changes don't trigger re-renders
const count = useCounterStore(state => state.count);
return <div>{count}</div>;
}
// Measured: ~2ms per update (only the 50 components whose count selector changed)
// When `noise` updates: 0 re-renders for ZustandSubscriber components
The 6x performance difference is not the most important part. What matters is the predictability: with Context, you need to carefully manage which components consume which Context, split Contexts, and use memo defensively. With Zustand selectors, components automatically opt into exactly the state they need โ nothing more, nothing less.
Zustand's philosophy is that the best abstraction is not the most featureful one. It's the one with the fewest concepts, each precisely right. One create function, one hook, one selector โ and an optional middleware system for the times you need more. The next chapter examines Redux Toolkit, which takes the opposite approach: comprehensive by design, and worth it for the right team size and problem complexity.