State Management
Local and lifted state, prop drilling, context as a store and what it costs, external stores and selectors, the URL as state, server state with a query cache, optimistic updates and forms — with live React demos that count every re-render.
The 30-second version
"Should we use Redux?" is the wrong first question. Most state bugs come from data that lives in the wrong place: two copies that disagree, a whole page that re-renders on every keystroke, filters that vanish when you press Back, or a list from the server that's quietly out of date.
State is data that can change while the page is open and that the screen depends on: a counter, a search text, the logged-in user, a list of orders. When it changes, React re-renders: it calls your components again to work out the new screen.
So there isn't one kind of state; there are roughly five, and each has a natural home:
| Kind | Example | Natural home | Section |
|---|---|---|---|
| Local UI state | is this dropdown open? | the component's own useState | §02 |
| Shared client state | theme, cart, a multi-step wizard | lifted state, context, or a store | §02–§06 |
| URL state | search text, filters, page number | the address bar | §07 |
| Server state | the orders from your API | a query cache | §08–§09 |
| Form state | what's typed before Submit | inside the form | §10 |
Try sorting some real examples:
GET /api/ordersStart low, lift when needed
Shared ≠ global
Server data is a cache
Local state, and lifting it up
Local state is the default, and it's the best kind: only one component can change it, so when it's wrong you know exactly where to look. A dropdown's open flag, a hover, the text in a single input: all local.
function Dropdown({ options }) {
const [open, setOpen] = useState(false); // ← nobody else needs this
return (
<div>
<button onClick={() => setOpen((o) => !o)}>Sort by ▾</button>
{open && <ul>{options.map((o) => <li key={o}>{o}</li>)}</ul>}
</div>
);
}When a sibling needs it
Now a page has a <SearchBox> and a <ResultList> next to each other. The list must filter by what's typed in the box. If each keeps its own q (the query text) in useState, they can't talk: a component can only pass data down to its children, never sideways to a sibling. Try it, then flip the toggle:
SearchBox and ResultList each have their own q, and siblings can't read each other's state.With the state lifted, FruitPage owns q. It passes the value down to both children, and passes setQ down to the box so the box can change it. Data flows down as props; changes flow up through functions passed down.
function FruitPage() {
const [q, setQ] = useState(""); // ← one q, owned by the common parent
return (
<>
<SearchBox q={q} onChange={setQ} /> {/* can change it */}
<ResultList q={q} /> {/* can read it */}
<Footer />
</>
);
}
function SearchBox({ q, onChange }) {
return <input value={q} onChange={(e) => onChange(e.target.value)} />;
}Lifting gives you a single source of truth: one q, stored once. Two copies of the same data always drift apart eventually, so whenever you catch yourself keeping two things in sync with code, lift them into one.
The cost: re-renders spread with the state
Look at the counters in the lifted mode again. Every keystroke re-rendered FruitPage and all its children, including Footer, which uses nothing. That's not a bug: when a component re-renders, React re-renders its children too (How React Works §06 explains why). The higher the state lives, the more components each change re-renders.
So lift state only as high as needed, to the nearest common parent, and no higher. Keeping state close to where it's used is called colocation. It's the single most effective "performance optimization" in React, and it's free.
function App() {
const [q, setQ] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [hovered, setHovered] = useState(null);
// typing in a search box re-renders
// the header, the menu, the footer…
return <Layout q={q} setQ={setQ} menuOpen={menuOpen} /* … */ />;
}function App() {
return <Layout />; // no state needed here
}
function SearchPage() {
const [q, setQ] = useState(""); // only this page
/* … */
}
function Menu() {
const [open, setOpen] = useState(false); // only the menu
/* … */
}Prop drilling, and two ways out
Lifting state (§02) has a side effect: the owner can end up far above the component that shows the value. In between sit layout components that have to accept the prop and pass it along, like buckets passed hand to hand down a line. That's prop drilling.
Here's the same tree wired three ways. Watch which components have to mention user:
- <App>owns user
- <Layout>takes user only to pass it on
- <Header>never sees user
- <Sidebar>takes user only to pass it on
- <Nav>takes user only to pass it on
- <UserMenu>uses user
- <Main>never sees user
function App() {
const [user] = useState({ name: "Ana" });
return <Layout user={user} />;
}
function Layout({ user }) { // doesn't use it…
return (<><Header /><Sidebar user={user} /><Main /></>);
}
function Sidebar({ user }) { return <Nav user={user} />; } // …nor this
function Nav({ user }) { return <UserMenu user={user} />; } // …nor this
function UserMenu({ user }) { return <span>{user.name}</span>; }user; only two care. Rename the prop or add a second one, and you edit all five.Drilling isn't evil
Two or three levels of props are fine, and often the clearest option: the data flow is visible in the code, and a component's props tell you exactly what it depends on. Drilling becomes a problem when the in-between components multiply, change often, or are shared by many pages. Then every rename touches all of them.
Fix 1: composition
Most drilling happens because a layout component builds its children itself. Let it accept them instead. A component that renders {children} doesn't need to know what's inside, so the owner can pass user straight to the component that uses it. This approach is called composition.
function App() {
const [user] = useState({ name: "Ana" });
return (
<Layout sidebar={<UserMenu user={user} />}> {/* ← an element passed as a prop */}
<Dashboard user={user} /> {/* ← and as children */}
</Layout>
);
}
function Layout({ sidebar, children }) { // knows nothing about user
return (
<div className="grid">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}Composition costs nothing at runtime and keeps the data flow visible. It's the fix to try first.
Fix 2: context
When many components in different branches need the same value (the theme, the current user, the language), composition gets awkward. Context lets a component provide a value once and any component below read it directly:
const ThemeContext = createContext("light"); // 1. create it (with a default)
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext value={theme}> {/* 2. provide a value */}
<Page />
</ThemeContext>
);
}
function Button() {
const theme = useContext(ThemeContext); // 3. read it anywhere below
return <button className={theme}>OK</button>;
}Context solves the "passing" problem. It's tempting to go one step further and put all shared state in one big context, as a home-made store. The next section measures what that costs.
Context as a store, and what it costs
A common pattern is to combine context with useReducer and call it a store. If you've only used useState, useReducer needs three new words, so here they are first (more in React Hooks §04).
With useState you set the new value yourself. With useReducer you describe what happened and one function decides the new state:
- An action is a plain object describing what happened:
{ type: "bump", i: 7 }means "seat 7 was clicked". - A reducer is a function
(state, action) => newStatethat holds all the rules in one place. - dispatch is the function
useReducergives you instead of a setter.dispatch(action)callsreducer(currentState, action), and whatever it returns becomes the new state.
Here's the reducer for a seat board (48 seat counters plus a title):
const initialBoard = { title: "Seat map", seats: Array(48).fill(0) };
function boardReducer(state, action) {
switch (action.type) {
case "bump": {
const seats = state.seats.slice(); // copy the array…
seats[action.i] = seats[action.i] + 1; // …change the copy
return { ...state, seats }; // ← always a NEW state object
}
case "title":
return { ...state, title: action.title };
default:
return state; // unknown action: no change
}
}Now put state and dispatch into a context so any component can use them:
const BoardContext = createContext(null);
function BoardProvider({ children }) {
const [state, dispatch] = useReducer(boardReducer, initialBoard);
const value = useMemo(() => ({ state, dispatch }), [state]); // ← new object when state changes
return <BoardContext value={value}>{children}</BoardContext>;
}
function Seat({ i }) {
const { state, dispatch } = useContext(BoardContext);
const value = state.seats[i]; // uses one number…
return <button onClick={() => dispatch({ type: "bump", i })}>{value}</button>;
}Step through one click on seat 7, from the action to the re-renders:
1function boardReducer(state, action) {2 switch (action.type) {3 case "bump": {4 const seats = state.seats.slice(); // copy the array5 seats[action.i] = seats[action.i] + 1; // change the copy6 return { ...state, seats }; // a new state object7 }8 case "title":9 return { ...state, title: action.title };10 default:11 return state; // unknown action: no change12 }13}1415// inside <Seat i={7}>16<button onClick={() => dispatch({ type: "bump", i: 7 })} />
{ type: "bump", i: 7 }, and hands it to dispatch.It works, and for a small app it's fine. But look at Seat: it uses one number and reads the whole value. React has no way to know which part a component cares about. When any seat changes, state is a new object, so value is a new object, so every Seat re-renders. Click one seat and count:
performance.now(), from the click to the end of React's commit.One click, about fifty re-renders, and every component here is wrapped in memo. memo only skips a re-render when the props are unchanged; a context change goes around it. Typing in the title box is worse: each keystroke re-renders all 48 seats, which don't show the title at all.
Is that actually slow?
Usually not, at this size. A re-render means React called the function; if the output is the same, nothing on the page changes (How React Works §06). We measured these fifty tiny components re-rendering in about 1.6 ms in a production build (this machine, headless Chromium). It becomes a problem when the readers are many, expensive (big lists, charts), or the value changes very often (every keystroke, every mouse move, a timer). Then the work adds up to visible lag, and the cure is to re-render fewer components, not to make each one faster.
Making context cheaper
Split by change rate
ThemeContext, UserContext, CartContext. A component re-renders only for the contexts it reads.Split state from dispatch
dispatch never changes, so put it in its own context. Components that only send actions (buttons) stop re-rendering.Keep busy state local
const BoardState = createContext(null);
const BoardDispatch = createContext(null);
function BoardProvider({ children }) {
const [state, dispatch] = useReducer(boardReducer, initialBoard);
return (
<BoardDispatch value={dispatch}> {/* dispatch is stable: readers never re-render */}
<BoardState value={state}>{children}</BoardState>
</BoardDispatch>
);
}None of these fixes lets a Seat say "re-render me only when seat 7 changes". For that you need state that lives outside React and lets each component subscribe to its own slice. That's an external store, and we'll build one in the next section.
An external store from scratch
useSyncExternalStore, and each component re-renders only when the value it selected changes.Zustand, Redux and their cousins all work this way at the core. Building the small version yourself is the fastest way to understand what they do, and what their rules (like "never mutate") are protecting.
The store
export function createStore(initial) {
let state = initial; // lives outside React
const listeners = new Set(); // who to tell about changes
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener); // ← hand back an "unsubscribe"
},
setState(update) {
const next = typeof update === "function" ? update(state) : update;
if (Object.is(next, state)) return; // same object → nothing changed
state = next;
listeners.forEach((l) => l()); // tell everyone
},
};
}This is the publish/subscribe idea (also called the observer pattern, see Behavioral patterns): the store doesn't know who's listening or why; it just calls every listener after a change.
Connecting it to React
React needs two things from an external store: a way to subscribe (so it hears about changes) and a way to read the current value. useSyncExternalStore takes exactly those:
import { useSyncExternalStore } from "react";
export function useStore(store, selector) {
return useSyncExternalStore(
store.subscribe, // React calls this once, and the returned function on unmount
() => selector(store.getState()), // "the snapshot": what this component wants, right now
);
}
// in a component
const value = useStore(boardStore, (s) => s.seats[i]); // ← only seat iThe second function returns the snapshot. After every change, React calls it again and compares the result with the previous one using Object.is (===, except it treats NaN as equal to itself). Same value: React skips this component. Different value: it re-renders. That comparison is what context can't do. Step through one click:
1function createStore(initial) {2 let state = initial;3 const listeners = new Set();4 return {5 getState: () => state,6 subscribe(listener) {7 listeners.add(listener);8 return () => listeners.delete(listener);9 },10 setState(update) {11 const next = typeof update === "function" ? update(state) : update;12 if (Object.is(next, state)) return;13 state = next;14 listeners.forEach((l) => l());15 },16 };17}1819function useStore(store, selector) {20 return useSyncExternalStore(21 store.subscribe,22 () => selector(store.getState()),23 );24}
createStore keeps two private things: the current state (a plain object) and a Set of listeners, functions to call after each change. Nothing outside can touch them directly; they're captured by the closures it returns.Why not useEffect + useState?
You could subscribe in an effect and copy the value into useState. It mostly works, but React can pause a render halfway through and continue later (concurrent rendering, see How React Works §10). If the store changes during that pause, some components show the old value and some the new one, a glitch called tearing. useSyncExternalStore guarantees one consistent value per render. React Hooks §12 shows this site's own store built the same way.
Real libraries add conveniences on top: actions bundled with the state, middleware, devtools, persistence. But the mechanism you just stepped through is the whole idea.
Selectors: subscribe to a slice
state => state.seats[7], that tells the store which slice a component cares about. The component re-renders only when that slice changes, so a selector must return the same value when nothing relevant changed.Here is the board from §04 again, now with a switch. "useContext" is the context store from §04; "store + selector" is the createStore from §05, with each seat reading s => s.seats[i]. Click a seat in each mode, then type in the title:
performance.now(), from the click to the end of React's commit.With the store, one click re-renders one seat and the total, because those are the only two components whose selected value changed. Typing in the title re-renders only the title. Same components, same memo, the only difference is how they subscribe.
What we measured in a production build, clicking one seat (this machine, headless Chromium, median of 40 clicks):
| Wiring | Components re-rendered per click | Click → end of commit |
|---|---|---|
useContext | 50 of 50 | about 1.6 ms |
| store + selector | 2 of 50 | about 0.6 ms |
Both are fast here, because the components are tiny, and part of each number is fixed overhead that doesn't depend on how many components re-render. What grows is the count: 50 re-renders versus 2. Put 2,000 rows or a chart in each component and the context version does 25× the rendering work on every change.
The same thing with Zustand
Store libraries give you this pattern ready-made. Zustand is the smallest popular one; create returns a hook that takes a selector:
import { create } from "zustand";
export const useCart = create((set) => ({
owner: "Ana",
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })), // ← actions live in the store
clear: () => set({ items: [] }),
}));
// any component, no provider needed
function CartBadge() {
const count = useCart((s) => s.items.length); // re-renders only when the count changes
return <span>{count}</span>;
}
function AddButton({ item }) {
const add = useCart((s) => s.add); // functions never change: never re-renders
return <button onClick={() => add(item)}>Add</button>;
}And with Redux Toolkit
Redux is older and more structured: changes are described as actions (plain objects like { type: "cart/add", payload: item }), and a reducer decides the new state. Redux Toolkit is the official, modern way to write it:
import { configureStore, createSlice } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
add(state, action) {
state.items.push(action.payload); // ← looks like mutation; Immer makes a copy
},
},
});
export const { add } = cartSlice.actions; // add(item) → { type: "cart/add", payload: item }
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });import { useDispatch, useSelector } from "react-redux";
function CartBadge() {
const count = useSelector((s) => s.cart.items.length);
return <span>{count}</span>;
}
function AddButton({ item }) {
const dispatch = useDispatch();
return <button onClick={() => dispatch(add(item))}>Add</button>;
}
// once, at the top: <Provider store={store}><App /></Provider>Which one? Zustand is less code and has no provider. Redux Toolkit adds a strict action log, excellent devtools (replay every action), and conventions that help big teams. Both use useSyncExternalStore underneath, and both re-render by selector.
The selector that loops forever
Every selector rule comes down to Object.is. This looks harmless:
const { owner, count } = useCart((s) => ({ owner: s.owner, count: s.items.length }));The selector builds a new object every time it's called. React calls it, compares with the previous result, sees a different object, re-renders, calls it again, gets another new object… With Zustand 5, React logs The result of getSnapshot should be cached to avoid an infinite loop and then throws Maximum update depth exceeded. (React Redux doesn't loop, because it compares more carefully, but it warns in development that the selector "returned a different result when called with the same parameters" and re-renders on every store change.)
Three fixes:
// 1. one selector per value (simplest)
const owner = useCart((s) => s.owner);
const count = useCart((s) => s.items.length);
// 2. Zustand: compare the object field by field
import { useShallow } from "zustand/react/shallow";
const { owner, count } = useCart(useShallow((s) => ({ owner: s.owner, count: s.items.length })));
// 3. Redux: a memoized selector for derived data
import { createSelector } from "@reduxjs/toolkit";
const selectPricey = createSelector([(s) => s.cart.items], (items) => items.filter((it) => it.price > 50));
const pricey = useSelector(selectPricey); // same array back until s.cart.items changesuseShallow compares the old and new objects with shallow equality: same keys, each value ===. createSelector builds a memoized selector: it remembers its last input (s.cart.items) and returns the very same output array until that input changes.
The URL is state too
Imagine sending a colleague a link to "lamps under $100, sorted by price, page 2". If the filters live in useState, the link opens an unfiltered first page, a reload throws your filters away, and Back leaves the shop entirely. Users notice all three.
Put the filters in the query string instead: /products?q=lamp&sort=price&page=2. Try both versions in this simulated browser tab:
- Arc lamp$89
- Bean bag$60
- Blanket$38
- Bookshelf$150
- /
- ▶ /products
Reading and writing the query string
The browser has a built-in parser, URLSearchParams, and two functions that change the address without loading a new page, from the History API:
// read
const params = new URLSearchParams(location.search); // location.search is "?q=lamp&page=2"
const q = params.get("q") ?? ""; // "lamp"
const page = Math.max(1, Number(params.get("page")) || 1); // a string: convert, and fall back to 1
// write
params.set("page", "3");
history.pushState(null, "", `?${params}`); // new entry: Back returns to page 2
history.replaceState(null, "", `?${params}`); // same entry, updated: Back skips it
// listen for Back / Forward
window.addEventListener("popstate", () => render(new URLSearchParams(location.search)));Push or replace? Push for deliberate steps a user might want to go back to: changing page, switching tab, applying a filter. Replace for continuous changes like typing, otherwise Back walks through ?q=l, ?q=la, ?q=lam one letter at a time. Flip "typing: pushState" in the demo to feel the difference.
In a React app, use the router
You rarely call pushState yourself; your router wraps it and re-renders on changes. With React Router, useSearchParams works like a useState that lives in the URL:
import { useSearchParams } from "react-router";
function Products() {
const [params, setParams] = useSearchParams();
const q = params.get("q") ?? "";
const page = Math.max(1, Number(params.get("page")) || 1); // the URL is user input: validate it
return (
<>
<input
value={q}
onChange={(e) => setParams({ q: e.target.value }, { replace: true })} // ← typing: replace
// an object replaces ALL params, so page (and anything else) resets: wanted for a new search
/>
<button onClick={() => setParams({ q, page: String(page + 1) })}>Next</button> {/* push */}
<ProductList q={q} page={page} />
</>
);
}There's no useState for q or page at all: they're derived from the URL on every render, so they can't disagree with it. Next.js (useSearchParams + router.replace), TanStack Router (typed search params) and the nuqs library offer the same idea with different APIs.
Server state: a cache, not a store
In most apps, the biggest chunk of "global state" is server data: the user, the orders, the product list. Teams used to fetch it in an effect and copy it into a Redux store. That means writing loading flags, error flags, caching, refetching and "is this still current?" logic by hand, for every endpoint.
That strategy has a name: stale-while-revalidate, the same idea as the HTTP header of that name (Caching). Here's how it plays out when you return to a profile you've seen before:
A query cache, live
The demo below runs a real (small) query cache. It works like TanStack Query, the most widely used library for this, with the same vocabulary. Things to try:
- Ana → Bo → Ana. The first visit to each shows a spinner. Going back to Ana is instant from cache, while a background refetch runs.
- "2 cards, same key", then switch person. Two components ask for the same data; the log shows one request.
- "Move Ana on the server". The cache doesn't know. Then press invalidate or simulate window focus and watch it catch up.
- Set staleTime 15 s and repeat step 1: no refetch while the data is fresh.
- Switch away from someone and wait 20 seconds: their entry is removed (gcTime).
| key | data | status | on screen | age |
|---|---|---|---|---|
| empty | ||||
The vocabulary
- Query key: the name the data is stored under, like
["user", 1]. Same key, same entry. A different key (a different user) is a different entry and a different request. Put everything the request depends on into the key. - staleTime: how long fresh data stays fresh. TanStack Query's default is 0: data is stale the moment it arrives. "Stale" doesn't mean "hidden"; it means "show it, but refetch at the next opportunity" (a component mounting, window focus, reconnecting).
- Deduplication: if a request for a key is already in flight, a second component waits for that one instead of sending another.
- gcTime: how long an entry nobody is using stays in the cache. TanStack Query's default is 5 minutes in the browser. (The demo uses 20 seconds so you can watch it happen.)
The same thing in TanStack Query
import { useQuery } from "@tanstack/react-query";
function Profile({ userId }) {
const { data, isPending, isFetching, error } = useQuery({
queryKey: ["user", userId], // ← the cache key
queryFn: ({ signal }) =>
fetch(`/api/users/${userId}`, { signal }).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`); // fetch doesn't throw on 404/500
return r.json();
}),
staleTime: 30_000, // fresh for 30 s
});
if (isPending) return <Spinner />; // nothing cached yet
if (error) return <p>Couldn't load: {error.message}</p>;
return <h2>{data.name} {isFetching && "⟳"}</h2>; // cached data + background refresh
}
// once, at the top:
// const queryClient = new QueryClient();
// <QueryClientProvider client={queryClient}><App /></QueryClientProvider>We checked the two key claims against TanStack Query 5 in a real browser: two components with the same key mounted together made one call, and invalidateQueries made the active query fetch again. SWR (by Vercel) and RTK Query (part of Redux Toolkit) work the same way with different names.
Changing server data: invalidate or be optimistic
A request that changes server data (POST, PUT, PATCH, DELETE) is a mutation. The query cache from §08 doesn't know what your mutation changed, so you tell it.
Option 1: invalidate, then refetch
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (text) =>
fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" }, // tell the server the body is JSON
body: JSON.stringify({ text }),
}).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`); // ← a 500 must fail, or onSuccess runs
return r.json();
}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["todos"] }), // ← every key starting with "todos"
});
return (
<button disabled={mutation.isPending} onClick={() => mutation.mutate("Buy milk")}>
{mutation.isPending ? "Adding…" : "Add"}
</button>
);
}Two details matter. fetch only rejects when the network fails; a 404 or 500 still "succeeds", so the r.ok check turns it into an error, and then onSuccess doesn't run and mutation.error is set. And the Content-Type header tells the server how to read the body; many servers ignore a JSON body without it.
Invalidation marks matching entries stale, and the ones on screen refetch right away (you tried this with the ✱ button in §08). It matches by prefix: ["todos"] covers ["todos"], ["todos", { done: true }] and ["todos", 7]. It's simple and always correct, because the server's answer is the truth. The cost is one extra round trip before the user sees the result.
Option 2: optimistic updates
For small, likely-to-succeed actions (a like, a checkbox, renaming), waiting feels sluggish. An optimistic update shows the result immediately, marks it as pending, and undoes it if the request fails. That undo is the rollback. Try both styles, with a working and a failing server:
Here's what each style shows over time, for a server that takes 900 ms:
React 19 has a hook for exactly this, useOptimistic. The demo uses it:
import { startTransition, useOptimistic, useState } from "react";
function TodoList({ initial }) {
const [todos, setTodos] = useState(initial); // what the server confirmed
const [shown, addOptimistic] = useOptimistic( // what the screen shows
todos,
(current, { id, done }) => current.map((t) => (t.id === id ? { ...t, done, pending: true } : t)),
);
function toggle(todo) {
const done = !todo.done;
startTransition(async () => {
addOptimistic({ id: todo.id, done }); // ← on screen right now
try {
await saveTodo(todo.id, done);
startTransition(() =>
setTodos((ts) => ts.map((t) => (t.id === todo.id ? { ...t, done } : t))));
} catch {
showToast("Couldn't save. Try again."); // the optimistic value disappears by itself
}
});
}
return shown.map((t) => <Row key={t.id} todo={t} onToggle={() => toggle(t)} />);
}TanStack Query supports the same pattern through onMutate (update the cache and remember the old value), onError (put the old value back) and onSettled (invalidate to be sure), for cases where the optimistic value must appear in several components that read the same cache key.
Form state
A form needs more state than it seems: the values, which fields the user has touched (left at least once), which are dirty (changed from the start), the validation errors, and whether a submit is in progress. The question is who holds the values.
const [email, setEmail] = useState("");
<input
value={email} // React decides
onChange={(e) => setEmail(e.target.value)}
/>
// every keystroke → setState → re-renderA controlled input gets its value from state, so React knows the text at all times: easy to validate as you type, format a phone number, or disable the submit button. An uncontrolled input keeps its text in the page itself, as plain HTML does; you read everything at the end with FormData. Both are valid tools. Type in each mode below and watch the render counter:
React 19 form actions
React 19 lets you pass a function to <form action={…}>. React calls it with the form's FormData when the form is submitted. Combined with useActionState, you get the last result and a pending flag without writing any state yourself. The third mode in the demo is this:
import { useActionState } from "react";
function SignupForm() {
const [state, formAction, isPending] = useActionState(
async (previousState, formData) => { // ← runs on submit
const email = String(formData.get("email"));
const result = await signup(email); // call your server
return result.ok
? { error: "", email: "" }
: { error: result.message, email }; // ← send the typed text back
},
{ error: "", email: "" }, // the initial state
);
return (
<form action={formAction}>
<input name="email" defaultValue={state.email} /> {/* ← keeps the text after the reset */}
{state.error && <p role="alert">{state.error}</p>}
<button disabled={isPending}>{isPending ? "Saving…" : "Sign up"}</button>
</form>
);
}When to validate
Errors shown on the first keystroke are annoying ("not an email" after typing a). A common rule: validate a field when the user leaves it (on blur), then re-check as they type once an error is showing, and validate everything on submit. That's what the "touched" set in the controlled demo is for. And always validate again on the server: the browser's checks are a convenience, not a security measure.
Where form state does not go
For big forms (dozens of fields, arrays of rows, complex validation), a form library saves a lot of code. React Hook Form is built on uncontrolled inputs, so typing in one field doesn't re-render the whole form; pair it with a schema library like Zod to describe the rules once and reuse them on the server.
Staff level: choosing, structuring, and SSR
A decision guide
Walk down the list and stop at the first "yes":
| Question about one piece of state | Then put it… |
|---|---|
| Does it come from the server and live there? | in a query cache (TanStack Query, SWR, RTK Query, or a route loader: a function your router runs to fetch a page's data before showing it) (§08) |
| Should a link, a reload or Back bring it back? | in the URL (§07) |
| Is it the draft of a form? | in the form, or a form library (§10) |
| Can you compute it from other state? | nowhere: compute it during render (a derived value) |
| Does only one component (and its children) use it? | local useState / useReducer (§02) |
| Do a few nearby components need it? | lift it to their common parent (§02) |
| Do many distant components read it, and it changes rarely? | context (§03–§04) |
| Do many distant components read it, and it changes often? | an external store with selectors (§05–§06) |
The order matters: the first questions remove the most state. Teams that answer the server-data question first usually find the last two rows hold very little.
Shaping a store that stays maintainable
Split by domain
cart, editor, session. Not one giant object with 80 fields that everyone imports.Store facts, not results
Actions describe events
checkoutStarted, itemAdded, not setLoadingTrue. The store decides what changes, so the rules live in one place.When the same record shows up in several places (a user in a list, in a detail panel and in a comment), store it once, keyed by id. That's normalized state: a lookup table, like a hash map, plus lists of ids.
// nested: Ana is copied into every post. Rename her → find and fix every copy.
{ posts: [{ id: 1, author: { id: 7, name: "Ana" } }, { id: 2, author: { id: 7, name: "Ana" } }] }
// normalized: Ana is stored once; posts refer to her by id.
{
users: { byId: { 7: { id: 7, name: "Ana" } } },
posts: { byId: { 1: { id: 1, authorId: 7 }, 2: { id: 2, authorId: 7 } }, ids: [1, 2] },
}Redux Toolkit's createEntityAdapter generates this shape and its update functions for you. Query caches avoid the problem differently: each query has its own copy and invalidation refreshes them together.
Stores and server rendering
A store created at module level (export const store = createStore(…), or Zustand's create) is one object per JavaScript process. In the browser that's one per tab, which is what you want. On a server that renders pages for many users (Rendering strategies), it's one object shared by every request: user A's cart can appear in user B's HTML. Create a per-request store instead, inside the component tree, and hand it down through context:
const StoreContext = createContext(null);
export function StoreProvider({ initial, children }) {
const [store] = useState(() => createStore(initial)); // ← created once per mounted tree, i.e. per request on the server
return <StoreContext value={store}>{children}</StoreContext>;
}
export function useAppStore(selector) {
const store = useContext(StoreContext); // the store object never changes: no context re-renders
return useSyncExternalStore(store.subscribe, () => selector(store.getState()), () => selector(store.getState()));
}This is the shape the board in §06 uses, and the one Zustand's docs recommend for Next.js (createStore from zustand/vanilla plus a context). Context carries the store object, which never changes, so it causes no extra re-renders; the selectors do the subscribing. The third argument to useSyncExternalStore is the snapshot used during server rendering and Hydration; it must give the same value the server used, or React reports a hydration mismatch. TanStack Query has the same rule: create the QueryClient per request on the server.
Persistence, testing, devtools
- Persistence. Saving a store to
localStorage(Zustand'spersistmiddleware,redux-persist) is easy. The hard parts: the saved shape outlives your code, so version it and write migrations; and the server can't readlocalStorage, so the first render must match the server's (load after Hydration, or accept a flash). Browser Storage covers what's safe to store. - Testing. A reducer or a store is a plain function or object: test it with no React at all (
store.setState(…), then checkstore.getState()). Test components against a real store created per test, not a mock, so selectors are exercised too. - Devtools. Redux DevTools shows every action and the state after it, and can step backwards. Zustand can connect to the same extension with its
devtoolsmiddleware. A log of "what happened, in order" is the best debugging tool for state bugs.
Transitions and external stores
One trade-off to know: updates to an external store can't be marked non-urgent. React's startTransition (How React Works §10) can interrupt and delay renders caused by React state, but a change to a store read through useSyncExternalStore is always rendered as urgent. React's docs say that if the store changes during a transition, React falls back to rendering that update as blocking, which is the price of never showing Tearing. If you need useDeferredValue or transitions on top of store data, select the value from the store, then defer it in the component.
Pitfalls
Storing what you could compute
const [fullName, setFullName] = useState(first + " " + last) plus an effect to keep it updated. It will be one render behind, or wrong after the next refactor. Compute it during render: const fullName = first + " " + last. Same for filtered lists, totals and counts.
Copying props into state
useState(props.user.name) only uses the prop the first time. When the parent passes a different user, the state keeps the old name. Either use the prop directly, or, if you really want a fresh editable copy per user, give the component a key={user.id} so React starts it over.
Mutating store state
state.items.push(x); setState(state) hands back the same object, so Object.is says "no change" and nothing re-renders (§05). Make new objects: setState((s) => ({ ...s, items: [...s.items, x] })). Inside Redux Toolkit's createSlice the push style is fine, because Immer makes the copy (§06).
One big context for everything
Every reader re-renders on every change of anything in it, and memo can't stop it (§04). Split contexts by how often they change, put dispatch in its own context, or move busy state to a store with selectors.
A selector that builds a new object
useStore((s) => ({ a: s.a, b: s.b })) returns a new object every call. In Zustand 5 that's an infinite loop (Maximum update depth exceeded); in React Redux, a re-render on every store change plus a warning. Select values separately, use useShallow, or a memoized selector (§06).
Server data in a client store
Fetching in an effect and saving the result in Redux means writing your own caching, loading flags, refetching and race handling, and it still goes stale. Use a query cache (§08). If several components need it, they all call the same query.
Forgetting to invalidate after a write
The save succeeds, but the list still shows the old data until a reload. After every mutation, invalidate the keys it affects, or update the cache directly (§09).
Filters in useState on a linkable page
Users share links, reload, and press Back. All three lose filters held in useState. Put them in the URL, and use replace while typing (§07).
Lifting everything to the top
State kept in App "in case someone needs it later" re-renders the whole app on every change. Keep state as low as it can go; lift only to the nearest common parent (§02).
A module-level store on the server
During server rendering, a store created at module level is shared by every request, so one user's data can leak into another's page. Create the store per request and pass it through context (§11).
Check yourself: predict what happens
Commit to an answer before you click. Each explanation points back to the section that covers it.
The context value is { user, cart, theme }, memoized with useMemo. The user switches the theme.
const CartBadge = memo(function CartBadge() {
const { cart } = useContext(AppContext);
return <span>{cart.items.length}</span>;
});const { name, count } = useAppStore((s) => ({
name: s.name,
count: s.count,
}));store.setState((s) => {
s.count += 1; // change it in place
return s;
});A search box writes ?q=… with history.pushState on every keystroke. The user types lamp, then presses Back once.
<Header> and <Profile> both call useQuery({ queryKey: ["user", 7], … }) and mount at the same moment. The cache is empty. How many requests are sent?
A checkbox uses useOptimistic inside startTransition(async () => { addOptimistic(…); await save(); … }). The server answers 500 and your code catches the error.
Cheat sheet
children to skip layers that don't care.memo doesn't block it.useStore(s => s.x) re-renders only when x changes. Selectors must return stable values.| Kind of state | Home | Tool |
|---|---|---|
| one component's UI | the component | useState, useReducer |
| a few nearby components | nearest common parent | lifted useState + props |
| rare changes, many readers | a provider near the top | createContext + useContext |
| frequent changes, many readers | an external store | Zustand, Redux Toolkit, useSyncExternalStore |
| linkable view state | the URL | useSearchParams, URLSearchParams |
| data owned by a server | a query cache | TanStack Query, SWR, RTK Query |
| a form draft | the form | uncontrolled inputs + FormData, useActionState, a form library |
Symptom → cause → fix
| You see… | Probably… | Fix |
|---|---|---|
| Two components show different values for the same thing | two copies of the state | lift to one owner, or derive one from the other |
| Typing is slow; the whole page flashes in the profiler | state too high, or a busy value in context | move it down; split context; use a store with selectors |
| Screen doesn't update after a store change | mutated the state object | return a new object |
Maximum update depth exceeded after adding a selector | selector returns a new object/array | separate selectors, useShallow, memoized selector |
memo component re-renders anyway | it reads a context that changed | split the context, or select from a store |
| List shows old data after saving | no invalidation | invalidateQueries for the affected keys |
| Spinner every time you revisit a page | no cache for server data | a query library, maybe a longer staleTime |
| Filters gone after reload or Back | filters in useState | put them in the URL |
| Another user's data in server-rendered HTML | module-level store on the server | create the store per request |