Skip to content
Software Atlas

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.

⏱ ~60 min read14 sections11 interactive widgetsJSX · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

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.

In one sentenceState management is deciding where each piece of data lives. Ask two questions: who owns it (this component, the whole app, the URL, or a server?) and who needs to read it. The answers pick the tool.

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:

KindExampleNatural homeSection
Local UI stateis this dropdown open?the component's own useState§02
Shared client statetheme, cart, a multi-step wizardlifted state, context, or a store§02–§06
URL statesearch text, filters, page numberthe address bar§07
Server statethe orders from your APIa query cache§08–§09
Form statewhat's typed before Submitinside the form§10

Try sorting some real examples:

Where does this state live?
For each piece of state, pick the home you'd give it. There's a short explanation after each choice.
1. Is the “Sort by” dropdown open right now?
2. The list of orders returned by GET /api/orders
3. The search text, active filter and page number of a product list
4. What someone has typed into the sign-up form, before they press Submit
5. The colour theme and the display language
6. Cart items, shown in the header badge, a side drawer and the checkout page
Answered 0 / 6
1

Start low, lift when needed

Keep state in the component that uses it. Move it up to a parent only when a sibling needs it too (§02).
2

Shared ≠ global

Context and stores both share state, at very different re-render costs. We'll count the re-renders live (§04–§06).
3

Server data is a cache

Most "global state" in real apps is a copy of server data. A query cache (a library that keeps server data by request and refreshes it) handles it better than any store (§08).
Foundations§02

Local state, and lifting it up

In one sentenceState starts in the component that uses it. When two siblings need the same state, move it up to their nearest common parent and pass it down as props. That move is called lifting state 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.

Dropdown.jsx
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:

Two siblings, one search box
Type in the search box. Each box counts its renders and flashes when it re-renders.
<FruitPage>no staterenders: 1
<SearchBox>owns its own qrenders: 1
<ResultList>owns its own qrenders: 1
15 of 15 match “”
appleapricotbananablueberrycherrygrapekiwilemon+7
<Footer>uses no staterenders: 1
© Fruit Shop
The list never filters: 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.

FruitPage.jsx (lifted)
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.

✕ Everything at the top, just in case
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} /* … */ />;
}
✓ Each piece as low as it can go
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
  /* … */
}
Foundations§03

Prop drilling, and two ways out

In one sentenceProp drilling is passing a prop through components that don't use it, only to reach one deep below. Fix it by passing the finished element down (composition) first, and with context when many distant components need the value.

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:

Who has to know about user?
The same component tree, wired three ways. App owns user; only UserMenu shows it.
component tree5 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>; }
Five components mention 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.

composition: the parent fills the slots
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:

context in three steps
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.

Practitioner§04

Context as a store, and what it costs

In one sentenceContext has no selectors. A component that reads a context re-renders whenever the context's value changes, even if the part it uses didn't. Context is a great way to pass values that change rarely, and a poor home for state that changes often.

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) => newState that holds all the rules in one place.
  • dispatch is the function useReducer gives you instead of a setter. dispatch(action) calls reducer(currentState, action), and whatever it returns becomes the new state.

Here's the reducer for a seat board (48 seat counters plus a title):

boardReducer.js
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:

the context-as-a-store pattern
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:

One click, from dispatch to re-render
The reducer behind the board in the next demo. Step through a click on seat 7.
Code
1function boardReducer(state, action) {
2 switch (action.type) {
3 case "bump": {
4 const seats = state.seats.slice(); // copy the array
5 seats[action.i] = seats[action.i] + 1; // change the copy
6 return { ...state, seats }; // a new state object
7 }
8 case "title":
9 return { ...state, title: action.title };
10 default:
11 return state; // unknown action: no change
12 }
13}
14
15// inside <Seat i={7}>
16<button onClick={() => dispatch({ type: "bump", i: 7 })} />
the action
{ type: "bump", i: 7 }
state
state.seats = [0, 0, 0, 0, 0, 0, 0, 0, …]
after the reducer returns
not yet
You click seat 7. The click handler doesn't change any state itself. It builds an action: a plain object saying what happened, { type: "bump", i: 7 }, and hands it to dispatch.
1 / 6

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:

A whole app's state in one context
48 seats, a title and a total. Every component is wrapped in memo and uses only its own slice (but reads the whole context). Click a seat; the tiny number in each seat is its render count.
renders: 1
total = 0 · renders: 1
Times are measured in your browser with 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.

Why every reader re-renders
click seat 7
dispatch
reducer
new state
provider
new value
readers
Title · Total · Seat ×48 all re-render
React compares the provider's value with the previous one using Object.is (basically ===, so for objects: is it the very same object?). Different object → every component that called useContext(BoardContext) is re-rendered. Which field they destructure doesn't matter.

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

1

Split by change rate

ThemeContext, UserContext, CartContext. A component re-renders only for the contexts it reads.
2

Split state from dispatch

dispatch never changes, so put it in its own context. Components that only send actions (buttons) stop re-rendering.
3

Keep busy state local

The text in the title box belongs in the title component until it's saved. Don't route every keystroke through a shared context.
state and dispatch in separate contexts
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.

Practitioner§05

An external store from scratch

In one sentenceA store is three things: state kept outside React, a setState that replaces it, and a list of listeners to call after each change. Connect it with 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

createStore.js
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:

useStore.js
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 i

The 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:

setState → listeners → selectors → one re-render
The whole store, plus the hook that connects it to React. Step through one click on seat 1.
Code
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}
18
19function useStore(store, selector) {
20 return useSyncExternalStore(
21 store.subscribe,
22 () => selector(store.getState()),
23 );
24}
store state
{ title: "Seat map", seats: [0, 0, 0] }
listeners (the Set)
nobody subscribed yet
components on screen
nothing rendered yet
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.
1 / 8

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.

Practitioner§06

Selectors: subscribe to a slice

In one sentenceA Selector is a small function, 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:

Context vs a store with selectors
Same board, same memo'd components. Switch the wiring and click a seat. The tiny number in each seat is its render count.
renders: 1
total = 0 · renders: 1
Times are measured in your browser with 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):

WiringComponents re-rendered per clickClick → end of commit
useContext50 of 50about 1.6 ms
store + selector2 of 50about 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:

cartStore.js (Zustand 5)
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:

store.js (Redux Toolkit 2)
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 } });
CartBadge.jsx (React Redux 9)
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:

❌ a new object on every call
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:

✅ three ways to select several values
// 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 changes

useShallow 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.

Practitioner§07

The URL is state too

In one sentenceIf a user would expect a link to bring back what they see (a search, filters, a tab, a page number), that state belongs in the URL. You get sharing, reload and the Back button for free, and the URL becomes the single source of truth.

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:

Filters in useState vs in the URL
A simulated browser tab (it never touches this page's real address). Search, sort, change page, then try ← Back and ⟳ Reload.
shop.example/products
  • Arc lamp$89
  • Bean bag$60
  • Blanket$38
  • Bookshelf$150
18 results
back-button history2 entries
  1. /
  2. ▶ /products
Each filter change writes the URL. Typing replaces the current entry; clicks push a new one.

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:

url-state.js
// 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:

Products.jsx (React Router)
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.

Practitioner§08

Server state: a cache, not a store

In one sentenceData from your API isn't really your state; it's a cached copy of the server's. Treat it like a cache: store it under a key, show it instantly when you have it, refetch in the background when it might be old, and share one request between everyone who asks.

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:

Coming back to a profile you've already loaded
no cache
spinner… GET /users/1 (700 ms)
show Ana
SWR: screen
show cached Ana immediately
fresh Ana
SWR: network
background GET /users/1
Without a cache (top), every visit waits for the network. With stale-while-revalidate (bottom), the cached copy shows at once and the fresh one replaces it when it arrives, usually unnoticed.

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:

  1. 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. "2 cards, same key", then switch person. Two components ask for the same data; the log shows one request.
  3. "Move Ana on the server". The cache doesn't know. Then press invalidate or simulate window focus and watch it catch up.
  4. Set staleTime 15 s and repeat step 1: no refetch while the data is fresh.
  5. Switch away from someone and wait 20 seconds: their entry is removed (gcTime).
A query cache, live
Pick a person, switch back and forth, change data on the fake server, invalidate. The server takes 700 ms to answer.
<Profile> in the pageuseQuery(["user", 1])
Loading… (nothing cached yet, so a spinner)
the cachestaleTime 0 s · gcTime 20 s
keydatastatuson screenage
empty
Network log. Try: Ana → Bo → Ana.

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

Profile.jsx (TanStack Query 5)
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.

Practitioner§09

Changing server data: invalidate or be optimistic

In one sentenceAfter you change data on the server, some cached copies are now wrong. The simple fix is to invalidate them so they refetch. The fast-feeling fix is an optimistic update: change the screen immediately, and roll back if the server refuses.

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

AddTodo.jsx (TanStack Query 5)
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:

Wait for the server, or assume success?
Tick a few boxes. The fake server takes 900 ms. Then make it fail and tick again.
Tick a box.

Here's what each style shows over time, for a server that takes 900 ms:

Ticking a box: what the user sees
wait
☐ unchanged… waiting for the server
☑ ticked
optim. OK
☑ ticked (pending)
☑ confirmed
optim. 500
☑ ticked (pending)
☐ rolled back
Optimistic: the tick appears at once (pending). On success the confirmed state catches up and nothing visible changes; on failure the tick disappears when the request ends, which is the rollback.

React 19 has a hook for exactly this, useOptimistic. The demo uses it:

TodoList.jsx (React 19)
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.

Practitioner§10

Form state

In one sentenceForm state is local by nature: it lives while the form is on screen and is thrown away on submit. Keep it in the form. Choose between React holding every keystroke (controlled) and the inputs holding their own text until you read it (uncontrolled).

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-render

A 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:

Three ways to hold a form
Type a name and an email in each mode and watch the render counter. Then submit.
renders of <SignupForm>: 1
// React state, updated on every keystroke
values  = {"name":"","email":""}
touched = {}
errors  = {}   // computed

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:

SignupForm.jsx (React 19)
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§11

Staff level: choosing, structuring, and SSR

In one sentencePick the tool from the kind of state, not from fashion. Most apps need a query cache for server data, the URL for navigation state, local state for almost everything else, and a small store (or context) for the little that's left.

A decision guide

Walk down the list and stop at the first "yes":

Question about one piece of stateThen 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

1

Split by domain

One store (or slice) per area: cart, editor, session. Not one giant object with 80 fields that everyone imports.
2

Store facts, not results

Keep the minimum. A filtered list, a total or "is the cart empty" are selectors, not fields you must keep in sync.
3

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 vs normalized
// 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:

a store per request (works on server and client)
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's persist middleware, redux-persist) is easy. The hard parts: the saved shape outlives your code, so version it and write migrations; and the server can't read localStorage, 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 check store.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 devtools middleware. 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.

Practitioner§12

Pitfalls

In one sentenceAlmost every state bug is one of these: two copies of the same data, a change React can't see, or state living too high or in the wrong kind of place.
01

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.

02

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.

03

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).

04

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.

05

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).

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.

07

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).

08

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).

09

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).

10

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).

Practitioner§13

Check yourself: predict what happens

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 6
Does the badge re-render?

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>;
});
QUESTION 2 / 6
Zustand 5: what happens?
const { name, count } = useAppStore((s) => ({
  name: s.name,
  count: s.count,
}));
QUESTION 3 / 6
Our createStore from §05
store.setState((s) => {
  s.count += 1;   // change it in place
  return s;
});
QUESTION 4 / 6
Search box and the Back button

A search box writes ?q=… with history.pushState on every keystroke. The user types lamp, then presses Back once.

QUESTION 5 / 6
Two components, one query

<Header> and <Profile> both call useQuery({ queryKey: ["user", 7], … }) and mount at the same moment. The cache is empty. How many requests are sent?

QUESTION 6 / 6
useOptimistic and a failed save

A checkbox uses useOptimistic inside startTransition(async () => { addOptimistic(…); await save(); … }). The server answers 500 and your code catches the error.

Answered 0 / 6
Foundations§14

Cheat sheet

🧭
Ask who owns it, who reads itThe answers pick the home: component, parent, URL, query cache, form, context or store.
⬇️
State starts lowLocal first. Lift to the nearest common parent only when a sibling needs it.
🧮
Don't store what you can computeTotals, filtered lists, full names: derive them during render.
🧩
Composition before contextPass finished elements as children to skip layers that don't care.
📣
Context re-renders every readerUse it for values that change rarely. memo doesn't block it.
🎯
Stores re-render by selectoruseStore(s => s.x) re-renders only when x changes. Selectors must return stable values.
🔗
Linkable state goes in the URLPush for steps, replace while typing, validate what you read.
📄
Server data is a cacheKey it, let it go stale, refetch in the background, invalidate after writes.
Kind of stateHomeTool
one component's UIthe componentuseState, useReducer
a few nearby componentsnearest common parentlifted useState + props
rare changes, many readersa provider near the topcreateContext + useContext
frequent changes, many readersan external storeZustand, Redux Toolkit, useSyncExternalStore
linkable view statethe URLuseSearchParams, URLSearchParams
data owned by a servera query cacheTanStack Query, SWR, RTK Query
a form draftthe formuncontrolled inputs + FormData, useActionState, a form library

Symptom → cause → fix

You see…Probably…Fix
Two components show different values for the same thingtwo copies of the statelift to one owner, or derive one from the other
Typing is slow; the whole page flashes in the profilerstate too high, or a busy value in contextmove it down; split context; use a store with selectors
Screen doesn't update after a store changemutated the state objectreturn a new object
Maximum update depth exceeded after adding a selectorselector returns a new object/arrayseparate selectors, useShallow, memoized selector
memo component re-renders anywayit reads a context that changedsplit the context, or select from a store
List shows old data after savingno invalidationinvalidateQueries for the affected keys
Spinner every time you revisit a pageno cache for server dataa query library, maybe a longer staleTime
Filters gone after reload or Backfilters in useStateput them in the URL
Another user's data in server-rendered HTMLmodule-level store on the servercreate the store per request
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserBrowser Storage & Caching
Read →