Overview

State management is the most over-engineered surface in a React codebase. Most apps need three tiers: local component state, URL state, and a small client store for the few values that genuinely span the tree. Server data is not client state; fetch it through Suspense or React Query and read it where it renders. This page covers the decision tree, the rule of three for lifting, and the libraries to reach for in a React 19 codebase.

Default to local state

Put state in the component that uses it. A useState call inside a leaf component is the cheapest, fastest, most testable form of state in React. It does not re-render siblings, it does not require a provider, and it disappears when the component unmounts.

function SearchBox() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Pre-emptively hoisting state to a parent “in case” is a smell. A Context that wraps the whole app and stores a single component’s state is the same smell with extra steps.

Apply the rule of three before lifting

Lift state up only when three or more components share it, or when two siblings need to read and write the same value. Two components can pass a callback. Three is the threshold where prop-drilling starts to hurt; that is when the state moves up.

// Two consumers: callback is fine.
<Parent>
  <Display value={v} />
  <Editor onChange={setV} />
</Parent>
 
// Three or more: lift to a shared owner or a store.

When you lift, lift to the lowest common ancestor, not the root. A store at the root is rarely the right answer.

Put shareable state in the URL

If two users would expect to share a link and see the same view, the state belongs in the URL. Search queries, filters, sort order, pagination, selected tab, modal open state on permalinkable views. Reach for useSearchParams in Next.js, nuqs for typed search params, or a route segment for deeper structure.

"use client";
import { useSearchParams, useRouter } from "next/navigation";
function Filter() {
  const params = useSearchParams();
  const router = useRouter();
  const status = params.get("status") ?? "all";
  const setStatus = (s: string) => router.replace(`?status=${s}`);
  return <select value={status} onChange={(e) => setStatus(e.target.value)}>{/* ... */}</select>;
}

The URL is free state: the back button works, deep links work, refresh works, server components can read it. Picking it over useState is almost always right.

Prefer Zustand or Jotai over Redux for new code

For client globals that genuinely span the tree (auth user, theme, current org, command palette open state, multi-step wizard progress), reach for a focused store. Zustand and Jotai are the right choice for new React 19 code; Redux Toolkit remains fine if a codebase is already on it.

import { create } from "zustand";
 
interface CartStore {
  items: CartItem[];
  add: (item: CartItem) => void;
  remove: (id: string) => void;
}
 
export const useCart = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
}));

Read the store with a selector hook, not the whole state. useCart((s) => s.items.length) re-renders only when the count changes. See react-performance for the selector pattern.

Use Context for low-frequency globals only

useContext is right when the value changes rarely and many components read it: the current user, the theme, the current locale, a feature-flag map. It is wrong when the value changes often: every consumer re-renders on every change, regardless of whether it reads the changed field.

const ThemeContext = createContext<"light" | "dark">("light");
// changes when the user toggles the theme: low frequency, fine in context.
 
const MousePosContext = createContext<{ x: number; y: number }>({ x: 0, y: 0 });
// changes 60 times a second: never put this in context.

When a context value is an object, memoize it; otherwise every render publishes a new identity and every consumer re-renders. If the value is high-frequency, move it to a store with selectors.

Keep server data out of client state

A row from the database is not React state. Fetch it through a server component, a use() call in a client component, or a query library (TanStack Query, SWR). Storing server data in useState or Zustand drifts: the local copy goes stale, the server changes, the UI lies.

// Wrong: mirror server data into client state.
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []);
 
// Right: read it where it renders, with Suspense.
const user = use(fetchUser());

Server-derived client state (an optimistic update, a draft form value) is fine; the source of truth still lives on the server. See react-server-components and react-suspense.

Reach for useReducer when transitions are non-trivial

When several pieces of state move together and the next state depends on the previous, collapse them into one reducer. The transition function is pure and testable; the component renders the result.

type State = { status: "idle" | "loading" | "ready" | "error"; data?: Data; error?: string };
type Action = { type: "fetch" } | { type: "ok"; data: Data } | { type: "fail"; error: string };

Three useState hooks that update in lockstep are one reducer wearing a costume.