Managing State in React in 2026: Server-First, Then Zustand
Half the state you used to keep in React now lives on the server. A 2026 decision framework: server data, URL state, local UI state, and when to reach for Zustand.
On this page
- The only distinction that matters: server state vs. client state
- RSC deleted a lot of your client state
- URL state: the global store you already shipped
- Client-fetched server data: TanStack Query, not a global store
- Local UI state: still just useState and useReducer
- Genuinely shared client state: reach for Zustand
- Why Redux is rarely the answer now
- The framework, as a checklist
- Further reading
The fastest way to fix a tangled React app in 2026 is to delete state, not add a better library to manage it. I recently inherited a Next.js dashboard with a 1,400-line Redux store, three slices for data that was fetched on every page load, and a useEffect that synced a filter into Redux so a sibling component could read it. We deleted the entire store. The data moved to the server and TanStack Query; the filter moved into the URL. The app got faster, the bundle shrank by roughly 40 KB gzipped, and a class of "stale data" bugs disappeared because there was no longer a second copy of the truth to drift.
That is the whole story of React state in 2026. The question stopped being "which global store" and became "does this state even belong on the client." Most of the time, it does not.
The only distinction that matters: server state vs. client state
Almost every state bug I debug traces back to one mistake: treating server data as if it were client state. They are different animals.
Server state is data you do not own. It lives in a database, it can change without your app knowing, and the copy in your component is a cache that is stale the moment it arrives. Think: the list of orders, the current user, a product's inventory count.
Client state is data your app fully owns and that has no source of truth anywhere else. Think: is this modal open, what did the user type into this not-yet-submitted input, which tab is active.
The old Redux-everything era blurred this line. You fetched orders in a thunk, dumped them into the store, and then spent the rest of your life fighting cache invalidation by hand. TanStack Query and React Server Components both exist because the industry finally admitted that server data needs caching, deduplication, revalidation, and request lifecycle handling — and a general-purpose client store gives you none of that for free.
Here is the decision tree I now apply to every piece of state:
| State | Source of truth | Tool |
|---|---|---|
| Data from your DB/API, rendered on first paint | Server | Server Component + fetch/ORM |
| Data from your API, fetched after interaction | Server (cached on client) | TanStack Query / SWR |
| Shareable / bookmarkable UI (filters, tabs, page) | The URL | searchParams |
| Ephemeral local UI (open/closed, form draft) | This component | useState / useReducer |
| Genuinely shared client-only state | The client | Zustand / Jotai |
| "We need Redux" | Almost never | reconsider the four rows above |
Work top to bottom. By the time you reach the bottom row, the amount of state left is usually tiny.
RSC deleted a lot of your client state
With React Server Components (stable in React 19, and the default in the Next.js App Router through Next.js 16), the data that used to require useEffect + useState + a loading flag now just... renders.
// app/orders/page.tsx — a Server Component. No useState, no useEffect, no loading state.
import { db } from "@/lib/db";
export default async function OrdersPage() {
const orders = await db.order.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
take: 50,
});
return (
<ul>
{orders.map((o) => (
<li key={o.id}>
#{o.id} — {o.customer} — ${(o.totalCents / 100).toFixed(2)}
</li>
))}
</ul>
);
}There is no client-side store here, no fetching state machine, and zero of this JavaScript ships to the browser. The three pieces of state you used to write by hand — data, isLoading, error — are handled by async/await and React's <Suspense> and error.tsx boundaries. For read-heavy pages, this is the single biggest reduction in client state you will ever make.
The trap: people see Server Components and try to make everything a Server Component, then hit the wall when they need an onClick. The rule is mundane. Fetch and render on the server; drop a "use client" component in only at the leaves where you need interactivity. Pass server data down as props.
URL state: the global store you already shipped
The most underused state container in React is the address bar. If a piece of state should survive a refresh, be shareable as a link, or work with the browser back button — filters, the active tab, pagination, a search query, a selected sort — it belongs in searchParams, not in a store.
In the App Router, the server reads searchParams directly and re-renders; the client updates it with useRouter and useSearchParams.
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback } from "react";
export function StatusFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const current = searchParams.get("status") ?? "all";
const setStatus = useCallback(
(status: string) => {
const params = new URLSearchParams(searchParams);
if (status === "all") params.delete("status");
else params.set("status", status);
// Update the URL without scrolling to top; the server re-renders with new params.
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams],
);
return (
<select value={current} onChange={(e) => setStatus(e.target.value)}>
<option value="all">All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
);
}The corresponding Server Component reads searchParams as a prop (it is a Promise in Next.js 15+, so you await it) and refetches. No useEffect to sync the filter, no store, no prop drilling. The URL is the single source of truth, and it is shareable for free. For anything beyond a handful of params with type-safe parsing, nuqs is worth the dependency, but plain URLSearchParams covers most cases.
Client-fetched server data: TanStack Query, not a global store
You cannot put everything on the server. Infinite scroll, optimistic mutations, polling, data behind a client-only interaction, fully client-rendered widgets — these fetch from the browser. The instinct from 2018 is to fetch into Redux. Do not. Use a dedicated server-cache library.
I default to TanStack Query (@tanstack/react-query, v5). SWR is a fine, lighter alternative; the mental model is the same. The TanStack Query docs are explicit that it is an "async state manager," not a generic store — and that distinction is the entire point. It gives you caching keyed by query, request deduplication, background refetching, stale-while-revalidate, retries, and pagination helpers that you would otherwise reimplement badly.
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
type Order = { id: string; customer: string; status: "open" | "closed" };
async function fetchOrders(): Promise<Order[]> {
const res = await fetch("/api/orders");
if (!res.ok) throw new Error("Failed to load orders");
return res.json();
}
export function OrdersWidget() {
const qc = useQueryClient();
const { data, isPending, isError } = useQuery({
queryKey: ["orders"],
queryFn: fetchOrders,
staleTime: 30_000, // treat data as fresh for 30s; no refetch storm on remount
});
const close = useMutation({
mutationFn: (id: string) =>
fetch(`/api/orders/${id}/close`, { method: "POST" }),
// Optimistic update: flip the UI immediately, roll back on failure.
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ["orders"] });
const prev = qc.getQueryData<Order[]>(["orders"]);
qc.setQueryData<Order[]>(["orders"], (old) =>
old?.map((o) => (o.id === id ? { ...o, status: "closed" } : o)),
);
return { prev };
},
onError: (_err, _id, ctx) => qc.setQueryData(["orders"], ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ["orders"] }),
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>Something went wrong.</p>;
return (
<ul>
{data.map((o) => (
<li key={o.id}>
{o.customer} — {o.status}{" "}
{o.status === "open" && (
<button onClick={() => close.mutate(o.id)}>Close</button>
)}
</li>
))}
</ul>
);
}That onMutate/onError/onSettled trio is the optimistic-update pattern you would have written hundreds of lines of Redux middleware to achieve. Here it is twelve lines, and rollback is correct by construction.
Local UI state: still just useState and useReducer
For state that belongs to one component and its children, nothing changed. A toggle, a draft input, a hover flag — useState. A small state machine with several related fields and transitions — useReducer. Resist the urge to "future-proof" by hoisting these into a global store. Local state that stays local is the easiest code in the building to reason about.
The one tool I tell people to be careful with is React Context. Context is a dependency-injection mechanism, not a state manager. When you put a frequently-changing value in a Context Provider, every consumer re-renders on every change, regardless of whether it reads the part that changed. Put your auth user, your theme, your query client in Context — values that rarely change. Do not put a counter, a text field, or live data in Context and expect it to scale. That misuse is responsible for more "why is my whole tree re-rendering" tickets than anything else.
Genuinely shared client state: reach for Zustand
After server data, URL state, and local state, what is left? Real, client-only state shared across distant components: a multi-step wizard's progress, a shopping cart before checkout, the open/closed state of a global command palette, selection state in a complex editor. This is the slice that justifies a global store — and it is small.
My default is Zustand (zustand, v5). The Zustand docs sell its main virtue honestly: it is a tiny (~1 KB) hook-based store with no provider boilerplate and no context re-render problem, because components subscribe to exactly the slice they select.
import { create } from "zustand";
type CartItem = { id: string; qty: number };
type CartState = {
items: CartItem[];
add: (id: string) => void;
remove: (id: string) => void;
total: () => number;
};
export const useCart = create<CartState>((set, get) => ({
items: [],
add: (id) =>
set((s) => {
const existing = s.items.find((i) => i.id === id);
return existing
? { items: s.items.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i)) }
: { items: [...s.items, { id, qty: 1 }] };
}),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((n, i) => n + i.qty, 0),
}));Consuming it is the key part — select narrowly so a component only re-renders when its slice changes:
"use client";
import { useCart } from "@/stores/cart";
// Re-renders ONLY when the count changes, not on every cart mutation.
export function CartBadge() {
const count = useCart((s) => s.items.length);
return <span>{count}</span>;
}Jotai is the equally good alternative if you prefer an atomic, bottom-up model over Zustand's single-store approach. Both solve the Context re-render problem; pick by taste. What neither should hold is server data — that is what TanStack Query is for.
Why Redux is rarely the answer now
I am not anti-Redux. Redux Toolkit is well-engineered, and a large team with deeply interconnected client-side logic and a need for strict time-travel debugging can still justify it. But the original reasons most apps reached for it have evaporated:
- "We need to share fetched data globally" → that is server state; use RSC or TanStack Query, which cache it properly.
- "We need predictable global state" → Zustand and Jotai are predictable, with a fraction of the boilerplate.
- "We need devtools" → Zustand ships a
devtoolsmiddleware that hooks into the same Redux DevTools extension.
The boilerplate-to-value ratio simply lost. When I greenfield a project in 2026, Redux is not in the dependency list, and I have not missed it.
The framework, as a checklist
For each piece of state, ask in order:
- Can the server own it? Render it in a Server Component. No client state at all.
- Should it be in the URL? Filters, tabs, pagination, search, sort →
searchParams. Shareable and refresh-proof for free. - Is it server data fetched on the client? TanStack Query or SWR. Never a hand-rolled store, never Redux.
- Is it local to one subtree?
useState/useReducer. Keep it there. - Is it genuinely shared client-only state? Zustand or Jotai, with narrow selectors.
- Still think you need Redux? Re-read steps 1–5. You probably don't.
Do this and your "state management" reduces to a small Zustand store, a few useState calls, some query keys, and a lot of searchParams. That is not a downgrade. That is what a 2026 React app is supposed to look like.
Further reading
- TanStack Query documentation — tanstack.com/query
- Zustand documentation — the official GitHub repository and docs site for
zustand - Next.js App Router and Server Components — nextjs.org/docs
- React documentation on
useReducer, Context, and Server Components — react.dev