Next.js State Management with Zustand — Complete Guide for 2026
Advertisement
Introduction
Why This Matters
Next.js 15 handles server state natively through Server Components and React Query, but client-side UI state — theme preferences, cart contents, modal visibility, notifications — still needs a client-side solution. Zustand is the most popular choice in 2026 because it has no boilerplate, no providers required, and a bundle size under 1kb.
Unlike Redux, Zustand stores are plain functions. Unlike React Context, Zustand does not re-render all consumers when any slice of state changes — components subscribe only to the parts of the store they use.
Creating Your First Store
// lib/stores/counter.ts
import { create } from 'zustand'
interface CounterStore {
count: number
increment: () => void
decrement: () => void
reset: () => void
incrementBy: (amount: number) => void
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}))Use it in any Client Component — no provider needed:
// app/components/counter.tsx
'use client'
import { useCounterStore } from '@/lib/stores/counter'
export function Counter() {
const count = useCounterStore((state) => state.count)
const { increment, decrement, reset } = useCounterStore()
return (
<div className="flex items-center gap-4">
<button onClick={decrement} className="px-3 py-1 border rounded">-</button>
<span className="text-2xl font-bold w-12 text-center">{count}</span>
<button onClick={increment} className="px-3 py-1 border rounded">+</button>
<button onClick={reset} className="px-3 py-1 text-gray-500">Reset</button>
</div>
)
}Selecting a slice (state => state.count) means this component only re-renders when count changes, not when unrelated state updates.
Real-World Store: Shopping Cart
// lib/stores/cart.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface CartItem {
id: string
name: string
price: number
quantity: number
image: string
}
interface CartStore {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
total: () => number
itemCount: () => number
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (newItem) =>
set((state) => {
const existing = state.items.find((i) => i.id === newItem.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === newItem.id ? { ...i, quantity: i.quantity + 1 } : i
),
}
}
return { items: [...state.items, { ...newItem, quantity: 1 }] }
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: quantity <= 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
itemCount: () =>
get().items.reduce((sum, item) => sum + item.quantity, 0),
}),
{
name: 'cart-storage',
// Only persist the items array, not the computed functions
partialize: (state) => ({ items: state.items }),
}
)
)Persisting State with the Persist Middleware
The persist middleware saves and restores state from localStorage automatically:
// lib/stores/preferences.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface PreferencesStore {
theme: 'light' | 'dark' | 'system'
language: string
sidebarOpen: boolean
setTheme: (theme: 'light' | 'dark' | 'system') => void
setLanguage: (lang: string) => void
toggleSidebar: () => void
}
export const usePreferencesStore = create<PreferencesStore>()(
persist(
(set) => ({
theme: 'system',
language: 'en',
sidebarOpen: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}),
{
name: 'user-preferences',
storage: createJSONStorage(() => localStorage),
}
)
)The SSR-Safe Pattern for Next.js
Zustand persist reads from localStorage, which does not exist on the server. This causes hydration mismatches. Use the useEffect skip pattern to fix it:
// app/components/theme-toggle.tsx
'use client'
import { usePreferencesStore } from '@/lib/stores/preferences'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { theme, setTheme } = usePreferencesStore()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Render nothing on the server to avoid hydration mismatch
if (!mounted) return <div className="w-8 h-8" />
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 border rounded"
>
{theme === 'dark' ? 'Light' : 'Dark'}
</button>
)
}Using Immer for Nested State Updates
Immer lets you write mutating code that Zustand converts to immutable updates:
// lib/stores/editor.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface Block {
id: string
type: 'text' | 'image' | 'code'
content: string
}
interface EditorStore {
blocks: Block[]
addBlock: (block: Block) => void
updateBlock: (id: string, content: string) => void
removeBlock: (id: string) => void
reorderBlocks: (from: number, to: number) => void
}
export const useEditorStore = create<EditorStore>()(
immer((set) => ({
blocks: [],
addBlock: (block) =>
set((state) => {
state.blocks.push(block)
}),
updateBlock: (id, content) =>
set((state) => {
const block = state.blocks.find((b) => b.id === id)
if (block) block.content = content
}),
removeBlock: (id) =>
set((state) => {
state.blocks = state.blocks.filter((b) => b.id !== id)
}),
reorderBlocks: (from, to) =>
set((state) => {
const [block] = state.blocks.splice(from, 1)
state.blocks.splice(to, 0, block)
}),
}))
)Async Actions and Loading State
// lib/stores/notifications.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface Notification {
id: string
message: string
type: 'info' | 'success' | 'error'
read: boolean
}
interface NotificationStore {
notifications: Notification[]
unreadCount: number
loading: boolean
fetchNotifications: () => Promise<void>
markAsRead: (id: string) => void
markAllRead: () => void
}
export const useNotificationStore = create<NotificationStore>()(
devtools(
(set, get) => ({
notifications: [],
unreadCount: 0,
loading: false,
fetchNotifications: async () => {
set({ loading: true })
try {
const res = await fetch('/api/notifications')
const data: Notification[] = await res.json()
set({
notifications: data,
unreadCount: data.filter((n) => !n.read).length,
})
} finally {
set({ loading: false })
}
},
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
),
unreadCount: Math.max(0, state.unreadCount - 1),
})),
markAllRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
unreadCount: 0,
})),
}),
{ name: 'NotificationStore' }
)
)Common Mistakes
- Using
useStore()without a selector — this re-renders the component on every state change, not just the relevant slice - Using Zustand stores in Server Components — stores are client-only; always mark consumers with
'use client' - Not using
partializein the persist middleware — you may accidentally persist functions, causing runtime errors - Mutating state directly without
set()— this bypasses Zustand's reactivity system - Storing server data (API responses) in Zustand alongside React Query — choose one; mixing them causes sync issues
Best Practices
- Select only the state slices you need:
useStore((s) => s.count)notuseStore() - Separate stores by domain (cart, auth, editor, preferences) rather than one giant store
- Use
devtoolsmiddleware in development to inspect state in Redux DevTools browser extension - Use
persistwithpartializeto whitelist only the fields that should survive page refresh - Keep computed values as functions using
get()in the store rather than duplicating derived state
Key Takeaways
- Zustand requires no provider wrapper — any Client Component can access a store directly via the hook
- Selecting a state slice (
state => state.count) prevents unnecessary re-renders when unrelated state changes - The
persistmiddleware syncs store state tolocalStorageorsessionStorageautomatically - Use the
mountedpattern to avoid SSR hydration mismatches with persisted stores - The
immermiddleware enables mutating syntax for deeply nested state updates with no extra code devtoolsmiddleware integrates with Redux DevTools for inspecting store changes during development- Async actions call
set()before and after the async operation to track loading state - Zustand is for client UI state; use Server Components or React Query for server data
Advertisement