Skip to main content

Next.js State Management Patterns

In modern web development, managing state effectively is crucial for building responsive and maintainable applications. Next.js, as a React framework, offers various approaches to state management that can be leveraged according to your application's needs.

Introduction to State Management in Next.js

State management refers to how we handle the data that changes over time in our applications. In Next.js applications, we need to manage different types of state:

  • UI State: Controls the interactive parts of your interface (open/closed menus, active tabs)
  • Application State: Represents the core data your application works with
  • Server State: Data fetched from an API or server
  • URL State: Data stored in the URL (query parameters, route parameters)

Let's explore the different patterns you can use to manage these types of state in Next.js applications.

Built-in State Management Options

1. React's useState and useReducer

The most basic form of state management in Next.js comes from React itself.

Using useState for Simple State

jsx
'use client'

import { useState } from 'react'

export default function Counter() {
const [count, setCount] = useState(0)

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}

This approach works well for:

  • Component-level state
  • Simple state logic
  • State that doesn't need to be shared widely across the application

Using useReducer for Complex State

When state logic becomes more complex, useReducer provides a more structured way to manage state:

jsx
'use client'

import { useReducer } from 'react'

// Reducer function
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.text, completed: false }]
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
)
default:
return state
}
}

export default function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, [])
const [text, setText] = useState('')

const addTodo = () => {
if (text.trim()) {
dispatch({ type: 'ADD_TODO', text })
setText('')
}
}

return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add todo"
/>
<button onClick={addTodo}>Add</button>

<ul>
{todos.map(todo => (
<li
key={todo.id}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })}
>
{todo.text}
</li>
))}
</ul>
</div>
)
}

2. Context API for Shared State

When you need to share state between components without prop drilling, React's Context API is a good built-in solution:

jsx
'use client'

// In a file called ThemeContext.js
import { createContext, useContext, useState } from 'react'

const ThemeContext = createContext()

export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')

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

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

export function useTheme() {
return useContext(ThemeContext)
}

In your layout or component:

jsx
'use client'

import { ThemeProvider } from './ThemeContext'
import ThemeToggle from './ThemeToggle'
import ThemedComponent from './ThemedComponent'

export default function Layout({ children }) {
return (
<ThemeProvider>
<ThemeToggle />
<ThemedComponent />
{children}
</ThemeProvider>
)
}

And in your components:

jsx
'use client'

import { useTheme } from './ThemeContext'

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

return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}

3. Next.js Server Components and Data Fetching

Next.js 13+ introduced Server Components which change how we think about state. With Server Components, some state can be managed on the server side:

jsx
// This is a Server Component by default
async function ProductList() {
// This data fetching happens on the server
const products = await fetch('https://api.example.com/products')
.then(res => res.json())

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

export default ProductList

For client-side interactivity, you can mix Server and Client Components:

jsx
// This is a Client Component
'use client'

import { useState } from 'react'

export default function ProductFilter({ initialProducts }) {
const [filteredProducts, setFilteredProducts] = useState(initialProducts)
const [searchTerm, setSearchTerm] = useState('')

const handleSearch = () => {
const filtered = initialProducts.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
)
setFilteredProducts(filtered)
}

return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products"
/>
<button onClick={handleSearch}>Search</button>

<ul>
{filteredProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
)
}

Advanced State Management Patterns

1. Zustand for Simple Global State

Zustand is a minimalistic state management library that works well with Next.js:

jsx
'use client'

// store.js
import { create } from 'zustand'

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

Using the store in a component:

jsx
'use client'

import { useStore } from './store'

export default function Counter() {
const { count, increment, decrement, reset } = useStore()

return (
<div>
<h2>Count: {count}</h2>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
)
}

2. Jotai for Atomic State

Jotai takes an atomic approach to state management:

jsx
'use client'

// atoms.js
import { atom } from 'jotai'

export const countAtom = atom(0)
export const doubleCountAtom = atom((get) => get(countAtom) * 2)

Using atoms in a component:

jsx
'use client'

import { useAtom } from 'jotai'
import { countAtom, doubleCountAtom } from './atoms'

export default function Counter() {
const [count, setCount] = useAtom(countAtom)
const [doubleCount] = useAtom(doubleCountAtom)

return (
<div>
<h2>Count: {count}</h2>
<h3>Double Count: {doubleCount}</h3>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}

3. Redux Toolkit with Next.js

For complex applications with intricate state requirements, Redux Toolkit can be used:

jsx
'use client'

// store.js
import { configureStore, createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
},
})

export const { increment, decrement } = counterSlice.actions

export const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
})

Create a provider component:

jsx
'use client'

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

export function ReduxProvider({ children }) {
return <Provider store={store}>{children}</Provider>
}

Using Redux in your layout:

jsx
// layout.js
import { ReduxProvider } from './ReduxProvider'

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ReduxProvider>{children}</ReduxProvider>
</body>
</html>
)
}

Using Redux in a component:

jsx
'use client'

import { useDispatch, useSelector } from 'react-redux'
import { increment, decrement } from './store'

export default function Counter() {
const count = useSelector((state) => state.counter.value)
const dispatch = useDispatch()

return (
<div>
<h2>Count: {count}</h2>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
</div>
)
}

4. SWR and React Query for Server State

For managing server state (data fetched from APIs), SWR and React Query provide excellent solutions:

Using SWR:

