State & Data Flow
State & Data Flow

Choosing where state lives

Full · ~30 min
After this unit you can
  • Choose the smallest home for state: lift to a common parent first, and use the URL for shareable state
  • Spot fetched data that belongs in a useQuery cache, not in useState or a client store
  • Predict that every Context consumer re-renders on a new value, and explain how store selectors avoid it
  • Fix a provider's fresh value object with useMemo and useCallback so unrelated updates skip consumers

Read

Simple
1 / 2

State has a home — climb the ladder only as far as you need

Flag

Most "state management" problems are really placement problems: the state is fine, it's living in the wrong place. Before reaching for a library, ask two questions, then walk a ladder.

First, does something outside your components already own it?

  • Did it come from a server? (users, orders, this page's content) → a server-state library (TanStack Query / SWR), however few components read it. It's a cache of someone else's data, with its own rules: loading, caching, staleness, invalidation.
  • Should a link reproduce it? (filters, tabs, pagination, the currently-open item) → the URL (searchParams). It's shareable, bookmarkable, and survives a refresh.

If both answers are no, it's state your app invents. Climb only as far as you need and stop at the first rung that works:

  1. Local (useState / useReducer) — only one component cares. This is the default.
  2. Lifted — a couple of nearby components share it → move it to their nearest common parent and pass it down.
  3. Context — many components across the tree need the same, rarely-changing value (theme, locale). Context is a transport, not a store: it can also carry a value something else owns, such as the signed-in user your query cache fetched.
  4. Client store (Zustand / Redux) — app-wide client state that changes often and is read in many places, where you want components to subscribe to slices.

The mistakes that cause "state hell" are almost always one of two: skipping rungs — reaching for a global store when lifting would do — or skipping the two questions — stuffing server data into useState or a client store and re-implementing caching by hand.

Rule of thumb: put state at the smallest scope that still lets everything that needs it reach it — unless the server or the URL already owns it.

Medium

Context vs client store vs server state — what each is for

Flag

These three get conflated constantly. They solve different problems.

Context is a transport, not a state manager. It moves a value down the tree without prop-drilling. It has no notion of "subscribe to part of the value": every consumer re-renders whenever the provider's value changes identity. That's perfect for values that change rarely (theme, auth user, locale) and a trap for values that change often.

A client store (Zustand) adds selector subscriptions. You read a slice, and your component re-renders only when that slice changes:

// re-renders only when `theme` changes — not on every store update
const theme = useAppStore((s) => s.theme);

Reach for it when app-wide client state changes frequently and is read widely, or when Context's "re-render everyone" cost starts to bite.

Server state is a different animal — use a server-state library. Data you fetched (users, orders, this page's content) is a cache of the server's data. It goes stale, needs refetching, deduping, retries, and invalidation. TanStack Query gives you all of that (this track uses v5):

const { data, isPending } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });

isPending is the v5 name for "no data yet"; v4 called it isLoading, and in v5 isLoading means isPending && isFetching. v5 also accepts only this single-object form of useQuery.

Putting server data in useState or Zustand means hand-rolling caching and invalidation — badly. The tell: if you've written a useEffect that fetches and stores into state, you're building a worse useQuery.

Quick map:

  • Rarely-changing, tree-wide → Context
  • Often-changing, app-wide client state → client store with selectors
  • Remote data → server-state library
  • Shareable / refresh-surviving → URL
Complex

The trade-offs that decide it — and the anti-patterns

Flag

The ladder gets you 90% there. The last 10% is knowing the sharp edges.

Context's re-render cost is real and non-obvious. A consumer re-renders when the context value changes by identity — even if it only reads one field, even if it's wrapped in memo. Two consequences:

  • An inline value={{ user, logout }} creates a new object every provider render → all consumers re-render every time. Memoize it: useMemo(() => ({ user, logout }), [user, logout]) (and stabilize the functions with useCallback). This is the exact bug in this unit's challenge. If your build runs React Compiler (stable since v1.0, October 2025), it inserts this memoization for you; without it — as in the challenge sandbox — you write it by hand.
  • For a value that does change often, memoizing won't save you — the identity legitimately changes. The fixes are splitting the context (a stable "dispatch" context separate from the changing "state" context, so components that only dispatch don't re-render) or moving to a store with selectors.

Quiz

Which of these belongs in a server-state library (TanStack Query) rather than a global client store like Zustand or Redux?

Flag

You store a rapidly-changing value (say, the current playback time) in a Context that wraps half the app. What happens on each tick?

Flag

Two components three levels apart need to share one piece of state. What's the first thing to reach for?

Flag

What is the main practical reason to choose a client store like Zustand over Context for frequently-changing, app-wide client state?

Flag

Fill in the blank

Remote data belongs in a server-state cache, not useState. Complete the TanStack Query call.

Flag
const { data } = ({ queryKey: ["users"], queryFn: fetchUsers });

Stop this provider from re-rendering every consumer on each render by giving the value a stable identity.

Flag
const value = (() => ({ user, logout }), [user, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

A search filter should be shareable via a link and survive a refresh, so it belongs in the URL. Read it in a Next.js App Router client component.

Flag
const q = ().get("q");

Predict the output

const Ctx = createContext(null);

function Provider({ children }) {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Ana");
  return (
    <Ctx.Provider value={{ count, setCount, name, setName }}>
      {children}
    </Ctx.Provider>
  );
}

function CountLabel() {
  const { count } = useContext(Ctx);
  console.log("CountLabel render");
  return <span>{count}</span>;
}
Flag

Challenge

Stop a Context value from re-rendering every consumer

Flag

ThemeLabel is wrapped in React.memo and reads only the theme from context. It should re-render only when the theme changes. But every time you click Tick in Settings — an update that has nothing to do with the theme — the label's render count climbs anyway.

The reason: Settings builds a fresh value object on every render (and a fresh toggleTheme function). React Context compares its value by identity, so when Settings re-renders for the tick, the context value looks "new" and every consumer re-renders — memo can't help, because the change comes through context, not props.

Your task

Edit Settings.tsx so the tests pass:

  • The consumer renders once on mount (render count shows 1)
  • Clicking Tick does not re-render the consumer (render count stays 1)
  • Clicking Toggle theme still flips the theme (light → dark)

Loading the in-browser editor…

Further reading