State Management in Next.js
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.
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>
)
}
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>
)
}
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.
<!-- ❌ 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
- Create a Zustand store for managing a todo list with add, remove, toggle, and filter functionality
- Implement persistence using Zustand's persist middleware
- Add filtering via URL search parameters (completed, active, all)
- Create a Server Component that shows todo statistics without using client state
- 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
)
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.