State Management in React and Next.js: A Complete Guide

Imad Attif, Sr. Frontend Engineer
14 min read
Mar 27, 2026
"Which state management library should I use?" is the most asked and least useful question in React. It's the wrong question because it assumes all state is the same kind of thing, and the entire art of state management in React is realizing it isn't.
Ask instead: what kind of data is this? Answer that, and the tool picks itself. This guide gives you that classification, shows where each kind of state should live in a modern React or Next.js app, and covers the patterns that come up once the basics are placed: optimistic updates, URL state, and what to do when client state gets genuinely big.
The decision guide up front, before the reasoning:
- Data from your backend is server state. It belongs to a data-fetching cache (React Query, SWR, Relay) or, in Next.js, to Server Components. It does not belong in useState or a global store.
- Filters, tabs, pagination, "which modal is open" often belong in the URL, not in memory.
- State one component cares about is useState. Start here; leave only when forced.
- Client state shared across distant components goes in Context if it rarely changes (theme, locale), or a small store library (Zustand, Jotai) if it changes often.
- Form state belongs to the form (uncontrolled inputs, React Hook Form, or server actions), not to a global store.
- State that must survive a reload goes to localStorage if tiny, IndexedDB if big.
If that list already feels obvious, skim to the patterns in the second half. If it doesn't, the reasoning follows.
What kinds of state does an app actually have?
A useful classification asks two questions about any piece of data: what class is it, and how does it behave?
Three classes:
- Server data. Anything fetched from a backend: the user's profile, the product list, the messages in a thread. You don't own it; the server does. Your copy is a snapshot that starts going stale the moment it arrives.
- UI state. Ephemeral, client-owned interaction state: which dropdown is open, what's typed in the search box, which rows are selected.
- App configuration. The user's chosen theme, locale, font size. Client-owned like UI state, but long-lived and usually persisted.
Three behavioral properties:
- Access level. Does one component care, or do many, far apart in the tree?
- Read/write frequency. A value updated on every keystroke or every websocket message has different needs than one updated once per session.
- Size. Ten booleans and ten thousand chat messages are both "state," and nothing that works for the first survives the second.
Most state management pain comes from ignoring the first classification: treating server data as if it were client state. So that's where a modern setup starts.
Server state is not client state
Here's the pattern that quietly generated a decade of Redux boilerplate and a thousand stale-data bugs:
1// ❌ Server data hand-managed as client state2function Profile({ userId }) {3 const [user, setUser] = useState(null);4 const [loading, setLoading] = useState(true);5 const [error, setError] = useState(null);67 useEffect(() => {8 setLoading(true);9 fetchUser(userId)10 .then(setUser)11 .catch(setError)12 .finally(() => setLoading(false));13 }, [userId]);14 // ...15}
It works, once. Then requirements arrive: don't refetch if another component already has this user; refetch when the tab regains focus; dedupe two components mounting at once; handle the response for a userId the user has already navigated away from; invalidate after an edit. Each one is more hand-rolled cache logic, because that's what this is: server data on the client is a cache, and caches have well-known problems that shouldn't be re-solved per component.
Data-fetching libraries (React Query, SWR, or Relay in GraphQL codebases) exist to be that cache:
1// ✅ Server state managed as a cache2function Profile({ userId }) {3 const { data: user, isPending, error } = useQuery({4 queryKey: ['user', userId],5 queryFn: () => fetchUser(userId),6 });7 // deduping, staleness, refocus revalidation, retries: all handled8}
The mental shift: you stop storing server data and start declaring which server data this component needs. The library owns the copy, keyed by query, shared across every component that asks for it.
This one decision usually deletes most of what teams thought was their "state management problem." What's left (genuinely client-owned state) is small, and small tools handle it.
What changes in Next.js: most state moves off the client
The App Router pushes this logic one step further: for a lot of server data, the client shouldn't even hold the cache. Server Components fetch data on the server and send HTML; there's no client-side state for that data at all, nothing to go stale, nothing to hydrate a store with.
1// app/products/page.tsx: a Server Component2export default async function ProductsPage() {3 const products = await getProducts(); // runs on the server4 return <ProductList products={products} />;5}
Mutations follow with Server Actions, and instead of updating a client cache, you tell Next.js which server-rendered data is now stale:
1'use server';23export async function addProduct(formData: FormData) {4 await db.insert(products).values({ name: formData.get('name') });5 revalidatePath('/products'); // the Server Component re-renders with fresh data6}
So in a Next.js app, the server-state question becomes a split:
- Read-mostly pages (listings, articles, dashboards viewed more than manipulated): Server Components plus revalidation. No client state library involved.
- Highly interactive islands (a live-updating feed, an editor, anything with polling or optimistic interactions): a client cache like React Query still earns its place, inside the
'use client'boundary.
Both in one app is normal, and it mirrors what production codebases do. One large fintech app I studied runs exactly two client-side state systems: Relay as the server-state cache, and Jotai for everything client-owned, which turned out to be small enough to count on one hand (theme, an embed-detection flag, editor state). That ratio, an entire product with a handful of truly global client atoms, is typical once server state is handled properly, and it's the strongest argument that your global store should be tiny.
The most underrated store: the URL
Filters, sort order, pagination, search text, the open tab, sometimes even "is this modal open": teams reflexively put these in useState or a store, and then discover users can't share a link to a filtered view, the back button doesn't undo a filter, and a reload wipes everything.
That's because this state describes the location in the app, and the browser already has a store for location:
1'use client';23import { useRouter, useSearchParams, usePathname } from 'next/navigation';45function Filters() {6 const router = useRouter();7 const pathname = usePathname();8 const searchParams = useSearchParams();910 function setCategory(category: string) {11 const params = new URLSearchParams(searchParams);12 params.set('category', category);13 params.set('page', '1'); // reset pagination on filter change14 router.push(`${pathname}?${params}`);15 }16 // ...17}
And in Next.js, the payoff compounds: a Server Component page can read searchParams directly and fetch the filtered data server-side, so URL state and server state compose without any client store between them. Shareable, bookmarkable, back-button-friendly, reload-proof, for free.
The rule of thumb: if a user might want to share, bookmark, or back-button it, it's URL state.
Local state: useState is not a phase you grow out of
For state one component (or a small subtree) cares about, useState is the answer, and it stays the answer at every scale of app:
1function SearchBox() {2 const [query, setQuery] = useState('');3 const [isOpen, setIsOpen] = useState(false);4 // ...5}
When several pieces of local state update together (a multi-step wizard, a complex editor panel), useReducer groups the transitions. When a few siblings need the same value, lift it to the common parent. None of this needs a library, and the common failure is escalating too early: reaching for a global store because prop drilling passed through two components. Two levels of props is not a problem; it's just data flow you can see.
Shared client state: Context for slow, stores for fast
Sometimes state really is needed in distant corners of the tree. The choice between the built-in tool and a library comes down to write frequency, because of how each re-renders.
React Context re-renders every consumer whenever the value changes. For state that changes rarely (theme, locale, the current user object, feature flags), that's completely fine and no dependency is warranted:
1const ThemeContext = createContext<'light' | 'dark'>('light');For state that changes often, Context's all-consumers re-render becomes the performance problem, and that's what the small store libraries fix with subscription granularity: components re-render only for the slice they select.
1// Zustand: one store, components subscribe to slices2const useStore = create((set) => ({3 sidebarOpen: false,4 toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),5}));67// Only re-renders when sidebarOpen changes, not on any other store update8const sidebarOpen = useStore((s) => s.sidebarOpen);
1// Jotai: independent atoms, components subscribe to atoms2const themeAtom = atom<'light' | 'dark'>('light');3const composerAtom = atom({ content: '', pendingSuggestion: null });45const [theme, setTheme] = useAtom(themeAtom); // re-renders on theme only
Zustand models "one store, many slices" (Redux's shape, a fraction of its ceremony); Jotai models "many independent atoms" and composes well when unrelated features each own a bit of global state. Both are a few kilobytes, and honestly, for the amount of truly-global client state a well-factored app has left at this point, either is fine. Redux Toolkit remains reasonable in large codebases that want strict conventions and devtools-heavy debugging, but reaching for it by default in 2026 is inertia, not analysis.
One Next.js-specific caution: global stores and Server Components don't mix (a store is client-side by definition), so stores live behind 'use client' boundaries, and module-level store instances can leak between requests during SSR. Follow your library's Next.js setup guide (per-request store creation with a provider) rather than declaring a global singleton.
Optimistic updates: lying to the user, responsibly
When the user acts, waiting a network round-trip to show the result makes the app feel slow. The optimistic update pattern shows success immediately and reconciles afterward. It's a bet on the 99% case where the write succeeds, and its cost is owning the walk-back in the 1% where it doesn't.
React ships a primitive for the simple case:
1'use client';23function LikeButton({ post }) {4 const [optimisticLikes, addOptimisticLike] = useOptimistic(5 post.likes,6 (current, delta: number) => current + delta7 );89 async function like() {10 addOptimisticLike(1); // UI updates now11 await likePost(post.id); // server action; on error React reverts12 }1314 return <button onClick={like}>♥ {optimisticLikes}</button>;15}
With React Query, the same idea is explicit: snapshot the cache, apply the optimistic change, roll back on error, and always refetch to converge on the server's truth:
1useMutation({2 mutationFn: likePost,3 onMutate: async (postId) => {4 await queryClient.cancelQueries({ queryKey: ['post', postId] });5 const previous = queryClient.getQueryData(['post', postId]);6 queryClient.setQueryData(['post', postId], (p) => ({ ...p, likes: p.likes + 1 }));7 return { previous }; // snapshot for rollback8 },9 onError: (err, postId, ctx) => {10 queryClient.setQueryData(['post', postId], ctx.previous); // walk it back11 toast.error('Could not like the post');12 },13 onSettled: (postId) =>14 queryClient.invalidateQueries({ queryKey: ['post', postId] }),15});
Two honest notes. First, the failure path is a product decision, not just code: "un-do the checkmark and toast an error" is easy for a like, genuinely hard for a sent message. Design the walk-back before shipping the optimism. Second, be careful with "saved" indicators: much autosave UX reports success when data reached a local cache, not the server, which is why a document can look saved on one device and be missing on another. Say what's true.
When client state gets big: normalize, then offload
Most apps never hit this section. Chat apps, editors, and dashboards with thousands of live entities do, and two database ideas apply directly in the browser.
Normalize by ID. Nested state (conversations containing message arrays containing author objects) makes every update a deep traversal and duplicates entities everywhere. Restructure it like tables, entities keyed by ID, references by ID:
1// ❌ Nested: updating one author's name touches every conversation2{ conversations: [{ id: 'c1', messages: [{ id: 'm1', author: {...} }] }] }34// ✅ Normalized: every entity is one O(1) lookup, stored exactly once5{6 users: { 'u1': { id: 'u1', name: 'Sam' } },7 messages: { 'm1': { id: 'm1', authorId: 'u1', text: '...' } },8 conversations: { 'c1': { id: 'c1', messageIds: ['m1'] } },9}
If you use a normalized cache (Relay does this automatically; React Query can be structured this way), you're already getting this. If you hand-roll a big store, this shape is the difference between O(1) updates and quadratic churn.
Offload what's inactive. Runtime memory is one shard; the disk is another. When state outgrows RAM comfort (the classic case: a messenger holding every conversation), keep active entities in memory and move inactive ones to IndexedDB, swapping them back on demand. It's sharding, in the browser.
Storage choice matters here: localStorage is synchronous and blocks the main thread, so it's for small, rarely-touched values (a theme preference), and never for anything read or written frequently. IndexedDB is asynchronous, stores real objects, and holds gigabytes, which makes it the only sane choice for bulk state, ideally accessed from a web worker so even serialization stays off the main thread.
FAQ
Do I still need Redux in 2026? Usually not. Its historical job (holding server data globally) is done better by React Query/SWR/Relay or Server Components, and what's left of global client state fits Context, Zustand, or Jotai. Redux Toolkit remains defensible for large teams that value its conventions and devtools.
Zustand or Jotai or Context? Context for rarely-changing values, no library needed. For frequently-changing shared state: Zustand if you think in one store with slices, Jotai if you think in independent atoms. They solve the same problem; team taste can decide.
Do I need React Query if I'm using Next.js Server Components? Not for read-mostly pages; fetch in Server Components and revalidate after mutations. Add React Query inside client boundaries that are genuinely interactive: polling, infinite scroll, optimistic-heavy UIs, websocket-adjacent data.
Is prop drilling bad? Passing props two or three levels is normal, visible data flow. It becomes a smell when intermediate components that don't use a value must know about it across many levels; that's when Context or a store earns its complexity.
Where should form state live? In the form. Uncontrolled inputs plus a submit handler or server action cover most cases; React Hook Form covers complex validation. Putting per-keystroke form state in a global store is the most common self-inflicted performance wound in React apps.
Summary
State management gets simple when you classify before you reach for tools:
- Server data is a cache, not state. Give it to React Query, SWR, or Relay, or better, keep it in Server Components and never ship it as client state at all.
- Shareable state belongs in the URL. Filters, pagination, tabs: searchParams compose beautifully with server-side fetching.
- Local first. useState and useReducer are the permanent default, not the beginner tier.
- Global client state should be embarrassingly small. Context for slow-changing values; Zustand or Jotai atoms for the fast-changing remainder. A whole product can run on a handful of atoms.
- Optimistic updates are a bet on the 99%. Take the bet, and design the 1% walk-back before you ship it.
- At scale, think like a database. Normalize entities by ID for O(1) access, and shard inactive bulk state out of RAM into IndexedDB.
The question was never which library. It's which kind of state, and after that, the answers are short.