Next.js State Management with Zustand — Complete Guide for 2026

Sanjeev SharmaSanjeev Sharma
7 min read

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 partialize in 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) not useStore()
  • Separate stores by domain (cart, auth, editor, preferences) rather than one giant store
  • Use devtools middleware in development to inspect state in Redux DevTools browser extension
  • Use persist with partialize to 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 persist middleware syncs store state to localStorage or sessionStorage automatically
  • Use the mounted pattern to avoid SSR hydration mismatches with persisted stores
  • The immer middleware enables mutating syntax for deeply nested state updates with no extra code
  • devtools middleware 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading