Apr 17, 2026
React State Management with Zustand: A Practical Guide for Next.js Apps
A practical guide to React state management with Zustand covering store design, Context API comparisons, TypeScript patterns, and best practices for Next.js applications.
9 min read
React State Management with Zustand: A Practical Guide for Next.js Apps
Most React applications start with useState and a handful of props, then hit a wall the moment two unrelated components need to share the same piece of data. React state management with Zustand is one of the cleanest solutions to that problem. Zustand is a small, fast, and opinionated store library that keeps global state simple without the ceremony of Redux or the re-render overhead that often plagues naive Context API setups.
This guide walks through when Zustand makes sense, how to design a well-typed store, how it compares to the Context API for real use cases, and the Zustand best practices that keep stores readable as an app grows. If you want to pair this with architecture guidance, React Folder Structure for Scalable Apps covers where store files fit in a production project. For the React primitives behind selector design, Advanced React Hooks Explained for Performance and Scalable Apps is a useful companion.
1. Why Zustand Fits Modern React Projects
Zustand was designed around one constraint: update subscriptions should be as granular as the developer needs and no more expensive than a direct reference comparison. The result is a library that ships in about 1 KB gzipped, works with or without React, and has zero required boilerplate.
That does not mean it is the right tool for every project. Zustand shines when:
- Multiple components at different tree depths read or write the same state
- You want to avoid wrapping the component tree in provider nesting just to share a few values
- You need derived state (selectors) that re-renders consumers only when relevant slices change
- You are already using TypeScript and want type inference without extra configuration
It is less compelling when state is truly local to a subtree, or when you need strict Redux DevTools workflows, complex reducer composition, or server-sourced data that would be better handled by a data-fetching library like React Query or SWR.
2. Creating Your First Zustand Store
Install Zustand with a single command:
npm install zustand
A minimal store looks like this:
// stores/useCounterStore.ts
import { create } from "zustand";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
And consuming it from a component:
// components/Counter.tsx
import { useCounterStore } from "@/stores/useCounterStore";
export function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
The selector function passed to useCounterStore is the key detail. Each component subscribes only to the slice it needs. If count changes but increment does not, only components that selected count will re-render. This is Zustand best practices rule one: always use selectors, never destructure the entire store.
3. Zustand vs Context API: When Each Makes Sense
Zustand vs Context API is one of the most searched comparisons for developers choosing a state strategy. The short answer is that they solve different problems, and many projects benefit from using both.
Context API strengths
Context is built in, requires no extra dependency, and works naturally for dependency injection — things like theme objects, locale strings, or an authentication provider that rarely changes. When the context value is stable or changes infrequently, the performance overhead is manageable.
The weakness appears when context holds frequently-changing values. Every context consumer re-renders when the provider value changes, even if the consumer only cares about one nested property. Splitting contexts helps, but it adds providers and can become hard to follow across a large component tree.
Zustand strengths
Zustand's subscription model means a component only re-renders when the specific slice it selected has changed. This makes it much better for data that changes often — UI state, filter selections, cart contents, form drafts — where re-render control matters.
Zustand also keeps store logic outside the component tree entirely. Actions live in the store, which makes them easy to test in isolation and easy to call from outside React (route handlers, event listeners, utility functions).
The practical split
A useful mental model: use Context for values that are set once or rarely updated and need to be available application-wide (auth user object, feature flags, theme). Use Zustand for UI state and any application data that changes frequently or needs to be shared across many components. This approach is explored further in React Folder Structure for Scalable Apps, where the stores/ and context/ directories serve distinct roles.
4. TypeScript Patterns for Zustand Stores
One of the reasons react state management zustand has grown popular in TypeScript codebases is that create<T>() infers action types automatically. You rarely need to write explicit return types for actions. If you want a broader look at TypeScript patterns in React, React TypeScript Patterns Every Developer Should Know covers utility types, generics, and discriminated unions that pair well with Zustand store design.
A more realistic store with slices of related state:
// stores/useCartStore.ts
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
// Derived value using get()
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
Notice the use of get() inside total. Zustand passes get as a second argument to the store creator so actions can read current state without capturing a stale closure.
5. Derived State and Selectors
Selectors keep component logic clean and re-render scope tight. For computed values that depend on expensive calculations, combine Zustand's selector with useMemo or use a library like zustand/middleware to memoize slices.
// Granular selector — re-renders only when item count changes
const itemCount = useCartStore((state) =>
state.items.reduce((sum, i) => sum + i.quantity, 0)
);
// Stable action reference — never triggers a re-render
const clearCart = useCartStore((state) => state.clearCart);
Action selectors like the second example never cause re-renders because actions are stable references. This pattern also pairs well with the useCallback patterns covered in Advanced React Hooks Explained for Performance and Scalable Apps.
6. Using Zustand in Next.js App Router
Zustand works in both client and server components as long as you keep the store on the client. The rule is simple: any file that imports useCartStore must be a Client Component or a file that only runs on the client.
For Next.js applications, this means:
// app/cart/page.tsx — this is a Server Component
import { CartSummary } from "@/components/CartSummary";
export default function CartPage() {
return <CartSummary />;
}
// components/CartSummary.tsx
"use client";
import { useCartStore } from "@/stores/useCartStore";
export function CartSummary() {
const total = useCartStore((state) => state.total());
const items = useCartStore((state) => state.items);
return (
<div>
<p>{items.length} items — ${total.toFixed(2)}</p>
</div>
);
}
The "use client" directive keeps the store call on the client while the surrounding page can still be a Server Component. This is the same boundary discipline described in Next.js Authentication Patterns for Secure App Router Apps, where sensitive data stays server-side and interactive state stays client-side.
One important note: Zustand stores are module-level singletons. In Next.js with SSR, this means the store is shared across requests if you initialize it in a module shared by the server. Use per-request store initialization if you need SSR-safe stores, following the Zustand documentation's "Next.js SSR" pattern.
7. Persisting Store State
Zustand ships a persist middleware that writes store slices to localStorage or sessionStorage with a single wrapper:
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface ThemeState {
colorScheme: "light" | "dark";
setColorScheme: (scheme: "light" | "dark") => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
colorScheme: "light",
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{ name: "theme-preference" }
)
);
The name key is the localStorage key. The persist middleware automatically handles serialization and hydration on the next page load.
8. Common Pitfalls to Avoid
Over-globalizing state. Not all state belongs in Zustand. Form input values, hover states, and component-level toggles belong in useState. Putting them in Zustand adds indirection without benefit.
Skipping selectors. Calling useStore() with no selector returns the entire store object, and the component re-renders on any store change. Always pass a selector.
Mutating state directly. Zustand's set uses shallow merge by default. If you need deep updates, spread manually or use the immer middleware:
import { immer } from "zustand/middleware/immer";
Ignoring SSR hydration. When using persist with Next.js, the server renders with no persisted value and the client hydrates with the stored value. Wrap components that depend on persisted state in a mounted check or use the Zustand skipHydration option to prevent hydration mismatches.
9. Quick Checklist for Zustand Store Design
Before shipping a new store, run through these questions:
- Does this state actually need to be global, or can
useStatein a closer ancestor handle it? - Does every component that reads the store use a selector, not a full store destructure?
- Are actions defined inside the store rather than computed inline in components?
- Is persisted state guarded against SSR hydration mismatches?
- Does the store have a clear name and live in a dedicated
stores/directory?
Following these Zustand best practices keeps stores predictable as the team grows and the codebase scales. For performance-critical applications, combine these patterns with the render profiling techniques in React Performance Optimization Guide for Faster Production Apps to confirm that selector granularity is producing measurable re-render reductions.
Conclusion
React state management with Zustand works because it does less than most alternatives and does it well. Stores are plain objects with actions, subscriptions are granular by default, and TypeScript support is first-class. For teams already comfortable with React hooks, Zustand reads like a natural extension rather than a new paradigm.
The practical path forward: start with useState and Context for local and stable shared state, introduce Zustand when you hit re-render problems or need to share frequently-updated state across a large component tree, and keep the store focused on data that genuinely needs to be global. That discipline — choosing the right tool for each kind of state — is what separates maintainable React applications from ones that gradually become harder to debug. For handling the async loading and error states that sit alongside your Zustand data, React Suspense and Error Boundaries: A Complete Practical Guide shows how Suspense fallbacks and error boundaries compose cleanly with store-driven components.