Overview

Most apps need three tiers of client state: component state, URL state, and a small store for the few values that span the tree. Server data is a separate concern; read it where it renders instead of copying it into state. This page gives the decision order, the rule of three for lifting, and library picks for a React 19 codebase.

Default to local state

Put state in the component that uses it. A useState in a leaf component is the cheapest, most testable state: it does not re-render siblings, needs no provider, and disappears on unmount. Hoisting state “in case” and wrapping the app in a Context that holds one component’s state are the same mistake.

Lift on the third consumer, to the lowest common ancestor

Two components can share state with a callback. Move it up when a third component needs it or when two siblings must both read and write it. Lift to the lowest common ancestor, not the root; reach for a store when the ancestor is far above the consumers.

Put shareable state in the URL

If two users would expect a link to show the same view, the state belongs in the URL: search query, filters, sort, page, selected tab, a modal that has its own shareable URL. The back button, deep links, refresh, and Server Components all work for free. Use nuqs for typed search params.

"use client";
const params = useSearchParams();
const status = params.get("status") ?? "all";
const setStatus = (s: string) => router.replace(`?status=${s}`);

In Next.js, a Client Component that calls useSearchParams must sit inside a <Suspense> boundary when Cache Components is on, because search params exist only at request time.

Prefer Zustand or Jotai over Redux for new client globals

Use a focused store for values that truly span the tree: the auth user, theme, current organization, command palette, wizard progress. Redux Toolkit stays fine in a codebase already on it. Read with a selector so only the used slice re-renders.

import { create } from "zustand";
 
export const useCart = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
}));
 
const count = useCart((s) => s.items.length);

Under SSR, build the store per request with a factory and provide it through context. A module-level store on the server is shared by every request and leaks one user’s state into another’s, and Server Components must not read or write it.

Use context for low-frequency values only

Context suits values that change rarely and are read widely: current user, theme, locale, feature flags. Every consumer re-renders when the value changes, so keep mouse position, scroll offset, and form values out of it. When the value is an object, give it a stable identity (the compiler or useMemo), or each provider render re-renders all consumers. Move high-frequency state to a store with selectors; see react-performance.

Keep server data out of client state

A database row is not React state. Fetch it in a Server Component, pass a promise to a Client Component and unwrap it with use(), or use a query library (TanStack Query, SWR). A copy in useState or a store goes stale.

// Wrong: mirror server data into state.
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetchUser().then(setUser); }, []);
 
// Right: a server component passes the promise; a client component unwraps it.
const user = use(userPromise);

Never call use(fetchUser()) inside a Client Component render: it creates a new promise on every render, and React reports an uncached-promise error. Server-derived client state such as an optimistic update or a draft is fine as long as the server stays the source of truth. See react-server-components and react-suspense.