Next.js with Zustand — State Management

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Next.js with Zustand — State Management

Zustand provides a minimal, flexible approach to state management that works perfectly with Next.js and React.

Installation

npm install zustand

Basic Store

Create a store:

// lib/store.ts
import { create } from 'zustand'

interface Store {
  count: number
  increment: () => void
  decrement: () => void
}

export const useCountStore = create<Store>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 }))
}))

Using in Components

// app/components/counter.tsx
'use client'

import { useCountStore } from '@/lib/store'

export function Counter() {
  const { count, increment, decrement } = useCountStore()

  return (
    <div className="flex items-center gap-4">
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}

User Store Example

// lib/stores/user.ts
import { create } from 'zustand'

interface User {
  id: string
  email: string
  name: string
  role: 'user' | 'admin'
}

interface UserStore {
  user: User | null
  setUser: (user: User) => void
  clearUser: () => void
  isAdmin: () => boolean
}

export const useUserStore = create<UserStore>((set, get) => ({
  user: null,

  setUser: (user) => set({ user }),

  clearUser: () => set({ user: null }),

  isAdmin: () => {
    const { user } = get()
    return user?.role === 'admin'
  }
}))

Persist State

Persist state to localStorage:

// lib/stores/theme.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface ThemeStore {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

export const useThemeStore = create<ThemeStore>(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light'
        }))
    }),
    {
      name: 'theme-storage'
    }
  )
)

Multiple Stores

Combine stores:

// lib/useAppStore.ts
import { useUserStore } from './stores/user'
import { useThemeStore } from './stores/theme'
import { useNotificationStore } from './stores/notifications'

export function useAppStore() {
  return {
    user: useUserStore(),
    theme: useThemeStore(),
    notifications: useNotificationStore()
  }
}

Usage:

const { user, theme } = useAppStore()

Complex State

Handle complex state updates:

// lib/stores/todo.ts
import { create } from 'zustand'

interface Todo {
  id: string
  text: string
  completed: boolean
}

interface TodoStore {
  todos: Todo[]
  addTodo: (text: string) => void
  removeTodo: (id: string) => void
  toggleTodo: (id: string) => void
  updateTodo: (id: string, text: string) => void
}

export const useTodoStore = create<TodoStore>((set) => ({
  todos: [],

  addTodo: (text) =>
    set((state) => ({
      todos: [...state.todos, { id: Date.now().toString(), text, completed: false }]
    })),

  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id)
    })),

  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    })),

  updateTodo: (id, text) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, text } : todo
      )
    }))
}))

DevTools Integration

Enable debugging:

// lib/stores/debug.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

interface DebugStore {
  count: number
  increment: () => void
}

export const useDebugStore = create<DebugStore>(
  devtools((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 }))
  }), { name: 'DebugStore' })
)

Use Redux DevTools browser extension to debug.

Immer Middleware

Simplify state updates:

// lib/stores/cart.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface CartItem {
  id: string
  quantity: number
  price: number
}

interface CartStore {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  updateQuantity: (id: string, quantity: number) => void
}

export const useCartStore = create<CartStore>(
  immer((set) => ({
    items: [],

    addItem: (item) =>
      set((state) => {
        state.items.push(item)
      }),

    removeItem: (id) =>
      set((state) => {
        state.items = state.items.filter((item) => item.id !== id)
      }),

    updateQuantity: (id, quantity) =>
      set((state) => {
        const item = state.items.find((item) => item.id === id)
        if (item) item.quantity = quantity
      })
  }))
)

Real-World: Shopping Cart

// lib/stores/shopping.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface CartItem {
  productId: string
  quantity: number
  price: number
}

interface ShoppingStore {
  cart: CartItem[]
  addToCart: (productId: string, price: number) => void
  removeFromCart: (productId: string) => void
  clearCart: () => void
  getTotal: () => number
}

export const useShoppingStore = create<ShoppingStore>(
  persist(
    (set, get) => ({
      cart: [],

      addToCart: (productId, price) =>
        set((state) => {
          const existing = state.cart.find((item) => item.productId === productId)

          if (existing) {
            return {
              cart: state.cart.map((item) =>
                item.productId === productId
                  ? { ...item, quantity: item.quantity + 1 }
                  : item
              )
            }
          }

          return {
            cart: [...state.cart, { productId, quantity: 1, price }]
          }
        }),

      removeFromCart: (productId) =>
        set((state) => ({
          cart: state.cart.filter((item) => item.productId !== productId)
        })),

      clearCart: () => set({ cart: [] }),

      getTotal: () => {
        const { cart } = get()
        return cart.reduce((total, item) => total + item.price * item.quantity, 0)
      }
    }),
    { name: 'shopping-store' }
  )
)

Async Actions

// lib/stores/posts.ts
import { create } from 'zustand'

interface Post {
  id: string
  title: string
}

interface PostsStore {
  posts: Post[]
  loading: boolean
  error: string | null
  fetchPosts: () => Promise<void>
}

export const usePostsStore = create<PostsStore>((set) => ({
  posts: [],
  loading: false,
  error: null,

  fetchPosts: async () => {
    set({ loading: true })

    try {
      const response = await fetch('/api/posts')
      const data = await response.json()
      set({ posts: data, error: null })
    } catch (error) {
      set({ error: (error as Error).message })
    } finally {
      set({ loading: false })
    }
  }
}))

FAQ

Q: Should I use Zustand or Context API? A: Zustand for global, frequently-updated state. Context for rare updates or small scopes.

Q: Can I use Zustand without 'use client'? A: No, stores must be used in Client Components where hooks work.

Q: How do I handle async state? A: Create async actions that call set when complete. Use immer for easier mutations.


Zustand provides powerful, minimal state management for Next.js applications.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro