cf.completefrontend
My progress
FUNDAMENTALS · MODULE 10

Local vs global state

Where each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.

The problem: one keystroke, 400 re-renders

Picture a project dashboard. There is a header with the user's avatar, a sidebar with 30 projects, a main panel with a table of 300 tasks, and a small search box above the table. A new team member is told to make the search box filter the table. They want to be safe, so they put the search text in the app's global store, next to the user and the projects.

It works. Then someone notices typing feels sticky on a mid-range laptop. React DevTools shows why: every keystroke re-renders the header, the sidebar, all 30 sidebar rows, the table, and its 300 rows. About 400 components do work for one letter, when only the search box and the table rows that match needed to change.

Nothing here is a bug in the usual sense, and the data is correct. The problem is where the state lives.

When a value changes, the framework re-runs the components that read it and, in React, everything those components render below them. Put a fast-changing value high up, or in a place that many components subscribe to, and every change pays for all of them.

Two opposite ways to get it wrong

Teams fail in two directions. Some put everything in one global store from day one: every toggle, every input, every modal flag. The store grows to hundreds of fields, nobody knows who reads what, and small changes re-render large parts of the page.

Others keep everything local and pass values down by hand. When a distant component finally needs the same value, they pass it through six layers of components that don't use it (prop drilling), or they copy it into a second place and the two copies drift apart. The cart badge says 3 items, the cart page says 2.

Both problems have the same cure: a clear rule for where each piece of state should live, and a short list of tools for when it has to be shared. That is what this lesson builds, from the smallest case (one component) to the largest (a store shared by the whole app).

A bar chart of components rendered for one keystroke in a dashboard with a header, a sidebar of 30 projects, and a table of 300 tasks. With the search text in the global store and read at the top: about 400 renders. Lifted only to TasksPanel: 303 renders, the panel, the search box, the table, and 300 rows. In TasksPanel with rows wrapped in React.memo: about 15 renders, the panel, the box, the table, and only the rows whose props changed.components rendered per keystroke, to scalein the global store, read at the top400 renderslifted to TasksPanel303 rendersTasksPanel, rows wrapped in React.memoabout 15
Same feature, same data, different owner. Moving the state down cuts a quarter of the work; adding a re-render boundary on the rows cuts almost all of the rest. Counts are illustrative, for this example dashboard.

What this lesson covers

This lesson is about client state: values that only exist in the browser, like the open tab, a draft comment, or the selected rows. The lesson on state categories explains how to tell client state apart from server state, URL state, and form state; here we take that split as given. When we meet server data, we will see why it usually does not belong in a global store at all.

  • Colocation: keep state in the component that uses it, and why that is the default.
  • Lifting state and composition: sharing between siblings without drilling props through the whole tree.
  • Context: what it is good at, and why every consumer re-renders when its value changes.
  • External stores: Redux Toolkit, Zustand, Jotai, MobX, and signals, and how React subscribes to them with useSyncExternalStore.
  • Selectors and re-render boundaries: how to make a change reach only the components that care.
  • When a global store is overkill, and how to explain all of this in an interview.

Check your understanding · Exercise 1

In the dashboard above, the search text is read by the search box and the task table only. Where should it live? Pick one.

The mental model: state has an owner, and changes flow down

Think of the component tree as an office building. Each piece of state is a document kept in one office, and whoever keeps it is its owner. When the document changes, the owner tells everyone in the offices below it, whether they care or not. The higher the floor the document lives on, the more people get the memo.

In React the rule is exact. When state changes, the component that owns it renders again, and by default so does every component it renders, all the way down. A child that receives no props from the parent still renders, unless it is wrapped in React.memo (or the React Compiler does the same for you). So the blast radius of a change is the owner's whole subtree.

A component tree. App has Header and Main. Header has Logo and Avatar. Main has Filter and List, and List has Row 1 and Row 2. Main owns a filter in useState. When the filter changes, Main re-renders along with everything below it: Filter, List, Row 1, and Row 2. App, Header, Logo, and Avatar are untouched.AppHeaderMainLogoAvatarFilterListRow 1Row 2Main owns filtera change in Main re-renders Main and all four below it
The blast radius of a change is the owner's subtree. Main owns the filter, so App, Header, Logo, and Avatar never notice it changed; everything under Main renders again, whether or not it reads the filter.

There is one escape hatch from this rule: a subscription. Context, Redux, Zustand, Jotai, and signals all let a component deep in the tree subscribe to a value directly. When the value changes, only the subscribers are told, and each re-renders its own subtree. The components in between, which do not subscribe, are skipped.

Two numbers decide the cost

For any piece of state, the cost of a change is roughly the number of components that re-render multiplied by how often it changes. A theme toggle that changes twice a day can re-render the whole app and nobody will notice. A cursor position that changes 60 times a second must not re-render more than a handful of components.

Frequency and reach together decide where state can live without hurting the page.
StateChangesReadersFine to keep high?
Theme (light or dark)Rarely, by a clickAlmost everythingYes: rare changes, so a full re-render is cheap overall
Signed-in userOnce per sessionHeader, menus, guardsYes, in context or a store
Search input textEvery keystrokeSearch box, one listNo: keep it near the list
Drag position60 times a secondThe dragged itemNo: keep it in the item, or outside React
Cart itemsA few times a visitBadge, cart page, checkoutYes, in a store with selectors

The ladder: climb only as high as you must

There are five places state can live, from the most local to the most global. Each step up the ladder lets more components share the value, and each step costs something: more re-renders, more indirection, or more code. The rule is to start at the bottom and climb one step only when a real reader forces you to.

A ladder of five places state can live, from the bottom. useState in the component: re-renders that component, use it for one reader. Lifted state in the closest common parent: re-renders the parent and its subtree, use it when siblings share. Composition with children and slots: the owner re-renders and slots skip the middle layers. Context, a provider above the readers: re-renders every consumer, use it for rare changes with wide reach. An external store with selectors: re-renders only the readers whose selected value changed, use it for hot state with many writers.globallocaluseStatein the componentre-renders: that componentuse when: one readerlifted stateclosest common parentre-renders: parent and its subtreeuse when: siblings share itcompositionchildren and slotsre-renders: owner, not middlesuse when: middles only place thingscontextprovider above readersre-renders: every consumeruse when: rare changes, wide reachexternal storesubscribe with selectorsre-renders: selected readers onlyuse when: hot, many writers
Start at the bottom and climb one step only when a real reader forces you to. Each step up lets more components share the value and changes what a change costs.
  1. Local state in the component that uses it (useState, useReducer).
  2. Lifted state in the closest common parent of the components that share it, passed down as props.
  3. Composition: the same lifted state, but the parent passes ready-made elements as children or slots, so the middle layers never see the props.
  4. Context: a value provided once near the top of a subtree and read anywhere below it, without props.
  5. An external store (Redux Toolkit, Zustand, Jotai, MobX, signals): state outside the component tree, with fine-grained subscriptions and selectors.

Notice what is not on the ladder: data from the server. Lists of tasks, the user's profile, search results: these have an owner already, the server, and they need caching, refetching, and invalidation. A server-state library such as TanStack Query handles that, and we will come back to it in its own chapter.

Check your understanding · Exercise 2

A parent owns count in useState. It renders a Footer that takes no props. The app does not use React.memo or the React Compiler. What happens to Footer when count changes? Pick one.

Colocation first, then lift only as far as needed

Colocation means keeping state in the component that uses it, or as close to it as the tree allows. A dropdown's open flag lives in the dropdown, a comment draft in the comment form, and a tab index in the tabs component. Nobody else needs these values, so nobody else should pay when they change.

Colocation has three benefits beyond speed. The code is easier to read, because the state sits next to the code that changes it. It is easier to delete, because removing the component removes its state too. And it resets naturally: when the dropdown unmounts, its open flag is gone, so there is no stale value waiting to surprise the next screen.

Lifting state to the closest common parent

Sooner or later two components need the same value. A filter bar sets a status filter, and a task list must show only matching tasks. Neither can own it alone, because siblings cannot read each other's state. The fix is to lift state up: move it to the closest common parent, and pass the value and a setter down as props.

tsx
function TasksPanel({ tasks }: { tasks: Task[] }) {
  const [status, setStatus] = useState<Status>("open");
  const visible = tasks.filter((t) => t.status === status); // derived, not stored
  return (
    <>
      <FilterBar status={status} onChange={setStatus} />
      <TaskList tasks={visible} />
    </>
  );
}
The parent owns the filter; both children are controlled by props.

"Closest" is the important word. Lift status to TasksPanel, not to the app root. Then a filter change re-renders the panel and its two children, while the header, the sidebar, and every other page stay untouched.

Derive, don't copy

Notice that visible is computed during render, not kept in a second useState. A common mistake is to store derived values (the filtered list, a total, a "has errors" flag) next to the values they come from, and sync them with an effect. The two drift apart as soon as one update path forgets the other.

If a value can be computed from other state, compute it. If the computation is slow, memoize it with useMemo, but still don't store it.

Push state down when it is too high

Colocation also works in reverse. If a big component owns a value that only one small part reads, move that state into a new, smaller child component. The big component stops re-rendering on every change. This is often the cheapest performance fix in a React app: no memoization, no store, just a smaller owner.

Try it in the lab below. Pick where the search text lives and see which components re-render when the user types one letter. Then move it and watch the count fall.

Keep the search text in
A component tree: App has Header and TasksPanel; Header has Avatar and Menu; TasksPanel has SearchBox and TaskTable; TaskTable has three rows. SearchBox and TaskTable read the search text. With the text kept in useState in TasksPanel, one keystroke renders 6 components: TasksPanel, SearchBox, TaskTable, Row 1, Row 2, Row 3.AppHeaderTasksPanelAvatarMenuSearchBoxTaskTableRow 1Row 2Row 3useState in TasksPanel
The closest common parent. The panel and everything below it re-render; the header, avatar, and menu are untouched.
Press "Type a letter" to see what renders.

Check your understanding · Exercise 3

A product page has Gallery and ColorPicker side by side inside ProductPage. Picking a color must change the gallery images. Nothing else on the site cares. Where should selectedColor live? Pick one.

Prop drilling, and how composition avoids it

Lifting works well across one or two levels, but it gets awkward when the owner and the reader are far apart. Say App owns user, and the only component that reads it is Avatar, five levels down: App, Layout, Header, Toolbar, UserMenu, Avatar. Every level in between has to accept user as a prop and pass it on, without using it. That is prop drilling.

Prop drilling is not wrong in itself. It is explicit: you can follow the value through the code, and TypeScript checks every step. It becomes a problem when the chain is long, when many values travel together, or when renaming one prop means editing ten files. It also widens the blast radius, because every layer that passes the prop re-renders when it changes.

Composition: pass the element, not the data

Before reaching for context, try composition. Instead of passing user through Layout and Header so that they can render Avatar, let the owner build the Avatar itself and pass the finished element down as children or as a named prop, often called a slot. The middle layers only place the element; they never see user.

tsx
function App() {
  const [user] = useState(currentUser);
  return (
    <Layout header={<Header actions={<Avatar user={user} />} />}>
      <Dashboard />
    </Layout>
  );
}

function Layout({ header, children }: { header: ReactNode; children: ReactNode }) {
  return (<div className="layout">{header}<main>{children}</main></div>);
}

function Header({ actions }: { actions: ReactNode }) {
  return (<header><Logo />{actions}</header>);
}
The middle layers take a slot. Only App and Avatar know about the user.
Two chains of four components side by side. Before, prop drilling: App owns user and passes it to Layout, which passes it unused to Header, which passes it unused to Avatar, which reads it. After, composition with slots: App owns user and builds the Avatar element itself; Layout only places a header slot, Header only places an actions slot, and the Avatar element goes straight from App to where it is shown.before: prop drillingafter: slotsAppowns userAppowns user, builds AvataruserLayoutpasses user, unusedLayoutplaces header slotuserHeaderpasses user, unusedHeaderplaces actions slotuserAvatarreads userAvatarreads useruser in 4 componentsuser in 2 components
With slots, the middle layers place an element they never look inside. The user value appears in two components instead of four, and Layout or Header can re-render for their own reasons without re-rendering Avatar.

Composition also helps performance. Elements passed in as children were created by the owner. When a middle component re-renders for its own reasons (say Layout toggles a sidebar), React sees the same children element as last time and does not re-render it. This is the "lift content up" pattern: a stateful wrapper that receives its expensive content as children does not re-render that content.

When composition runs out

Composition works when the owner is the one who builds the reader. It fails when the reader is created deep inside a reusable component that the owner does not control: every TableRow inside a generic DataTable needs the current locale, or every form field inside a design-system Form needs the form's disabled flag. The owner cannot hand each row a ready-made element. That is the job context was made for.

  • Use props when the chain is two or three levels and the value is part of the component's real API.
  • Use composition when middle layers are layout shells that just place content.
  • Use context when many components at different depths, built by code you don't control, need the same value.

Check your understanding · Exercise 4

Page owns cartCount. It renders Shell, which renders TopBar, which renders CartBadge. Shell and TopBar don't use the count. What is the lightest fix for the drilling? Pick one.

Context: sharing without props, and its re-render rule

React context lets a component provide a value to its whole subtree, and any component below it read that value with useContext (or use in React 19), with no props in between. It is the built-in answer to drilling. Vue has provide and inject, Svelte has setContext and getContext, and Angular has dependency injection: the idea is the same everywhere.

Context is best understood as a way to deliver a value, not a way to manage it. The state still lives in a component, usually in useState or useReducer inside the provider. Context only removes the need to pass it by hand.

The rule: every consumer re-renders when the value changes

When the provider's value changes, React finds every component that reads that context and re-renders it, even if it is wrapped in React.memo, and even if the part of the value it uses did not change. React compares the old and new value with Object.is, the same check as === for objects. There is no built-in way to subscribe to one field of a context value.

Two graphs. Top: one context named App whose value holds user, theme, and cart, read by six consumers: Avatar and Menu read user, Toggle and Logo read theme, Badge and Total read cart. The cart changes and all six re-render, four of them for nothing. Bottom: three split contexts, User, Theme, and Cart, each read by its own two consumers. The cart changes and only Badge and Total re-render.one context, cart changesAppuser, theme, cartAvataruserMenuuserTogglethemeLogothemeBadgecartTotalcartsplit contexts, cart changesUserThemeCartAvatarMenuToggleLogoBadgeTotal6 re-renders before, 2 after
Context has no selectors: every consumer of a context re-renders when its value changes. Splitting by what changes together limits a cart change to the two components that read the cart.

Two traps follow from this rule. The first is putting many unrelated values in one context: { user, theme, cart, notifications }. Now a new notification re-renders every component that only wanted the theme.

The second trap is creating the value object inline, value={{ user, setUser }}. That is a new object on every render of the provider, so Object.is says "changed" every time, and all consumers re-render whenever the provider's parent re-renders, even if user did not change.

Three ways to make context cheap

  1. Memoize the value. Wrap the object in useMemo with its real dependencies, so its identity changes only when its contents do. (When the React Compiler is enabled, it usually memoizes the object for you.)
  2. Split contexts by how often they change. One context for the user, one for the theme, one for the cart. A component reads only the contexts it needs, so it only re-renders for those.
  3. Split state from actions. Put the setter or dispatch in its own context. Functions from useState and useReducer never change identity, so components that only trigger updates (an "Add to cart" button) never re-render because of them.
tsx
const CartState = createContext<CartItem[]>([]);
const CartDispatch = createContext<Dispatch<CartAction>>(() => {});

function CartProvider({ children }: { children: ReactNode }) {
  const [items, dispatch] = useReducer(cartReducer, []);
  return (
    <CartDispatch.Provider value={dispatch}>
      <CartState.Provider value={items}>{children}</CartState.Provider>
    </CartDispatch.Provider>
  );
}

function AddButton({ id }: { id: string }) {
  const dispatch = useContext(CartDispatch); // never re-renders for cart changes
  return <button onClick={() => dispatch({ type: "add", id })}>Add</button>;
}
State and dispatch in separate contexts; the button reads only dispatch.

What context is good for

Context shines for values that change rarely and are read widely: the theme, the locale, the signed-in user, feature flags, a design-system component's internal state (a Tabs root sharing the active tab with its Tab children). It is also the right way to inject dependencies, such as an API client or a store instance, because the identity of those never changes.

Context is a poor fit for values that change often and are read by many components that each need a different slice: a large cart, a spreadsheet's cells, a canvas editor's shapes. You can split and memoize, but you end up building a store by hand. That is the point where an external store with selectors earns its place.

Check your understanding · Exercise 5

A provider renders value={{ theme, toggle }} and its parent re-renders every second for a clock. theme has not changed. What do the 200 theme consumers do every second? Pick one.

External stores: state outside the tree

An external store keeps state in a plain JavaScript object outside the component tree. Components do not receive the state from a parent; they subscribe to it. When the store changes, it tells its subscribers, and each one decides whether the part it reads changed. Only those re-render.

