Zustand for Client State
- react
- typescript
- zustand
- nextjs
- state-management
- webdev
- javascript
- frontend
In Choosing a State Management Approach, I laid out the decision tree I actually use: local state first, then Context for cross-cutting values that change rarely, then a dedicated store once you need selective subscriptions or update logic that a reducer can't cleanly express. If you went through that tree and landed on "I need a store," this article is the next step: setting one up with Zustand, the library I reach for most often on production SaaS apps.
This is part of the React & Next.js Complete Guide series, which started with React Fundamentals for Professionals and works toward production patterns for real applications. Zustand sits at a useful spot in that progression, since it solves the exact re-render problem Context can't solve on its own, with a fraction of the ceremony Redux Toolkit asks for.
I'm not going to argue Zustand is objectively better than every alternative. Redux Toolkit, Jotai, and even a well-organized set of Context providers all have legitimate use cases. What I want to cover here is why Zustand's design choices matter in practice, how to structure a store that stays maintainable as it grows, and the production pitfalls that show up specifically when you bring it into a Next.js app with server rendering.
Why Zustand over Redux Toolkit or plain Context
Zustand's pitch is narrow, and it delivers on it. A store is a hook. Components subscribe only to the slices of state they read, and there's no provider required to wrap your tree. Compare that to Context, where every consumer re-renders on any change to the context value, forcing you to split providers manually to isolate updates. Zustand handles that isolation automatically through selectors. That's the single biggest reason to reach for it once Context starts causing re-render problems.
Against Redux Toolkit, the difference is mostly boilerplate and mental overhead. Redux Toolkit still gives you slices, reducers, and a single store, and it's a genuinely good choice for large teams that want strict conventions and strong devtools support baked in from day one. Zustand gets you most of the same architectural benefits (a single source of truth, action-based updates, middleware for persistence and devtools) without requiring action types, dispatch, or a provider component. For a mid-sized SaaS product where the state doesn't need Redux's ecosystem of middleware and time-travel tooling, Zustand's smaller surface area is usually the better tradeoff.
The honest caveat: Zustand's flexibility means it enforces less structure than Redux Toolkit does. A team that doesn't establish conventions for how stores are organized can end up with a dozen ad hoc stores that don't follow any consistent pattern. The rest of this article is mostly about avoiding that outcome.
Building a typed store
A Zustand store is created with create, which takes a function receiving set and get and returns the initial state plus whatever actions operate on it. Typing it well up front avoids a lot of any creep later.
import { create } from "zustand";
type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
total: () => number;
};
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.productId === item.productId);
if (existing) {
return {
items: state.items.map((i) =>
i.productId === item.productId ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({ items: state.items.filter((i) => i.productId !== productId) })),
updateQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.productId === productId ? { ...i, quantity } : i)),
})),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
Notice total isn't stored state, it's derived from items on demand via get(). That's a deliberate choice: storing a computed value alongside its source data is how stores drift out of sync when one update path forgets to recalculate the derived field. Compute it when it's needed instead of caching it in state.
Selectors are the whole point
The reason to move off Context in the first place is selective subscription, and Zustand gives you that through the selector argument to the store hook. A component that reads useCartStore((state) => state.items) re-renders only when items changes, not when some other field in the store changes.
function CartItemCount() {
const itemCount = useCartStore((state) => state.items.length);
return <span>{itemCount} items</span>;
}
function AddToCartButton({ product }: { product: { id: string; name: string; price: number } }) {
const addItem = useCartStore((state) => state.addItem);
return (
<button onClick={() => addItem({ productId: product.id, name: product.name, price: product.price })}>
Add to cart
</button>
);
}
CartItemCount never re-renders when addItem changes (it never does, since it's a stable function reference) and AddToCartButton never re-renders when items changes. Each component subscribes to exactly what it uses. This is the pattern that pays off as a store grows: the discipline of always passing a selector rather than pulling the entire state object out of the hook.
The pitfall here is subtle. If a selector returns a new object or array on every call, like useCartStore((state) => ({ items: state.items, total: state.total() })), the component re-renders on every store update regardless of whether those specific values changed, because the returned object is a new reference each time. Zustand's useShallow helper fixes this by doing a shallow equality check on the selected value instead of a reference check, and it's worth reaching for any time a selector returns more than a single primitive or stable reference.
import { useShallow } from "zustand/react/shallow";
function CartSummary() {
const { itemCount, total } = useCartStore(
useShallow((state) => ({ itemCount: state.items.length, total: state.total() }))
);
return (
<div>
{itemCount} items, ${total.toFixed(2)}
</div>
);
}
Actions belong in the store, not the component
A pattern that keeps stores maintainable as they grow: business logic for updating state lives inside the store's actions, not scattered across the components that call them. If three different components need to add an item to the cart and apply the same validation or side effect, that logic belongs in addItem, not duplicated in three onClick handlers.
This also applies to async logic. An action that hits an API and updates state optimistically keeps the component simple and makes the update logic testable in isolation, without rendering anything.
type OrderState = {
status: "idle" | "submitting" | "error";
submitOrder: (cartItems: CartItem[]) => Promise<void>;
};
export const useOrderStore = create<OrderState>((set) => ({
status: "idle",
submitOrder: async (cartItems) => {
set({ status: "submitting" });
try {
await api.post("/orders", { items: cartItems });
useCartStore.setState({ items: [] });
set({ status: "idle" });
} catch {
set({ status: "error" });
}
},
}));
Note the cross-store call: useOrderStore reaching into useCartStore.setState directly, outside of a React component. This works because a Zustand store is just a plain object with getState and setState methods attached; you don't need a hook or a component to read or update it. That's useful for coordinating between stores in response actions, but it's also a place where dependencies between stores can get tangled if you're not deliberate about which store owns which piece of state.
Middleware: persist and devtools
Zustand's middleware system covers the two features most teams reach for immediately: persisting state to storage and inspecting state changes in Redux DevTools. Both are applied by wrapping the store creator function.
import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";
type PreferencesState = {
theme: "light" | "dark";
setTheme: (theme: "light" | "dark") => void;
};
export const usePreferencesStore = create<PreferencesState>()(
devtools(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }, false, "setTheme"),
}),
{ name: "preferences-storage" }
),
{ name: "PreferencesStore" }
)
);
Middleware order matters here: devtools wraps persist, so devtools sees the state after persistence has hydrated it, not before. Getting this backward tends to produce confusing devtools output where the initial state doesn't match what's actually in storage. The extra arguments to set in setTheme (false, "setTheme") label the action in the devtools timeline, which is worth doing for any action you'll want to find quickly while debugging a state issue in production logs replayed locally.
Zustand in Next.js: the shared-instance pitfall
This is the pitfall that catches teams moving a Zustand store from a client-only app into Next.js. A module-level create() call, like the examples above, creates a single store instance shared by every import of that module. In a pure client app that's fine. There's one browser tab and one user, so a single shared instance never causes a problem. On the server, in an App Router app rendering React Server Components and Server Actions, that same module can be shared across requests from different users if the store is ever read or written during server rendering, which leaks state between unrelated requests.
The fix Zustand's own documentation recommends is creating the store per request instead of at module scope, and providing it through Context, the same mechanism from the previous article in this series, just used here to scope a store's lifetime rather than to hold state directly.
"use client";
import { createContext, useContext, useRef, type ReactNode } from "react";
import { createStore, useStore } from "zustand";
type CartStoreApi = ReturnType<typeof createCartStore>;
function createCartStore() {
return createStore<CartState>((set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, { ...item, quantity: 1 }] })),
removeItem: (productId) =>
set((state) => ({ items: state.items.filter((i) => i.productId !== productId) })),
updateQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.productId === productId ? { ...i, quantity } : i)),
})),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
}
const CartStoreContext = createContext<CartStoreApi | undefined>(undefined);
export function CartStoreProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<CartStoreApi>();
if (!storeRef.current) {
storeRef.current = createCartStore();
}
return <CartStoreContext.Provider value={storeRef.current}>{children}</CartStoreContext.Provider>;
}
export function useCartStore<T>(selector: (state: CartState) => T): T {
const store = useContext(CartStoreContext);
if (!store) {
throw new Error("useCartStore must be used within CartStoreProvider");
}
return useStore(store, selector);
}
This costs a bit of setup compared to the module-level version. It's the correct pattern once server rendering is involved, though, since each request gets its own store instance created fresh inside the client component tree. For state that's genuinely global to a browser session and never touched during server rendering, like a UI preference or a persisted cart that only reads and writes on the client, the simpler module-level create() is still fine. The distinction to watch for is whether the store's data could ever be tied to a specific user or request; if it can, give it a factory and a provider rather than a shared module instance.
A related hydration issue: persist reads from localStorage, which doesn't exist during server rendering. Zustand's persist middleware handles this by rendering the store's initial state on the server and rehydrating from storage after the client mounts, but if your UI branches on a persisted value (like theme) during the very first render, you can get a flash of the wrong value or a hydration mismatch warning. Setting skipHydration: true in the persist options and calling rehydrate() explicitly after mount, gated behind a loading state, avoids that mismatch at the cost of a small delay before the persisted value is available.
Key takeaways
- Zustand solves the exact problem Context can't: selective subscription to a slice of state without manual provider splitting, with far less boilerplate than Redux Toolkit.
- Keep derived values, like a cart total, computed on read rather than stored and kept in sync manually alongside their source data.
- Always subscribe with a selector that returns a single primitive or stable reference; use `useShallow` when a selector needs to return an object or array.
- Business logic for updating state belongs in store actions, not duplicated across the components that call them.
- Module-level stores work for client-only, session-scoped state. Anything touched during server rendering in Next.js needs a per-request store created through a factory and provided via Context.
- `persist` middleware needs `skipHydration` and an explicit rehydration step if your UI reads the persisted value on the very first render, to avoid a hydration mismatch.
Frequently asked questions
Do I need a provider to use Zustand, like I do with Context?
No, not for the basic module-level pattern. `create()` returns a hook you can import and call directly from any component, with no wrapping provider required. A provider only becomes necessary when you need a per-request store instance, most commonly in a Next.js app with server rendering.
How is Zustand different from Jotai?
Zustand centers on a single store object with a flat or nested shape, updated through actions you define. Jotai models state as a graph of independent atoms that compose together, closer to how Recoil works. Both give you selective subscriptions; the difference is mostly whether you prefer one store with explicit actions or many small, composable atoms.
Can I use Zustand outside of React components entirely?
Yes. A store created with `create` or `createStore` exposes `getState` and `setState` directly on the returned object, so you can read or update it from a plain function, a WebSocket handler, or an API client interceptor without needing a hook or a rendered component.
Should every piece of client state go into a Zustand store?
No. Local component state (`useState`) is still the right default for state that only one component and its direct children need. Reach for a store when multiple, unrelated parts of the tree need to read or update the same state, or when you need selective subscriptions that Context can't provide cleanly.
Why does my component re-render even though I'm using a selector?
Usually because the selector returns a new object or array reference on every call, even when the underlying values haven't changed. Zustand compares by reference by default, so an inline object literal in a selector always looks "changed." Wrap the selector in `useShallow`, or select individual primitive fields instead.
Is Zustand a good fit for server state, like data fetched from an API?
Generally no. Server state has its own concerns, caching, revalidation, background refetching, that a dedicated data-fetching library handles better than a hand-rolled store. Use Zustand for client-only state: UI state, form drafts, optimistic updates, and derived client-side data, and keep server-sourced data in a query library instead.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.