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
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.
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
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
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
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
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
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 đ§
| Scenario | Recommended Approach |
|---|---|
| Single component, simple value | useState |
| Complex, related state transitions | useReducer |
| Small-to-medium global client state | Zustand or Jotai |
| Large app, strict predictability needs | Redux Toolkit |
| Data from an API or database | TanStack Query or SWR |
| Shareable filters, pagination, sort | URL state (searchParams) |
27. Best Practices â
- Default to Server Components and server state; add client state only where interactivity truly requires it.
- Keep state as local as possible â lift it up only when multiple components genuinely need to share it.
- Store shareable, bookmarkable state in the URL rather than a client store.
- Let dedicated libraries (TanStack Query, SWR) own server-state caching instead of reinventing it manually.
28. Common Mistakes đĢ
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.