State Management in Next.js

1. Introduction 🚀

State management in Next.js is trickier than in a typical SPA because your app spans two environments — the server and the client — each with different lifetimes and capabilities. This tutorial walks through every kind of state you'll encounter, from a simple useState counter to global stores, server state caching, and URL-driven state.

Information

Throughout this guide, "state" broadly means any data that changes over time and affects what gets rendered — not just useState specifically.

2. State in Next.js 🧭

With the app router, components render on the server by default. This changes how you think about state: Server Components have no client-side state at all, while Client Components (marked with 'use client') behave like traditional React.

Application State
Server State (data fetched on the server)
Client State
Local (component-level)
Global (shared across components)
URL (search params, route segments)

3. Local State 📍

Local state lives inside a single component and disappears when that component unmounts — the simplest and most common kind of state, ideal for things like toggles, input values, and modals.

components/Counter.tsx

'use client';
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

4. Global State 🌐

Global state is shared across many unrelated components — think a shopping cart, the current user, or a notification queue. It typically needs a dedicated store rather than prop-drilling through every component.

Tip

Reach for global state only when multiple, distant components genuinely need the same data — overusing it can make an app harder to reason about than plain props.

5. Server State đŸ—„ī¸

Server state is data that actually lives in a database or external API — your UI just holds a temporary, possibly stale copy of it. This is the domain of tools like TanStack Query and SWR.

  • Owned by the server, not the client.
  • Can go stale and needs revalidation.
  • Often shared by multiple users simultaneously.

6. Client State đŸ’ģ

Client state exists only in the browser and has no server-side source of truth — UI toggles, form drafts, and animation states are typical examples.

components/Accordion.tsx

'use client';
import { useState } from 'react';

export function Accordion() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && <p>Content</p>}
    </div>
  );
}

7. URL State 🔗

Storing state in the URL — via path segments or search params — makes it shareable and bookmarkable, and it survives a page refresh without any extra code.

app/products/page.tsx

export default function ProductsPage({ searchParams }: { searchParams: { sort?: string } }) {
  const sort = searchParams.sort ?? 'newest';
  return <ProductList sort={sort} />;
}

Best Practice

Filters, sort order, and pagination are excellent candidates for URL state — a user should be able to share a link and land on the exact same view.

8. Context API 🧩

React's built-in Context lets you share state across a component tree without prop-drilling, without adding an external dependency.

context/ThemeContext.tsx

'use client';
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext<{ theme: string; toggle: () => void } | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');
  const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
  return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
}

export const useTheme = () => useContext(ThemeContext)!;

Caution

Context re-renders every consumer when its value changes — avoid putting frequently changing, high-frequency state (like mouse position) directly into a large Context.

9. useState đŸŽŖ

useState is the most basic state primitive in React — perfect for a single component's isolated, simple values.

components/Toggle.tsx

'use client';
import { useState } from 'react';

export function Toggle() {
  const [on, setOn] = useState(false);
  return <button onClick={() => setOn(!on)}>{on ? 'ON' : 'OFF'}</button>;
}

10. useReducer 🔀

When state updates involve multiple related fields or complex transitions, useReducer centralizes that logic into a single, testable function instead of scattering it across many setState calls.

components/Form.tsx

'use client';
import { useReducer } from 'react';

type State = { name: string; email: string };
type Action = { type: 'setName'; value: string } | { type: 'setEmail'; value: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'setName': return { ...state, name: action.value };
    case 'setEmail': return { ...state, email: action.value };
  }
}

export function Form() {
  const [state, dispatch] = useReducer(reducer, { name: '', email: '' });
  return (
    <input value={state.name} onChange={(e) => dispatch({ type: 'setName', value: e.target.value })} />
  );
}

11. Zustand đŸģ

Zustand is a minimal global state library with no boilerplate and no context provider required — a store is just a hook.

store/cart.ts

import { create } from 'zustand';

interface CartState {
  items: string[];
  addItem: (item: string) => void;
}

export const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

12. Redux Toolkit 🧰

Redux Toolkit is the modern, opinionated way to use Redux — offering predictable, centralized state with strong DevTools support, best suited to large, complex applications.

store/cartSlice.ts

import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] as string[] },
  reducers: {
    addItem: (state, action) => {
      state.items.push(action.payload);
    },
  },
});

export const { addItem } = cartSlice.actions;
export default cartSlice.reducer;

13. Jotai âš›ī¸

Jotai takes an atomic approach: instead of one big store, state is split into small, independent atoms that components subscribe to individually, minimizing unnecessary re-renders.

store/atoms.ts

import { atom, useAtom } from 'jotai';

export const countAtom = atom(0);

// In a component
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

14. Recoil ⚡

Recoil, developed at Meta, offers a similar atom-based model to Jotai, with additional support for derived state through selectors.

store/atoms.ts

import { atom, useRecoilState } from 'recoil';

export const countState = atom({ key: 'countState', default: 0 });

function Counter() {
  const [count, setCount] = useRecoilState(countState);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Note

Recoil's development pace has slowed relative to Zustand and Jotai — weigh long-term maintenance when choosing between them for a new project.

15. TanStack Query 🔄

TanStack Query (formerly React Query) manages server state specifically: fetching, caching, background refetching, and synchronizing remote data with your UI.

components/UserProfile.tsx

'use client';
import { useQuery } from '@tanstack/react-query';

export function UserProfile({ id }: { id: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
  });

  if (isLoading) return <p>Loading...</p>;
  return <p>{data.name}</p>;
}

16. SWR 🔁

SWR, built by Vercel, is a lighter alternative to TanStack Query with a similar stale-while-revalidate caching strategy — show cached data instantly, then refresh in the background.