This breaks the "changes flow down" rule from the mental model. A store change does not re-render the root and everything below it. It re-renders exactly the subscribed components whose selected value changed, plus their subtrees. That is the main reason stores exist, more than "global access", which context already gives you.

How React subscribes: useSyncExternalStore

Since React 18, every store connects to React through one hook, useSyncExternalStore(subscribe, getSnapshot). subscribe registers a callback and returns an unsubscribe function. getSnapshot returns the current value. React calls getSnapshot on render and again whenever the callback fires; if the result changed by Object.is, the component re-renders.

ts
type Listener = () => void;
function createStore<T>(initial: T) {
  let state = initial;
  const listeners = new Set<Listener>();
  return {
    get: () => state,
    set: (next: T) => { state = next; listeners.forEach((l) => l()); },
    subscribe: (l: Listener) => { listeners.add(l); return () => listeners.delete(l); },
  };
}

const cart = createStore({ items: [] as Item[], coupon: "" });

function useCart<S>(select: (s: { items: Item[]; coupon: string }) => S): S {
  return useSyncExternalStore(cart.subscribe, () => select(cart.get()));
}
// const count = useCart((s) => s.items.length);
A tiny store and a selector hook: the core of every store library.
A sequence diagram with four columns: Badge, React, Store, and AddButton. While rendering Badge, React calls getSnapshot, which returns 3, and renders Badge with 3. After the commit, React subscribes to the store with a callback, fn. Later AddButton calls addItem on the store. The store calls fn. React calls getSnapshot again, gets 4, sees it changed, and re-renders Badge with 4.BadgeReactStoreAddButtongetSnapshot()3render with 3subscribe(fn)addItem()fn()getSnapshot()4: changedre-render with 4
This is the whole contract of useSyncExternalStore: subscribe to hear about changes, getSnapshot to read the value, and a re-render only if the snapshot changed by Object.is.

Why a special hook, and not useState plus useEffect? With concurrent rendering, React can pause a render halfway and resume it later. If the store changes during the pause, one part of the screen could show the old value and another part the new one: this is called tearing. useSyncExternalStore checks the snapshot again before committing; if it changed, React redoes the render synchronously, so the screen stays consistent.

The main libraries, briefly

  • Redux Toolkit: one store, state changed only by dispatching actions to reducers (createSlice). Components read with useSelector. Strict and traceable, with time-travel DevTools. More code per feature than the others.
  • Zustand: a store is a hook made by create. You read with a selector, useStore((s) => s.count), and update with plain functions. Tiny (under 1 KB gzipped for the core), no provider needed.
  • Jotai: state is split into small atoms, and derived atoms compute from others. A component that reads one atom re-renders only when that atom changes. Good when state is many small independent pieces.
  • MobX: observable objects. Components wrapped in observer track which fields they read during render and re-render only when those change. Very fine-grained with little code; the tracking is implicit.
  • Signals: a value container that tracks its readers automatically. Solid and Preact Signals update the exact DOM text or attribute that read the signal, often with no component re-render at all. Angular (signal() and computed(), a developer preview in v16 and stable since v17) and Vue (ref, computed) use the same idea to decide what to update.

Decision The app has real shared client state that changes often. Which kind of store?

Redux Toolkit

One store; createSlice reducers; useSelector to read

Middleware and DevTools for logging and replay

Pros

  • Every change is an action you can log and replay
  • Clear rules scale to large teams
  • RTK Query covers server data in the same tool

Cons

  • More code per feature
  • Easy to overuse for local and server state

Zustand

create returns a hook; select a slice; call actions directly

Pros

  • Tiny and simple
  • No provider; works outside React too

Cons

  • Fewer conventions, so large teams must add their own
  • Module-level stores need care with SSR

Jotai (atoms)

Many small atoms; derived atoms compute from others

Pros

  • Fine-grained by default: readers of one atom ignore the others
  • Derived state is natural

Cons

  • Hard to see the whole state at once
  • Many atoms need naming discipline

Signals or MobX

Observable values; readers are tracked automatically

Pros

  • Finest-grained updates with the least code
  • Native in Solid, Angular, Vue, Preact

Cons

  • Implicit tracking is harder to debug
  • In React, needs a wrapper (observer) or a plugin