jsx
'use client'

import useSWR from 'swr'

// Create a fetcher function
const fetcher = (...args) => fetch(...args).then(res => res.json())

export default function UserProfile({ userId }) {
const { data, error, isLoading } = useSWR(
`/api/users/${userId}`,
fetcher
)

if (error) return <div>Failed to load user data</div>
if (isLoading) return <div>Loading...</div>

return (
<div>
<h1>{data.name}</h1>
<p>Email: {data.email}</p>
</div>
)
}

Using React Query:

jsx
'use client'

import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { useState } from 'react'

// Create a client
const queryClient = new QueryClient()

function UserProfile({ userId }) {
const { data, error, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
})

if (error) return <div>Failed to load user data</div>
if (isLoading) return <div>Loading...</div>

return (
<div>
<h1>{data.name}</h1>
<p>Email: {data.email}</p>
</div>
)
}

export default function App() {
// Client components can use providers
return (
<QueryClientProvider client={queryClient}>
<UserProfile userId="123" />
</QueryClientProvider>
)
}

Real-World Application: E-Commerce Shopping Cart

Let's build a simple shopping cart using Zustand to demonstrate a real-world state management scenario:

jsx
'use client'

// cartStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCartStore = create(
persist(
(set, get) => ({
items: [],
totalItems: 0,
totalPrice: 0,

addItem: (product) => {
const items = get().items
const existingItem = items.find(item => item.id === product.id)

if (existingItem) {
const updatedItems = items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)

set(state => ({
items: updatedItems,
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + product.price
}))
} else {
set(state => ({
items: [...items, { ...product, quantity: 1 }],
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + product.price
}))
}
},

removeItem: (productId) => {
const items = get().items
const itemToRemove = items.find(item => item.id === productId)

if (itemToRemove) {
set(state => ({
items: state.items.filter(item => item.id !== productId),
totalItems: state.totalItems - itemToRemove.quantity,
totalPrice: state.totalPrice - (itemToRemove.price * itemToRemove.quantity)
}))
}
},

clearCart: () => {
set({
items: [],
totalItems: 0,
totalPrice: 0
})
}
}),
{
name: 'shopping-cart', // unique name for localStorage
}
)
)

Product listing component:

jsx
'use client'

import { useCartStore } from './cartStore'

const products = [
{ id: 1, name: 'T-Shirt', price: 19.99, image: '/tshirt.jpg' },
{ id: 2, name: 'Jeans', price: 49.99, image: '/jeans.jpg' },
{ id: 3, name: 'Sneakers', price: 79.99, image: '/sneakers.jpg' },
]

export default function ProductList() {
const addItem = useCartStore(state => state.addItem)

return (
<div className="product-grid">
{products.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => addItem(product)}>
Add to Cart
</button>
</div>
))}
</div>
)
}

Cart component:

jsx
'use client'

import { useCartStore } from './cartStore'

export default function Cart() {
const { items, totalItems, totalPrice, removeItem, clearCart } = useCartStore()

if (totalItems === 0) {
return <div className="cart">Your cart is empty</div>
}

return (
<div className="cart">
<h2>Your Cart ({totalItems} items)</h2>

<div className="cart-items">
{items.map(item => (
<div key={item.id} className="cart-item">
<img src={item.image} alt={item.name} />
<div className="item-details">
<h3>{item.name}</h3>
<p>
${item.price.toFixed(2)} x {item.quantity} =
${(item.price * item.quantity).toFixed(2)}
</p>
</div>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
</div>

<div className="cart-summary">
<h3>Total: ${totalPrice.toFixed(2)}</h3>
<button onClick={clearCart}>Clear Cart</button>
<button>Checkout</button>
</div>
</div>
)
}

Choosing the Right State Management Pattern

The appropriate state management pattern for your Next.js application depends on several factors:

  1. Application complexity: For simple apps, React's built-in state management might be sufficient. For more complex apps, consider third-party libraries.

  2. Team familiarity: Choose patterns your team is comfortable with.

  3. Performance requirements: Some state management libraries are more optimized for specific use cases.

  4. Type of state: Differentiate between UI state, server state, and application state.

Here's a quick decision guide:

  • Simple component state: useState
  • Complex component state: useReducer
  • Shared state between components: Context API, Zustand, Jotai
  • Global application state: Redux, Zustand, Jotai
  • Server state: SWR, React Query
  • Form state: React Hook Form, Formik

Summary

In this guide, we've explored various state management patterns for Next.js applications:

  1. Built-in React state management with useState and useReducer
  2. Context API for sharing state between components
  3. Server Components for server-side state management
  4. Zustand for simple global state
  5. Jotai for atomic state management
  6. Redux Toolkit for complex state management
  7. SWR and React Query for server state management

Each pattern has its own advantages and use cases. By understanding these patterns, you can choose the right approach for your specific Next.js application needs.

Additional Resources

Exercises

  1. Create a theme switcher using Context API that persists the user's preference in local storage.
  2. Build a to-do list application using Zustand with features to add, delete, and mark tasks as completed.
  3. Implement a paginated data table using React Query that fetches data from an API.
  4. Create a multi-step form with form state management using React Hook Form or Formik.
  5. Build a small e-commerce app with product filtering and cart functionality using Jotai.

These exercises will help you practice different state management patterns and understand when to use each one in your Next.js applications.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)