Watch a React codebase grow and you will see the same scene play out. Somewhere around the twentieth component, prop drilling starts to hurt, someone says "we need state management", and a library gets installed. The library is rarely the problem, and rarely the solution either. The real problem is that "state" is four different things wearing one name.
- Server data — products, posts, user profiles. It lives in your database. The copy in the browser is a cache, with all of a cache's problems: staleness, invalidation, refetching.
- UI state — is the modal open, which tab is active, what has the user typed so far. Born in a component, dies with the component. useState handles almost all of it.
- Shared client state — state that genuinely originates in the browser and is needed by distant parts of the tree: a shopping cart, an audio player, a multi-step form draft.
- URL state — the current filter, the search query, the page number. State that describes where the user is, which is exactly what URLs were built to do.
Sort your app's state into those four buckets and most of the "state management problem" evaporates before you open npm. The bulk of what Redux stores held between 2018 and 2021 was server data — lists fetched in useEffect, dispatched into a slice, and invalidated by hand or never. That category has been leaving the client store for years, first to TanStack Query and SWR, and now, in App Router projects, to the server itself.
warning
If your instinct on every fetch is still "put it in the store", that is the 2020 habit to unlearn. Server data in a global client store means you own caching, staleness, and invalidation by hand — three problems a Server Component or TanStack Query solves for free.
The Context API gets blamed for performance problems it never promised to avoid, because it keeps getting hired for a job it never applied for. Context is a way to make a value available to a subtree without threading props through every layer — dependency injection, in plainer words. It has no selectors, no subscriptions, no way to say "only re-render me when this one field changes". When a provider's value changes, every component that reads that context re-renders. All of them.
That mechanic makes Context exactly right for a narrow set of values: ones that change rarely and that should repaint everything when they do. Theme. Session. Locale. When the user switches to dark mode, you want every consumer to re-render — that is the feature, not the bug.
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}Notice what is not here: no reducer, no middleware, no selectors. Two values, one toggle, and a hook that fails loudly when used outside the provider. For theme, session, and locale, this is the whole job. Adding a library on top of it is decoration.
warning
The classic Context trap: a parent that re-renders often passes a fresh object literal as the provider value. value={{ user, settings }} is a new reference on every render, so every consumer re-renders even when nothing inside changed. Memoize the value with useMemo, or split it into one context per concern.
When state really does originate in the browser and really is needed across the tree — the cart is the canonical example — Zustand is my default, and the entire store fits on one screen.
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
}
export const useCartStore = create<CartStore>()((set) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
const items = existing
? state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
)
: [...state.items, { ...item, quantity: 1 }];
return { items };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
}));There is no provider because there is no need for one. The store is a plain object living in module scope; create wires up a subscription mechanism and hands you a hook. Components subscribe through selectors, and that is where the performance story lives: a component re-renders only when the slice it selected actually changes.
"use client";
import { useCartStore } from "@/stores/cart-store";
export function CartBadge() {
const count = useCartStore((s) =>
s.items.reduce((sum, i) => sum + i.quantity, 0)
);
return <span aria-label={`${count} items in cart`}>{count}</span>;
}The badge re-renders when the item count changes and at no other time. Rename a product in the store, change a price — this component does not care. You get the fine-grained subscriptions that Redux needed react-redux and reselect to deliver, with zero setup. One caveat for App Router projects: a module-scope store is shared across requests during server rendering, so never put per-user server data into it. For client-born state like a cart that starts empty or hydrates from localStorage, this never bites.
Redux is not dead, and pretending it is makes for bad advice. Redux Toolkit removed most of the boilerplate that made 2017 Redux miserable, and the pattern underneath — every change is a dispatched action, reducers fold actions into state — still buys real things in the right codebase.
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
const cartSlice = createSlice({
name: "cart",
initialState: [] as CartItem[],
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, "quantity">>) {
const existing = state.find((i) => i.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.push({ ...action.payload, quantity: 1 });
}
},
removeItem(state, action: PayloadAction<string>) {
return state.filter((i) => i.id !== action.payload);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;The slice looks heavier than the Zustand store, and it is — though less than it appears, because RTK runs Immer underneath, so the "mutations" in addItem are translated into immutable updates for you. What the ceremony buys is an action log. Every change to your state becomes a named, serializable event, which is what makes the Redux DevTools time-travel debugger work, what makes middleware composable, and what keeps complex state legible to a team of fifteen.
- State that is genuinely event-sourced — a collaborative editor, an undo/redo stack, anything where "what happened, in what order" is the domain itself.
- Debugging that depends on replaying the exact action sequence that broke production — time travel is not a gimmick when you need it.
- Large established codebases where Redux is already load-bearing and the team's shared conventions are an asset, not debt.
And the honest counterpart: if your global state is a cart, a theme, and a couple of flags, RTK is ceremony. You will write slices, typed dispatch hooks, and a store configuration to do what Zustand does in one file. Choosing Redux for a new small app in 2026 is usually a team-familiarity decision rather than a technical one — which is a legitimate reason, as long as you name it out loud.
Filters, search queries, sort orders, pagination — the most common "shared state" in a typical app — should usually live in no store at all. They belong in the URL. A filter held in a search param is shareable (paste the link, see the same view), survives a refresh, plays correctly with the back button, and in the App Router can be read by Server Components, so the server renders the filtered list directly. You pay nothing for any of this.
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function CategoryFilter({ categories }: { categories: string[] }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const active = searchParams.get("category") ?? "all";
function select(category: string) {
const params = new URLSearchParams(searchParams);
if (category === "all") params.delete("category");
else params.set("category", category);
router.replace(`${pathname}?${params.toString()}`);
}
return (
<div role="group" aria-label="Filter by category">
{["all", ...categories].map((c) => (
<button key={c} onClick={() => select(c)} aria-pressed={c === active}>
{c}
</button>
))}
</div>
);
}router.replace updates the URL without stacking a history entry per click; switch to push when each filter change should be a back-button stop. The component holds no state of its own. The URL is the source of truth, which means the page on the server can read the same parameter and fetch accordingly — which is exactly where the next section picks up.
Here is the shift that shrank the whole debate. In an App Router project, a page is a Server Component by default. It can talk to your database or API directly, await the result, and pass plain props down. The fetched data never enters a client store because it never needed to be client state at all.
import { ProductGrid } from "./product-grid";
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const { category } = await searchParams;
const res = await fetch(
`https://api.example.com/products?category=${category ?? "all"}`,
{ next: { revalidate: 60 } }
);
const products = await res.json();
return <ProductGrid products={products} />;
}The page reads the URL state from the previous section, fetches on the server with a sixty-second revalidation window, and hands the result to a client component as props. There is no loading flag to manage, no cache slice, no hydrating fetched data into a store. Compare it to the 2020 version of the same page: a useEffect, a dispatch, a loading boolean, an error boolean, and a stale copy of the product list still sitting in Redux three routes later.
Client stores still matter — for state that is born in the browser. The cart, the half-finished form, the playback position, optimistic UI while a mutation is in flight. And when client components need to refetch, poll, or mutate server data interactively, TanStack Query remains the right tool, now in the narrower role of a server-cache manager rather than "the place data goes". The change is not that stores died. It is that the default flipped: data starts on the server and stays there unless interaction demands otherwise.
The whole decision compresses into one pass over your app:
- Server data — fetch in a Server Component and pass props down; reach for TanStack Query when the client must refetch, poll, or mutate it interactively.
- Local UI state — useState or useReducer in the component. No library.
- Rarely-changing app-wide values (theme, session, locale) — Context, memoized value, done.
- Filters, search, pagination, anything a user might want to share — URL search params.
- Client-born state shared across the tree (cart, drafts, players) — Zustand with selectors.
- Event-sourced state, time-travel debugging, or a large team already fluent in Redux — Redux Toolkit.
The practical next step: open the project you are building right now, list every piece of state it holds, and label each one with its bucket. The first time I ran this exercise on one of my own apps, more than half of the global store turned out to be server data or URL state in disguise — code to delete, not migrate. Do that audit before you install anything. The best state management decision in 2026 is usually the library you did not add, and the evidence shows up where it counts: a smaller bundle, fewer loading flags, and a diff that is mostly red.