What we pick: For a typical React product team, Zustand for a few shared slices or Redux Toolkit when many teams share a large client state and want strict, logged updates. Pick Jotai when state is many small independent pieces (an editor's panels). In Solid, Angular, or Vue, use the framework's own signals or refs before adding a library.

A comparison matrix. Rows: Context, Redux Toolkit, Zustand, Jotai, Signals. Columns: has selectors, works without a provider, logs every change as an action, re-renders per field by default, needs little code. Context: no, no, no, no, yes. Redux Toolkit: yes, no, yes, yes with selectors, some code. Zustand: yes, yes, some with the devtools middleware, yes, yes. Jotai: atoms act as selectors, yes, some, yes, yes. Signals: tracked automatically, yes, no, yes, yes.selectorsnoprovideractionlogper-fieldlowcodeContextnonononoyesRedux ToolkityesnoyesyessomeZustandyesyessomeyesyesJotaiatomsyessomeyesyesSignalsautoyesnoyesyes
Context is cheap to write but has no selectors. Every store adds fine-grained subscriptions; they differ in ceremony, tooling, and whether tracking is explicit (selectors) or automatic (signals, and MobX, which behaves like the signals row).

Check your understanding · Exercise 6

Why do store libraries use useSyncExternalStore instead of copying the store into useState from a useEffect? Select all that apply.

Selectors and re-render boundaries

A store makes fine-grained updates possible, but it does not make them automatic. Each subscriber decides whether to re-render by comparing what it selected last time with what it selects now. That function, from the whole state to the small part one component needs, is a selector. Good selectors are the difference between a store that re-renders 3 components per change and one that re-renders 300.

Rule 1: select the smallest value you need

A cart badge needs a number. If it selects the whole cart, useStore((s) => s.cart), it re-renders when a coupon is applied, when a quantity changes, and when an item's note is edited. If it selects (s) => s.cart.items.length, it re-renders only when the count changes. Same store, same data, far fewer renders.

Rule 2: return stable results

The comparison is Object.is by default. A selector that builds a new array or object every time, such as (s) => s.tasks.filter((t) => t.done) or (s) => ({ a: s.a, b: s.b }), returns a new reference on every store change. The component re-renders on every change in the whole store, even unrelated ones.

With a bare useSyncExternalStore (and Zustand v5, which is built on it), a selector that returns a new object on every call can even cause an infinite render loop, because the snapshot never settles. React warns in development that the result of getSnapshot should be cached.

  • Select primitives where you can: numbers, strings, booleans, ids.
  • Use shallow equality for small objects: Zustand's useShallow, or shallowEqual as the second argument to Redux's useSelector.
  • Memoize derived selectors: Redux Toolkit's createSelector (Reselect) recomputes only when its inputs change and returns the same array otherwise. Jotai derived atoms and signals' computed do the same.

Try it below. Each component subscribes with a different selector. Change one field of the store and see which components re-render. Then switch the selectors from "the whole store" to "just what it needs".

Each component selects
A store with four fields: items 2, coupon none, name Ana, theme light. Five components subscribe: CartBadge, Coupon, CartTotal, Avatar, and ThemeIcon, each selecting its slice. Nothing has changed yet.storeitems: 2coupon: nonename: Anatheme: lightsubscribers, each with its selectorCartBadges.cartCoupons.cartCartTotals.cartAvatars.userThemeIcons.ui
Each subscriber selects its slice. A cart change renders all three cart components, even the one whose value did not change.
Change a field to see which components render.

Rule 3: draw re-render boundaries on purpose

A re-render boundary is a place in the tree where a change stops spreading. There are four ways to make one, and good designs use them deliberately at the places where state changes often.

  1. A smaller owner. Move the state into the component that changes, so its parent never re-renders.
  2. children from above. Elements created by a higher component are not re-rendered when a middle component's state changes.
  3. React.memo on a child with stable props, or the React Compiler, which memoizes components and values automatically.
  4. A subscription in the leaf. In a list of 1,000 rows, let each row select its own item by id, useStore((s) => s.byId[id]), instead of the list passing items down. Editing one item re-renders one row, not 1,000. This is why the normalization lesson stores entities by id.

Check your understanding · Exercise 7

A Zustand component uses useStore((s) => ({ name: s.user.name, avatar: s.user.avatar })). It re-renders on every store change, including unrelated ones. Which fixes work? Select all that apply.

Server data is not global client state

Open the global store of an older React app and you will often find that most of it is copies of server data: users, projects, tasks, each with its own isLoading, error, and lastFetched fields, plus hand-written thunks to fetch, refetch, and patch them. This is a common reason global stores grow out of control.

Server data is different from client state. Its true owner is the server; the browser holds a cache of it that can go stale at any moment. Treating it as client state means writing a cache by hand: deduplicating requests, deciding when data is stale, refetching when the tab regains focus, invalidating after a mutation, and garbage-collecting data nobody reads. Every team that does this by hand gets some of it wrong.

A query cache does this for you

Libraries such as TanStack Query, SWR, RTK Query, and Apollo Client keep server data in a cache keyed by the request: ["tasks", { projectId: 7 }]. Any component that needs those tasks calls useQuery with the same key. The first call fetches; the others share the result and the in-flight request. Each component subscribes to its own key, so it gets the fine-grained updates of a store without you writing one.

tsx
function useTasks(projectId: number) {
  return useQuery({
    queryKey: ["tasks", { projectId }],
    queryFn: () => api.getTasks(projectId),
    staleTime: 30_000, // treat as fresh for 30 s
  });
}

function TaskCount({ projectId }: { projectId: number }) {
  const { data } = useTasks(projectId);
  return <span>{data?.length ?? 0}</span>;
}
Two components, one request, one cache entry. No global store involved.

Once server data moves to a query cache, the global client store usually shrinks to a handful of fields: maybe the current workspace id, a few UI preferences, an undo stack. Often it disappears entirely, and context covers what is left.

An architecture diagram of the browser. Local component state holds drafts, toggles, and hover. The URL holds filters, sort, and page. Context holds user, theme, and locale. An optional, small global store holds things like an undo stack and a selection. A query cache holds server data such as tasks, projects, and the profile, and talks to the API server outside the browser, fetching and refetching.BrowserLocal statedrafts, toggles, hoverURLfilters, sort, pageContextuser, theme, localeGlobal storeundo stack, selectionQuery cachetasks, projects, profile, by keyAPIfetch, refetch
Most state has a better home than the global store. Once server data sits in a query cache and shareable state in the URL, the store, drawn dashed because it is optional, is usually small.

The other homes that are not the store

  • The URL for anything a user should be able to share or bookmark: filters, sort order, the selected tab, the page number. A later lesson covers the URL as state.
  • The form library or the form itself for drafts and validation. A later lesson covers form state.
  • The DOM for things the browser already tracks: focus, scroll position, the text in an uncontrolled input.
  • Storage (localStorage, IndexedDB) only for what must survive a reload, synced into one of the homes above at startup.

Check your understanding · Exercise 8

Which of these belong in a global client store rather than a query cache, the URL, or local state? Pick one.

When a global store is overkill, and what breaks when you use one

Many apps never need a global client store. Once server data lives in a query cache, shareable view state in the URL, and form drafts in forms, what remains is usually small: the signed-in user, the theme, a few preferences. Context handles those well, because they change rarely.

Signs you do need a store

  • Many distant writers and readers. A whiteboard's selection is changed by the canvas, the layers panel, and keyboard shortcuts, and read by the toolbar and the properties panel.
  • Frequent changes with wide reach. A trading screen's prices, a music player's position, a collaborative cursor list: context would re-render every consumer on every tick.
  • Logic that outlives components. An undo stack, an offline queue of pending writes, or a WebSocket feed that must keep running while screens mount and unmount.
  • A need to trace every change. Regulated or complex flows where logging and replaying actions (Redux DevTools) pays for itself in debugging time.
A flowchart of five questions. Does it come from the server? Yes: a query cache. No: is it shareable in a link? Yes: the URL. No: does it have only one reader? Yes: local state. No: are the readers close together in the tree? Yes: lift it or use composition. No: is it hot, with many writers? Yes: an external store with selectors. No: context.Comes from the server?yesQuery cachenoShareable in a link?yesURLnoOnly one reader?yesLocal statenoReaders close together?yesLift or composenoHot, with many writers?yesStore + selectorsnoContext
Ask the questions in order and stop at the first yes. Most state stops early; a global store is the second-to-last answer, not the first.

What goes wrong in production

The dumping ground. Every new feature adds a slice "just in case". After two years the store has 150 fields, half are read by one component, and nobody dares remove any. You notice it when a simple change needs edits in the slice, the selectors, the actions, and a test file. The fix is a rule in code review: state goes in the store only if two distant features read it.

State that should reset, but doesn't. Local state disappears when its component unmounts. Store state lives until something clears it.

Open an "edit item" dialog, cancel, open it for another item, and you see the first item's draft. Worse, log out and log in as another user, and the store still holds the first user's data. Clear user-scoped stores on logout, and keep per-screen state local so it resets by itself.

One store shared by every user on the server. A store created at module level (export const store = create(...)) is a single object in the server process. With server-side rendering, two requests from two users can read and write the same store, and one user's data can appear in another user's HTML. Create the store per request and hand it down through a provider; the Redux and Zustand docs for Next.js both describe this pattern.

Hot state in a wide context. A context holding mouse position or a timer, read by dozens of components, makes the whole page sluggish. You notice it as input lag and long tasks in the Performance panel. Move the hot value to a store with selectors, or keep it local to the one component that draws it.

What real teams do

A common pattern in well-run codebases in 2025: server data goes through a query cache (TanStack Query, RTK Query, Apollo, or Relay). Shareable view state goes in the URL, which the Next.js and React Router routers make easy. Global client state, if any, is a small Zustand, Jotai, or Redux Toolkit store, and everything else is local.

The Redux docs say the same thing directly. Their FAQ answers "Do I have to put all my state into Redux?" with no, and the style guide says state used in only one place should stay in component state.

Check your understanding · Exercise 9

A Next.js app creates its Zustand store at module level and fills it with the user's profile during server rendering. What is the risk? Pick one.

Using it in a system design interview

Almost every frontend system design question has a state section: the news feed, the chat app, the spreadsheet, the photo editor. Interviewers are not looking for a library name. They want to hear that you know where each piece of state lives, why, and what re-renders when it changes.

What to draw

Draw the component tree at the level of five to eight boxes: app shell, route, the main panels, and one repeated item (a message, a row, a card). Then label each box with the state it owns. Add a separate box for the query cache and, only if you need one, a box for the client store, with dashed lines to the components that subscribe.

  1. Sort the state first. Server data, URL state, form state, and client state. Name each item in one line.
  2. Place server data in a query cache keyed by request, with the stale time and what invalidates it.
  3. Place client state as low as possible, and say which components read it.
  4. Name the hot paths: what changes many times a second (typing, scrolling, dragging, live updates) and how you keep those changes from spreading.
  5. Only then name a library, and say why that one.

How deep to go, by level

LevelWhat a strong answer shows
JuniorLocal state by default, lifting to a common parent, and knowing that server data is cached rather than copied into a store
MidContext and its re-render rule, split contexts, a store with selectors, and the reasons for each choice in this design
SeniorRe-render boundaries on hot paths, normalized entities selected by id, tearing and useSyncExternalStore, SSR store per request, and when not to add a store at all

Think first

A spreadsheet has 10,000 visible cells. Editing one cell must update that cell and every formula that depends on it. Where does cell state live?

Check your understanding · Exercise 10

An interviewer asks how you would manage state in a chat app. What is the best first move? Pick one.

Summary

  • Where state lives decides what re-renders: in React, a change re-renders the owner and its whole subtree, or only the subscribers of a store.
  • Start local. Lift to the closest common parent only when a second reader appears, and derive values instead of copying them.
  • Before context, try composition: pass ready-made elements as children or slots so middle layers never see the data.
  • Every consumer of a context re-renders when its value changes. Memoize the value, split contexts by change rate, and keep dispatch in its own context.
  • External stores connect through useSyncExternalStore; their power is selectors, so select the smallest stable value and memoize derived ones.
  • Server data belongs in a query cache such as TanStack Query, shareable view state in the URL, and drafts in forms. What remains for a global store is usually small.
  • Use a store when many distant parts read and write the same client state, when it changes often, or when it must outlive screens. Create it per request on the server.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Why does every consumer of a React context re-render, even with React.memo, and how do you limit it?

Question 2

When would you introduce a global store like Redux or Zustand, and when is it overkill?

Question 3

What is useSyncExternalStore and what problem does it solve?

Question 4

A list of 1,000 rows re-renders completely when one row's item is edited. How do you fix it?

Question 5

Prop drilling: is it bad, and what are the alternatives?

Practice it

These design problems lean on local vs global state.

References

Read next