State Management
Local, URL, server, global — pick the right home for each piece of state.
State Management
The hardest part of state management isn't choosing between Redux, Zustand, or TanStack Query. It's deciding where each piece of state belongs in the first place. Once a piece of state is in the right home, the library to manage it almost picks itself.
Analogy
Imagine four filing cabinets in a busy office. The first sits on each employee's desk and holds whatever sticky notes they need that day; nobody else cares (local state). The second is the receptionist's binder of "which floor is which department on" — anyone walking in needs the same answer (global client state). The third is the central records vault: payroll, contracts, customer history. Multiple people read from it, multiple people write to it; it's the source of truth (server state). The fourth is the bulletin board outside the building — anything pinned there is shareable, bookmarkable, and visible to whoever walks past (URL state). The mistake people make isn't owning all four cabinets; it's putting the company's tax records on a sticky note.
The four homes for state
1. Local component state
Owned by exactly one component. Disappears when that component unmounts.
function CommentBox() {
const [draft, setDraft] = useState(""); // typing in a draft
const [submitting, setSubmitting] = useState(false);
// ...
}
Use it for: form drafts, hover state, modal open/closed, accordion expanded state, anything that resets to default when the component remounts.
The classic React rule: lift state up to the lowest common ancestor of every component that needs it. But only as far as needed — don't lift everything to the root.
2. URL state
Lives in the address bar — query string, path params, hash. Survives reload. Shareable. Bookmarkable.
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get("sort") ?? "newest";
const page = Number(searchParams.get("page") ?? "0");
Use it for: filters, sort order, pagination, the active tab in a tabbed interface, the selected row in a master/detail view, the open dialog if it represents a real "page" (like an item-detail modal).
The litmus test: could a user share this URL with a teammate and expect to see the same thing? If yes, it's URL state.
3. Server state
Source of truth lives elsewhere — Postgres, S3, an upstream API. The UI is a cache plus mutations.
const { data: orders, isLoading } = useQuery({
queryKey: ["orders", { sort }],
queryFn: () => fetch(`/api/orders?sort=${sort}`).then((r) => r.json()),
});
const { mutate } = useMutation({
mutationFn: (id: string) => fetch(`/api/orders/${id}`, { method: "DELETE" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
});
Server state has its own concerns no client-only library handles natively:
- Caching — don't refetch on every navigation
- Deduping — three components asking for the same data should make one request
- Refetch on focus / reconnect — stale data is cheap, wrong data is expensive
- Invalidation — after a mutation, mark related queries stale
- Optimistic updates with rollback on failure
This is why TanStack Query and SWR ate Redux's lunch for server state. Redux can store the data, but it doesn't know the data came from a server, so you write all the cache plumbing yourself. TanStack Query was designed for the problem.
4. Global client state
Read by many components, lives only in the browser, isn't a snapshot of server data.
Examples:
- The current authenticated user (read by chrome, comment box, settings, modals)
- Theme (light / dark / system)
- Feature flags resolved on boot
- A draft in progress that needs to survive route changes
- A shopping cart in an SSR app where the server doesn't know it yet
import { create } from "zustand";
interface AuthStore {
user: User | null;
setUser: (u: User | null) => void;
}
export const useAuth = create<AuthStore>((set) => ({
user: null,
setUser: (u) => set({ user: u }),
}));
Reach for this last. Most things you might put here belong in URL or server state. A modern app might have under 200 lines of Zustand for state that's truly global-client.
The "lift state up" rule and its limits
When two siblings need the same state, the textbook React answer is to lift it to their parent. This works beautifully for state that's local to a small subtree.
It breaks when:
- The state has to live above the routing layer (auth, theme).
- The state crosses many subtrees (a shopping-cart counter in the chrome plus a checkout view).
- The state changes on the server independently (orders, notifications).
When lifting hits these walls, don't keep lifting. Move the state to the right home — URL, server, or a small global-client store. Lifting to a root provider only to drill props seven levels back down is the worst of both worlds.
Why TanStack Query / SWR replaced Redux for server state
Pre-2020 React app: Redux store with orders.byId, orders.allIds, orders.isLoading, orders.error, plus the saga / thunk that orchestrates fetch + retry + invalidation. Hundreds of lines for one resource.
Post-2020: a useQuery hook with the URL and a query key. Caching, deduping, refetch-on-focus, invalidation, mutation pairing — all default. Redux still works for true client state, but the primary reason most apps reached for Redux was server data, and that need vanished.
- // 200 lines of Redux setup, actions, reducers, sagas, selectors
+ const { data, isLoading } = useQuery({
+ queryKey: ["orders"],
+ queryFn: fetchOrders,
+ });
When global client state is right
Three signals:
- The state is purely client (not from any server).
- It's read by components in unrelated subtrees.
- Putting it in the URL would leak something private (auth token) or pollute the address bar (theme, ephemeral UI prefs).
For everything else — there's a better home.
Picking a tool
| State type | Default tool | When to upgrade |
|---|---|---|
| Local | useState / useReducer |
useReducer when 3+ related fields change together |
| URL | useSearchParams (Router) / nuqs |
nuqs when you want type-safety + serialisation per param |
| Server | TanStack Query / SWR | Apollo / urql for GraphQL with normalised cache |
| Global client | Zustand | Redux Toolkit for very large teams that need formal action audit trails |
Match the tool to the home. Don't pick a hammer first and hunt for nails.
Common bugs
Server data in Redux. A component fetches a resource, dispatches LOAD_SUCCESS, the data sits in the store. Now you've forgotten to refetch when stale, two components dedupe by accident, and a mutation in component A doesn't invalidate the cached list in component B. Move it to TanStack Query.
Filters in useState. The user shares the URL; their teammate sees the unfiltered view. The user reloads the page; their filters reset. Move filters to URL state.
Auth user in URL. Tokens leak into bookmarks, browser history, server logs. Auth state is global-client (or kept in an httpOnly cookie). Never URL.
Redundant state. Storing both items and itemCount (when count is just items.length). Storing both a selectedId and a selectedItem. Pick one source of truth and derive the other.
Effects that sync state. Reaching for useEffect to "synchronise" two pieces of state usually means one of them shouldn't exist. Derive instead of synchronise.
Practical checklist
- For each piece of state, ask: who reads it? who writes it? where does it come from?
- If the answer involves "the server", use TanStack Query / SWR — not local state, not Redux.
- If the answer involves "shareable URLs", use URL state — not local state.
- If the answer is "one component", keep it local. Lift only when a sibling needs it.
- Reach for global client state only when URL and server don't fit.
- Derive instead of synchronising. The fewer pieces of independent state, the fewer bugs.
Tools in the wild
6 tools- libraryTanStack Queryfree tier
Server-state for React/Vue/Solid/Svelte; caches, dedupes, refetches, invalidates. The default in 2024+.
- librarySWRfree tier
Vercel's lightweight server-state hook. Smaller than TanStack Query; same idea, fewer features.
- libraryZustandfree tier
Tiny global-client store; hooks-first, no boilerplate, replaces Redux for most apps.
- libraryRedux Toolkitfree tier
Modern Redux, much less boilerplate than 2016 Redux. Still common in large enterprise codebases.
- libraryReact Router useSearchParamsfree tier
URL-as-state for SPA routes — read and write search params with the same ergonomics as useState.
- librarynuqsfree tier
useState-shaped hook backed by URL search params; type-safe, debounced, Next.js-friendly.