Next.js

State Management in Next.js

25 min Lesson 16 of 40

Introduction to State Management

State management is a critical aspect of building complex Next.js applications. As your application grows, you'll need effective patterns to manage data that flows through your components. Next.js applications have unique considerations because they blend server and client components.

Key Concept: In Next.js 13+, you must distinguish between server state (data from databases, APIs) and client state (UI state, form inputs, user interactions).

Understanding State Types

Before diving into solutions, let's understand the different types of state in Next.js applications:

  • Server State: Data fetched from external sources (databases, APIs). Should be fetched in Server Components when possible.
  • Client State: UI state, form values, modals, theme preferences - anything that requires interactivity.
  • URL State: Search parameters, route segments - state encoded in the URL.
  • Global State: Data shared across many components (user authentication, shopping cart).

React Context for Simple State

For simpler applications or isolated state needs, React Context provides a built-in solution without external dependencies.

<!-- app/providers/theme-provider.tsx -->
'use client'

import { createContext, useContext, useState, ReactNode } from 'react'

type Theme = 'light' | 'dark'

interface ThemeContextType {
  theme: Theme
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light')

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider')
  }
  return context
}
<!-- app/layout.tsx -->
import { ThemeProvider } from './providers/theme-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}
<!-- app/components/theme-toggle.tsx -->
'use client'

import { useTheme } from '../providers/theme-provider'

export function ThemeToggle() {
  const { theme, toggleTheme } = useTheme()

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  )
}
Best Practice: Use React Context for simple, localized state. For complex state with frequent updates, consider more optimized solutions like Zustand or Redux.

Zustand - Lightweight State Management

Zustand is a minimal state management solution that works excellently with Next.js. It's simple, fast, and doesn't require context providers.

# Install Zustand
npm install zustand
<!-- app/store/use-cart-store.ts -->
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

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

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

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

      addItem: (item) => set((state) => {
        const existingItem = state.items.find(i => i.id === item.id)

        if (existingItem) {
          return {
            items: state.items.map(i =>
              i.id === item.id
                ? { ...i, quantity: i.quantity + item.quantity }
                : i
            )
          }
        }

        return { items: [...state.items, item] }
      }),

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

      updateQuantity: (id, quantity) => set((state) => ({
        items: state.items.map(item =>
          item.id === id ? { ...item, quantity } : item
        )
      })),

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

      getTotalPrice: () => {
        const { items } = get()
        return items.reduce((total, item) =>
          total + (item.price * item.quantity), 0
        )
      },

      getTotalItems: () => {
        const { items } = get()
        return items.reduce((total, item) => total + item.quantity, 0)
      }
    }),
    {
      name: 'cart-storage', // localStorage key
    }
  )
)
<!-- app/components/cart-button.tsx -->
'use client'

import { useCartStore } from '../store/use-cart-store'

export function CartButton() {
  const totalItems = useCartStore(state => state.getTotalItems())
  const totalPrice = useCartStore(state => state.getTotalPrice())

  return (
    <button>
      Cart ({totalItems}) - ${totalPrice.toFixed(2)}
    </button>
  )
}
<!-- app/components/add-to-cart.tsx -->
'use client'

import { useCartStore } from '../store/use-cart-store'

interface AddToCartProps {
  product: {
    id: string
    name: string
    price: number
  }
}

export function AddToCart({ product }: AddToCartProps) {
  const addItem = useCartStore(state => state.addItem)

  const handleAddToCart = () => {
    addItem({
      ...product,
      quantity: 1
    })
  }

  return (
    <button onClick={handleAddToCart}>
      Add to Cart - ${product.price}
    </button>
  )
}
Zustand Benefits: No providers needed, minimal boilerplate, excellent TypeScript support, built-in persistence, and selective re-renders for optimal performance.

Redux Toolkit for Complex Applications

For large-scale applications with complex state logic, Redux Toolkit provides powerful features including time-travel debugging, middleware, and excellent DevTools.

# Install Redux Toolkit and React-Redux
npm install @reduxjs/toolkit react-redux
<!-- app/store/store.ts -->
import { configureStore } from '@reduxjs/toolkit'
import userReducer from './features/user-slice'
import productsReducer from './features/products-slice'

export const store = configureStore({
  reducer: {
    user: userReducer,
    products: productsReducer,
  },
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
<!-- app/store/features/user-slice.ts -->
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface UserState {
  id: string | null
  email: string | null
  name: string | null
  isAuthenticated: boolean
}

const initialState: UserState = {
  id: null,
  email: null,
  name: null,
  isAuthenticated: false,
}

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setUser: (state, action: PayloadAction<Omit<UserState, 'isAuthenticated'>>) => {
      state.id = action.payload.id
      state.email = action.payload.email
      state.name = action.payload.name
      state.isAuthenticated = true
    },
    clearUser: (state) => {
      state.id = null
      state.email = null
      state.name = null
      state.isAuthenticated = false
    },
    updateUserName: (state, action: PayloadAction<string>) => {
      state.name = action.payload
    }
  },
})

