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).
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.
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.
| State | Changes | Readers | Fine to keep high? |
|---|---|---|---|
| Theme (light or dark) | Rarely, by a click | Almost everything | Yes: rare changes, so a full re-render is cheap overall |
| Signed-in user | Once per session | Header, menus, guards | Yes, in context or a store |
| Search input text | Every keystroke | Search box, one list | No: keep it near the list |
| Drag position | 60 times a second | The dragged item | No: keep it in the item, or outside React |
| Cart items | A few times a visit | Badge, cart page, checkout | Yes, 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.
- Local state in the component that uses it (
useState,useReducer). - Lifted state in the closest common parent of the components that share it, passed down as props.
- Composition: the same lifted state, but the parent passes ready-made elements as
childrenor slots, so the middle layers never see the props. - Context: a value provided once near the top of a subtree and read anywhere below it, without props.
- 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.
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} />
</>
);
}
"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.
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.
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>);
}
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 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
- Memoize the value. Wrap the object in
useMemowith 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.) - 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.
- Split state from actions. Put the setter or
dispatchin its own context. Functions fromuseStateanduseReducernever change identity, so components that only trigger updates (an "Add to cart" button) never re-render because of them.
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>;
}
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.
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);
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 withuseSelector. 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
observertrack 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()andcomputed(), 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?
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.
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, orshallowEqualas the second argument to Redux'suseSelector. - 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'computeddo 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".
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.
- A smaller owner. Move the state into the component that changes, so its parent never re-renders.
childrenfrom above. Elements created by a higher component are not re-rendered when a middle component's state changes.React.memoon a child with stable props, or the React Compiler, which memoizes components and values automatically.- 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.
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>;
}
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.
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.
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.
- Sort the state first. Server data, URL state, form state, and client state. Name each item in one line.
- Place server data in a query cache keyed by request, with the stale time and what invalidates it.
- Place client state as low as possible, and say which components read it.
- Name the hot paths: what changes many times a second (typing, scrolling, dragging, live updates) and how you keep those changes from spreading.
- Only then name a library, and say why that one.
How deep to go, by level
| Level | What a strong answer shows |
|---|---|
| Junior | Local state by default, lifting to a common parent, and knowing that server data is cached rather than copied into a store |
| Mid | Context and its re-render rule, split contexts, a store with selectors, and the reasons for each choice in this design |
| Senior | Re-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?
Not in each cell's local state: formulas in other cells need to read it, and a sheet-level sum needs all of them.
Not in one context: every cell would re-render on every edit, 10,000 renders per keystroke.
In an external store holding cells by id (normalized), where each cell component selects its own value by id. A dependency graph, or derived atoms or signals per formula, recomputes only the cells that depend on the edited one.
The text being typed stays local in the cell editor until commit, so typing re-renders one input.
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
childrenor 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?
Context has no per-field subscription. When the provider's value changes by Object.is, React re-renders every component that reads that context, and React.memo only compares props, so it does not stop this.
Limit it by memoizing the value with useMemo so its identity changes only when its contents do, splitting one large context into several by how often each part changes, and putting dispatch or setters in their own context because their identity is stable.
If a value changes often and many components need different slices of it, use an external store with selectors instead.
Question 2
When would you introduce a global store like Redux or Zustand, and when is it overkill?
First move server data to a query cache, shareable state to the URL, and drafts to forms. Most of the old reasons for a store disappear.
Add a store when client-only state is read and written by many distant components, changes often enough that context would re-render too much, or must outlive the screens that use it (an undo stack, an offline queue).
It is overkill for state with one or two readers, for rarely changing values like theme or user (context is fine), and for server data.
Question 3
What is useSyncExternalStore and what problem does it solve?
It is the React 18+ hook that connects a component to a store outside React. It takes subscribe, which registers a change callback, and getSnapshot, which returns the current value.
It prevents tearing in concurrent rendering: if the store changes while React is rendering in slices, React detects the new snapshot and re-renders synchronously, so all components show the same version.
getSnapshot must return a cached value; returning a new object each call makes React think the store changed every time.
Question 4
A list of 1,000 rows re-renders completely when one row's item is edited. How do you fix it?
Store items normalized by id and let each row subscribe to its own item with a selector, useStore((s) => s.byId[id]), while the list selects only the array of ids.
Editing one item then changes one selected value, so one row re-renders. The id array keeps its identity, so the list does not.
If rows receive items as props instead, wrap rows in React.memo and keep unchanged item objects referentially stable. Virtualize the list if it is long.
Question 5
Prop drilling: is it bad, and what are the alternatives?
It is explicit and type-checked, so two or three levels are fine. It becomes a problem when long chains pass values the middle layers don't use, and each middle layer re-renders on changes.
First try composition: the owner builds the reader and passes it down as children or a slot, so middle layers only place it.
Use context when many components at different depths, often inside reusable components, need the same rarely changing value.
Practice it
These design problems lean on local vs global state.
References
- Sharing State Between Components (react.dev) Lifting state up, controlled and uncontrolled components.
- Passing Data Deeply with Context (react.dev) When to use context and what to try first.
- useSyncExternalStore (react.dev) The subscribe and getSnapshot contract, and caching snapshots.
- Deriving Data with Selectors (Redux docs) Selector rules and memoization with createSelector.
- Organizing State (Redux FAQ) Do I have to put all my state into Redux? No.
- Zustand documentation Selectors, useShallow, and per-request stores with Next.js.
- Jotai documentation Atoms and derived atoms.
- TanStack Query: Does this replace client state? Why server data does not belong in a global store.
Read next
- Kinds of stateServer data, UI toggles, the URL, form drafts, and live streams are five different kinds of state, each with its own home: knowing which is which is the first step of every frontend design.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.