components/UserProfile.tsx

'use client';
import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function UserProfile({ id }: { id: string }) {
  const { data, isLoading } = useSWR(`/api/users/${id}`, fetcher);
  if (isLoading) return <p>Loading...</p>;
  return <p>{data.name}</p>;
}

17. Server Actions and State đŸ–Ĩī¸

Server Actions let you mutate server state directly from a form or event handler without manually writing a Route Handler, and pair naturally with revalidatePath or revalidateTag to keep the UI in sync.

app/actions.ts

'use server';
import { revalidatePath } from 'next/cache';

export async function addTodo(formData: FormData) {
  const title = formData.get('title');
  await db.todo.create({ data: { title } });
  revalidatePath('/todos');
}

18. Optimistic Updates ⚡

React's useOptimistic hook lets you update the UI immediately, before a Server Action finishes, then reconciles with the real result once it resolves — making mutations feel instant.

components/TodoList.tsx

'use client';
import { useOptimistic } from 'react';

export function TodoList({ todos, addTodo }: { todos: Todo[]; addTodo: (t: string) => Promise<void> }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (state, newTodo: string) => [
    ...state,
    { id: 'temp', title: newTodo },
  ]);

  async function handleAdd(title: string) {
    addOptimisticTodo(title);
    await addTodo(title);
  }

  return <ul>{optimisticTodos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}

19. Form State 📝

React's useFormState (now useActionState) connects a Server Action's return value directly to component state, ideal for surfacing validation errors after submission.

components/SignupForm.tsx

'use client';
import { useActionState } from 'react';
import { signup } from '@/app/actions';

export function SignupForm() {
  const [state, formAction] = useActionState(signup, { error: null });
  return (
    <form action={formAction}>
      <input name="email" />
      {state.error && <p>{state.error}</p>}
      <button type="submit">Sign up</button>
    </form>
  );
}

20. Authentication State 🔐

The currently logged-in user is a special case of server state, best read via a session helper on the server and passed down, rather than duplicated into a separate client-side store.

app/layout.tsx

import { getSession } from '@/lib/session';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await getSession();
  return <html><body><UserProvider user={session?.user}>{children}</UserProvider></body></html>;
}

21. Theme State 🎨

Theme preference (light/dark) is a classic example of state that benefits from persistence — it should survive a page reload, typically via a cookie or localStorage, read before the first paint to avoid flicker.

components/ThemeToggle.tsx

'use client';
import { useTheme } from '@/context/ThemeContext';

export function ThemeToggle() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>Switch to {theme === 'light' ? 'dark' : 'light'}</button>;
}

22. Persistent State 💾

Persistent state survives beyond a single page load — stored in localStorage, cookies, or a database — as opposed to ephemeral state that resets on refresh.

store/persistedCart.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useCartStore = create(
  persist(
    (set) => ({ items: [] as string[], addItem: (item: string) => set((s) => ({ items: [...s.items, item] })) }),
    { name: 'cart-storage' }
  )
);

23. State Hydration 💧

Hydration is the process where React attaches event handlers to server-rendered HTML on the client. State initialized differently on the server versus the client causes a hydration mismatch error.

Warning

Avoid reading browser-only values (like window or localStorage) directly during the initial render of a Client Component — initialize with a safe default and update inside useEffect instead.

24. State Synchronization 🔗

When the same data is needed in multiple places — a global store, the URL, and the server — keeping them all in sync is often the hardest part of state management. A single, clear source of truth per piece of data avoids conflicting updates.

  • Prefer deriving state rather than duplicating it in multiple stores.
  • Use revalidateTag/revalidatePath to keep server state and cached data aligned after mutations.
  • Treat the URL as the source of truth for shareable filters, not a separate client store.

25. Performance Optimization đŸŽī¸

  • Split global stores into smaller, focused pieces to limit re-renders (atomic libraries like Jotai help here naturally).
  • Memoize expensive derived state with useMemo rather than recomputing on every render.
  • Keep frequently changing state (like scroll position) out of Context to avoid cascading re-renders.
  • Let server state libraries (TanStack Query, SWR) handle caching instead of manually duplicating fetched data into another store.

26. Choosing the Right State Solution 🧭

ScenarioRecommended Approach
Single component, simple valueuseState
Complex, related state transitionsuseReducer
Small-to-medium global client stateZustand or Jotai
Large app, strict predictability needsRedux Toolkit
Data from an API or databaseTanStack Query or SWR
Shareable filters, pagination, sortURL state (searchParams)

27. Best Practices ✅

  1. Default to Server Components and server state; add client state only where interactivity truly requires it.
  2. Keep state as local as possible — lift it up only when multiple components genuinely need to share it.
  3. Store shareable, bookmarkable state in the URL rather than a client store.
  4. Let dedicated libraries (TanStack Query, SWR) own server-state caching instead of reinventing it manually.

28. Common Mistakes đŸšĢ

Common State Management Mistakes
Duplicating server data into client state instead of using a caching library
Reading localStorage during initial render, causing hydration mismatches
Over-centralizing state
Forgetting to revalidate cached server state after a mutation
Putting purely local UI state (like a single toggle) into a global store
Wrapping the whole app in one large Context that re-renders everything

29. Frequently Asked Questions ❓

Often less than before — many use cases that once needed Redux are now handled by server-rendered data plus lightweight libraries like Zustand for the remaining client state.

No — Server Components render once on the server and have no useState or interactivity; any stateful, interactive piece must be a Client Component.

No — URL state is ideal for shareable, navigable values, but transient UI state (like a modal being open) usually doesn't belong in the URL.

30. Summary 📚