export const { setUser, clearUser, updateUserName } = userSlice.actions
export default userSlice.reducer
<!-- app/store/hooks.ts -->
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'

export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
<!-- app/providers/redux-provider.tsx -->
'use client'

import { Provider } from 'react-redux'
import { store } from '../store/store'

export function ReduxProvider({ children }: { children: React.ReactNode }) {
  return <Provider store={store}>{children}</Provider>
}
<!-- app/layout.tsx -->
import { ReduxProvider } from './providers/redux-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <ReduxProvider>
          {children}
        </ReduxProvider>
      </body>
    </html>
  )
}
<!-- app/components/user-profile.tsx -->
'use client'

import { useAppSelector, useAppDispatch } from '../store/hooks'
import { updateUserName, clearUser } from '../store/features/user-slice'

export function UserProfile() {
  const user = useAppSelector(state => state.user)
  const dispatch = useAppDispatch()

  const handleLogout = () => {
    dispatch(clearUser())
  }

  const handleUpdateName = (newName: string) => {
    dispatch(updateUserName(newName))
  }

  if (!user.isAuthenticated) {
    return <div>Not logged in</div>
  }

  return (
    <div>
      <h2>Welcome, {user.name}</h2>
      <p>Email: {user.email}</p>
      <button onClick={handleLogout}>Logout</button>
    </div>
  )
}

Server State vs Client State

One of the most important distinctions in Next.js is understanding when to use server state versus client state.

Common Mistake: Don't store server data (from databases/APIs) in client state managers like Redux or Zustand. Use Server Components and fetch on the server whenever possible.
<!-- ❌ BAD: Fetching in client component and storing in Zustand -->
'use client'

import { useEffect } from 'react'
import { useProductStore } from '../store/product-store'

export function Products() {
  const { products, setProducts } = useProductStore()

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => setProducts(data))
  }, [])

  return (
    <div>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}
<!-- ✅ GOOD: Fetching in Server Component -->
import { prisma } from '@/lib/prisma'

export default async function Products() {
  const products = await prisma.product.findMany()

  return (
    <div>
      {products.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

URL as State

Next.js encourages using the URL for state that should be shareable and bookmarkable.

<!-- app/products/page.tsx -->
import { Suspense } from 'react'
import ProductList from './product-list'

interface SearchParams {
  category?: string
  sort?: string
  page?: string
}

export default function ProductsPage({
  searchParams
}: {
  searchParams: SearchParams
}) {
  return (
    <div>
      <h1>Products</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <ProductList searchParams={searchParams} />
      </Suspense>
    </div>
  )
}
<!-- app/products/product-filters.tsx -->
'use client'

import { useRouter, useSearchParams } from 'next/navigation'

export function ProductFilters() {
  const router = useRouter()
  const searchParams = useSearchParams()

  const updateFilter = (key: string, value: string) => {
    const params = new URLSearchParams(searchParams.toString())
    params.set(key, value)
    router.push(`/products?${params.toString()}`)
  }

  return (
    <div>
      <select
        value={searchParams.get('category') || ''}
        onChange={(e) => updateFilter('category', e.target.value)}
      >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>

      <select
        value={searchParams.get('sort') || 'name'}
        onChange={(e) => updateFilter('sort', e.target.value)}
      >
        <option value="name">Name</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
      </select>
    </div>
  )
}

Choosing the Right State Solution

Here's a decision matrix to help you choose the appropriate state management approach:

  • URL State: Filters, pagination, search terms, tabs - anything that should be shareable
  • Server Components: Data from databases/APIs that doesn't need frequent updates
  • useState/useReducer: Local component state (form inputs, toggle states)
  • React Context: Simple shared state (theme, language, simple user preferences)
  • Zustand: Medium complexity shared state (shopping cart, UI preferences, notifications)
  • Redux Toolkit: Complex state with intricate logic, need for time-travel debugging, or existing Redux ecosystem
Practice Exercise:
  1. Create a Zustand store for managing a todo list with add, remove, toggle, and filter functionality
  2. Implement persistence using Zustand's persist middleware
  3. Add filtering via URL search parameters (completed, active, all)
  4. Create a Server Component that shows todo statistics without using client state
  5. Build a theme switcher using React Context that persists to localStorage

Performance Optimization

When working with state management, always consider performance implications:

<!-- Optimize Zustand selectors -->
// ❌ BAD: Re-renders on any store change
const store = useCartStore()

// ✅ GOOD: Only re-renders when totalItems changes
const totalItems = useCartStore(state => state.getTotalItems())

// ✅ BETTER: Use shallow equality for objects
import { shallow } from 'zustand/shallow'

const { items, addItem } = useCartStore(
  state => ({ items: state.items, addItem: state.addItem }),
  shallow
)
Performance Tip: Always use selector functions with Zustand and Redux to ensure components only re-render when the specific data they need changes.

Summary

State management in Next.js requires understanding the distinction between server and client state. Use Server Components for data fetching, URL for shareable state, and choose the appropriate client state solution based on your complexity needs. Start simple with React hooks and Context, then scale to Zustand or Redux as your application grows.