React in Depth
Context, State Management and Choosing Where State Lives
Lifting state, context and its re-render cost, reducers, Zustand and Redux Toolkit at a glance, and a simple decision table.
On this page
- Words used in this lesson
- Step 1: keep state next to where it is used
- Step 2: lift state up when siblings must agree
- Step 3: the problem of prop drilling
- Step 4: context
- Step 5: useReducer for state with many kinds of updates
- Step 6: when you reach for a store
- Choosing where state lives: a decision table
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: every piece of state should live in the lowest place that still covers everyone who needs it. Start with useState next to the component, lift it up only when two siblings need it, use context to avoid passing it through ten layers, and bring in a store like Zustand or Redux Toolkit only when you have real shared client state. And remember the price of context: every component that reads it re-renders whenever its value changes. I measured that cost below.
Many beginners ask "which state library should I use?" as the first question. That is the wrong order. The first question is "where does this piece of state belong?" Once you can answer that, most apps need very little beyond useState and a few contexts. A store becomes useful only for a smaller set of cases, and you will see exactly which ones.
This is lesson 6 of a ten-lesson series. It builds on state and rendering from State and Rendering (lesson 1) and on the server-state ideas from Data Fetching, Caching and Server State (lesson 5). The next lesson, Performance, uses the render counts from this lesson to fix slow screens. If you want the same topic from the interview angle in another series, see State Management Decisions: Local, Context, Server Cache, Global Store.
What I ran / checked. Everything ran on Linux with Node.js v22.22.0, react 19.3.0, react-dom 19.3.0, zustand 5.0.15, @reduxjs/toolkit 2.13.0, react-redux 9.3.0, @testing-library/react 16.3.3, @testing-library/user-event 14.6.7 and vitest 5.0.3 with jsdom. I counted renders by putting a counter in each component's body, and I pasted the real numbers. Render counts are exact and repeat every run. I did not measure time in this lesson. Sources I read (own words): the React docs pages "Passing Data Deeply with Context", "Scaling Up with Reducer and Context", "Choosing the State Structure" and "Sharing State Between Components" (raw markdown from reactjs/react.dev on GitHub), the README of pmndrs/zustand, and the "Getting Started" page of Redux Toolkit from the reduxjs/redux-toolkit repository. Could not do: the Redux Toolkit root README link I tried returned 404, so I used its docs page instead. I did not test Zustand with server rendering, and I did not read the Redux DevTools docs. I give no GitHub star counts because I did not check them.
Words used in this lesson
- State is data that React remembers between renders and that makes the screen update when it changes.
- Lifting state up means moving state from a child to the closest common parent, so two children can share it through props.
- Prop drilling means passing a prop through many components that do not use it, only to reach one deep component.
- Derived state is a value you can calculate from other state or props. You should calculate it during render and not store it.
- Context is a React feature that lets a parent give a value to every component below it, without passing props.
- A provider is the component that sets the context value for its subtree.
- A consumer is any component that reads a context with
useContext(oruse). - A reducer is a plain function
(state, action) => newState. It says how state changes for each kind of action. - An action is a small object that says what happened, such as
{ type: "added", text: "milk" }. useReduceris the React hook that holds state and updates it with a reducer.- A store is an object outside React that holds state and lets components subscribe to it.
- A selector is a function that picks one part of the store, such as
s => s.count. A component re-renders only when what its selector returns changes. - Zustand is a small state library: you call
createand get a hook. - Redux Toolkit (RTK) is the standard, batteries-included way to write Redux: slices, immutable updates with a friendly syntax, and a configured store.
- Immutable update means making a new object or array instead of changing the old one.
- Single source of truth means each fact is stored in one place only.
Step 1: keep state next to where it is used
The best state is local. If only one component needs a value, useState inside that component is the right and complete answer:
function SearchBox() { const [text, setText] = useState(""); return <input value={text} onChange={(e) => setText(e.target.value)} />;}Nobody else cares about text. Putting it in a global store would only add code and make it harder to delete later. A good habit: start local, move up only when forced.
Step 2: lift state up when siblings must agree
Say you have two panels that must stay in sync: an "accordion" where only one section may be open at a time. Each panel cannot own its own isOpen, because then they cannot know about each other. The fix from the React docs is to move the state to the closest common parent and pass it down as props:
function Accordion() { const [activeIndex, setActiveIndex] = useState(0); return ( <> <Panel title="About" isActive={activeIndex === 0} onShow={() => setActiveIndex(0)}>...</Panel> <Panel title="Etymology" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)}>...</Panel> </> );}Now there is one fact ("which index is open") in one place. The panels are controlled by the parent. This pattern is called lifting state up. It is the first tool, and it is free.
Do not store what you can calculate
Before you add any state, ask "can I calculate this from something I already have?" The React docs call this avoiding redundant state. A classic mistake:
// Two states that must always agree. They will not, one day.const [items, setItems] = useState([]);const [count, setCount] = useState(0); // always items.length// Better: one state, one calculated valueconst [items, setItems] = useState([]);const count = items.length;Every extra state is a chance for the two to disagree. The same goes for a filtered list: keep the full list and the filter text in state, and compute visible = items.filter(...) during render. If that calculation is slow, you can memoise it (lesson 7), but you still do not store it as state. Also do not copy props into state ("const [name, setName] = useState(props.name)") unless you really want a starting value that then lives on its own. Otherwise the copy does not update when the prop changes.
Two more rules from the docs "Choosing the State Structure" page that I use all the time:
- Group related state. If two values always change together (x and y position), put them in one object.
- Avoid contradictions. Prefer one
statusstring ("idle" | "sending" | "done") to booleansisSendingandisDonethat can both be true.
Step 3: the problem of prop drilling
Lifting state up has a cost. If a deep component needs the value, you pass it through every layer between:
App (has user) └─ Layout (user) <- does not use it, just passes it └─ Sidebar (user) <- does not use it either └─ Menu (user) └─ Avatar (user) <- finally uses itThis is prop drilling. A little of it is fine. It is even good, because the data flow is easy to follow. But when it gets painful, the docs suggest two things to try before context:
- Pass the pieces, not the whole thing. Sometimes
Layoutcould receive a ready-made<Avatar user={user} />element as a prop or aschildren, so it does not need to know aboutuser. - Extract components and pass JSX as
children. If a layer only passes data down, it often means it should takechildreninstead.
Only after that, use context.
Step 4: context
Context is the "teleport". A provider high in the tree sets a value, and any component below can read it, however deep. Three steps from the docs: create the context, provide a value, use it.
import { createContext, useContext, useState } from "react";const ThemeContext = createContext("light"); // "light" is the default valuefunction App() { const [theme, setTheme] = useState("dark"); return ( <ThemeContext value={theme}> <Page /> <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>toggle</button> </ThemeContext> );}function Button() { const theme = useContext(ThemeContext); // reads the closest provider above return <button className={theme}>OK</button>;}Note: in React 19 you can write <ThemeContext value={...}> directly. In older code you will see <ThemeContext.Provider value={...}>. Both mean the same, the short form is the new one. The default value ("light") is used only when no provider is above the component.
Context is good for values that many distant components need and that change rarely: the theme, the current user, the language, the current route. The React docs list exactly these use cases: theming, current account, routing, and managing state together with a reducer.
React 19 also lets you read context with use(ThemeContext), which, unlike useContext, can be called inside if and loops. For most code useContext is still what you will see.
The cost: every consumer re-renders
Here is the part that surprises people. When the value of a context changes, React re-renders every component that reads that context, and it does so even if that component only needs a small part of the value and even if it is wrapped in memo. Memo does not block context updates, because the update comes from inside the component, not from its props.
I measured it. I made one "big" context that holds two unrelated things, theme and count. Two label components read it: ThemeLabel reads only the theme, CountLabel reads only the count. Both are wrapped in memo. I also added an Unrelated component that reads no context. Then I clicked a button that changes only the count:
const Big = createContext(null);function BigProvider({ children }) { const [theme, setTheme] = useState("light"); const [count, setCount] = useState(0); const value = { theme, setTheme, count, setCount }; // a new object every render return <Big.Provider value={value}>{children}</Big.Provider>;}mount {"ThemeLabel":1,"CountLabel":1,"Unrelated(no context)":1}after 1 click {"ThemeLabel":1,"CountLabel":1} (ThemeLabel did not need to change)After one click on the count button, ThemeLabel rendered again, although the theme did not change. It reads the same context object, the object changed, so React re-rendered it. Unrelated did not render, because it reads no context and the children element of the provider is the same one from the parent.
With 3 consumers this does not matter. With 300 consumers and a context that changes on every keystroke, it matters a lot.
Fix 1: split the context
Put things that change at different speeds in different contexts. I split the big context into ThemeCtx, CountCtx and SetCount (the setter never changes):
<ThemeCtx.Provider value="light"> <CountCtx.Provider value={count}> <SetCount.Provider value={setCount}>{children}</SetCount.Provider> </CountCtx.Provider></ThemeCtx.Provider>split after 1 click {"CountLabel":1}Now only the component that reads the count re-rendered. The button that only uses the setter did not re-render, and the theme label did not either. A neat trick: state in one context, the dispatch function in another. The setter or dispatch function from useState or useReducer keeps the same identity forever, so components that only trigger changes (buttons) never re-render because of state changes.
Fix 2: memoise the value if the provider re-renders for other reasons
Look at the line const value = { theme, setTheme, count, setCount }. It creates a new object on every render of the provider. Even if theme and count did not change, a new object means a changed value, so every consumer re-renders. This happens when the provider's parent re-renders for an unrelated reason.
I tested that. The parent has an unrelated tick state. The provider holds a user. I click "tick" twice and count how often the memoised Consumer renders:
const stable = useMemo(() => ({ user }), [user]);return <C value={memoise ? stable : { user }}>{children}</C>;new object each time -> Consumer renders after 2 parent updates: 2useMemo on value -> Consumer renders after 2 parent updates: 0Two useless renders became zero. This is the one place where useMemo is nearly always worth it: the value you give to a context provider, when the provider can re-render without its real data changing. (With the React Compiler, which lesson 7 explains, this is done for you.)
Fix 3: the children trick
Sometimes you do not need context at all. Put state in a component, and pass the heavy part in as children. A component that receives children from above does not re-render when its own state changes, because the children element was created by the parent and is the same object.
function Counter({ children }) { const [n, setN] = useState(0); return <div><button onClick={() => setN(n + 1)}>n={n}</button>{children}</div>;}// <Counter><Heavy /></Counter>I compared this with the version where Heavy is written inside the component that owns the state:
state next to child: Heavy renders per click = 1child passed as children: Heavy renders per click = 0Same screen, same state, but no render for the child. This is why putting state "lower" or passing children is often the best and cheapest performance fix. It costs nothing and needs no memo.
Trap: Many people say "context is bad for performance, never use it". That is wrong. Context is fine for rarely changing values. The problem is a single context that changes often and is read by many components. Split it, memoise it, or use a store with selectors for that case.
Trap: Context is not a state manager by itself. It only transports a value. The state still comes from useState or useReducer in the provider. People confuse the two in interviews.
Step 5: useReducer for state with many kinds of updates
When one piece of state has many ways to change (add, toggle, delete, rename), setState calls spread all over your components get messy. A reducer gathers all of the rules in one function:
function tasksReducer(tasks, action) { switch (action.type) { case "added": return [...tasks, { id: action.id, text: action.text, done: false }]; case "toggled": return tasks.map(t => t.id === action.id ? { ...t, done: !t.done } : t); case "deleted": return tasks.filter(t => t.id !== action.id); default: throw new Error("Unknown action: " + action.type); }}A reducer is a pure function: same input, same output, no fetching, no Math.random(), and it must not change the old state. Because it is plain, you can test it without React:
tasksReducer([{ id: 1, text: "a", done: false }], { type: "toggled", id: 1 })// [{"id":1,"text":"a","done":true}]Then connect it to the whole tree with two contexts, as in the "Scaling Up with Reducer and Context" page:
const TasksContext = createContext(null);const DispatchContext = createContext(null);function TasksProvider({ children }) { const [tasks, dispatch] = useReducer(tasksReducer, [{ id: 1, text: "milk", done: false }]); return ( <TasksContext value={tasks}> <DispatchContext value={dispatch}>{children}</DispatchContext> </TasksContext> );}const useTasks = () => useContext(TasksContext);const useTasksDispatch = () => useContext(DispatchContext);Any component below can now read the tasks with useTasks() or send an action with useTasksDispatch(). I ran a small app: one task "milk", click "add" (adds "eggs"), then tick the "eggs" box:
[ 'milk:false', 'eggs:true' ]Why two contexts? Components that only dispatch (the "add" button) do not need to re-render when the list changes. This is the same split trick as above. Custom hooks useTasks and useTasksDispatch hide the context details, so you can later swap the implementation (for example, to Zustand) without touching components. This pattern, useReducer + two contexts, is a complete state solution for a medium app and needs no library.
Step 6: when you reach for a store
A store is state that lives outside React, and components subscribe to the part they need. You reach for one when:
- Many distant components need to read and write the same client state, and it changes often (a shopping cart, an editor's selection, a multi-step wizard).
- You want components to re-render only for the slice they read, without hand-splitting contexts.
- You need to read or change the state outside React (in an event listener, a WebSocket handler, a test).
- You want tools like time travel debugging or middleware (logging, saving to local storage).
Zustand
Zustand's README describes it as a small, fast and scalable bearbones state solution with a hooks-based API. The core is create, which gives you a hook:
import { create } from "zustand";const useStore = create((set) => ({ a: 0, b: 0, incA: () => set((s) => ({ a: s.a + 1 })), incB: () => set((s) => ({ b: s.b + 1 })),}));function ShowA() { const a = useStore((s) => s.a); return <p>a {a}</p>; }function ShowB() { const b = useStore((s) => s.b); return <p>b {b}</p>; }function ShowAll() { const s = useStore(); return <p>all {s.a}/{s.b}</p>; }No provider is needed, which the README points out ("Use the hook anywhere, no providers are needed"). The argument to the hook is a selector. Notice ShowAll has no selector, so it reads the whole store. I called incA() and counted renders:
zustand after incA(): {"ShowA(selector a)":1,"ShowAll(no selector)":1}ShowB did not render, because its selector s => s.b returned the same value. ShowAll did, because the whole store object changed. This is the main Zustand rule: always select the smallest piece you need. If you need several pieces, the README shows useShallow to compare them one by one so the component re-renders only when one of them changes. (Vitest also printed a warning that the update was "not wrapped in act". That is a test-only warning because I called incA() from outside a React event. In the app it is fine.)
Trap (I hit this): in Zustand 5, a selector that returns a new object or array every time, like useStore(s => ({ a: s.a })), does not just render too often. In my test it threw Maximum update depth exceeded, and React logged "The result of getSnapshot should be cached to avoid an infinite loop". Select a single value, or wrap the selector in useShallow for object and array picks.
Two notes about Zustand: set merges the object you give it into the state at the first level (so you do not spread the old state yourself), but you must still update nested objects immutably. And the store is a plain JavaScript object, so useStore.getState() works in any file, with no hook rules. That is a real advantage for code outside components.
Redux Toolkit
Redux has the same core idea (one store, actions, reducers), with more structure and a strong set of dev tools. Redux Toolkit is the modern way to write it. The "Getting Started" page says its purpose is to be the standard way to write Redux logic, created to answer three complaints: setup is complicated, you need too many extra packages, and there is too much boilerplate. It also includes RTK Query, an optional data fetching tool in the same package.
import { configureStore, createSlice } from "@reduxjs/toolkit";import { Provider, useSelector, useDispatch } from "react-redux";const slice = createSlice({ name: "c", initialState: { a: 0, b: 0 }, reducers: { incA: (s) => { s.a++; }, // looks like mutation, Immer makes it a safe copy incB: (s) => { s.b++; }, },});const store = configureStore({ reducer: slice.reducer });function RA() { const a = useSelector((s) => s.a); return <p>a {a}</p>; }function RB() { const b = useSelector((s) => s.b); return <p>b {b}</p>; }function Btn() { const dispatch = useDispatch(); return <button onClick={() => dispatch(slice.actions.incA())}>rinc</button>;}// <Provider store={store}><RA /><RB /><Btn /></Provider>createSlice lets you write s.a++, which looks like a change to the old state. Inside, a library called Immer records what you did and produces a new object, so the old state is untouched. This is the only place in React land where "mutating" is safe, and only inside a createSlice reducer. I clicked the button:
redux after dispatch incA: {"RA":1} state {"a":1,"b":0}Only RA re-rendered, since useSelector also works with selectors. The state changed exactly as expected.
Zustand or Redux Toolkit?
Both do the job. How I choose:
| Zustand | Redux Toolkit | |
|---|---|---|
| Setup | one create call, no provider | store, slices, provider |
| Style | free, you design the shape | strict: actions and reducers, one way to do it |
| Tools | small middleware set (devtools, persist) | strong Redux DevTools, large ecosystem, RTK Query |
| Best for | small to medium shared client state, quick wins | large teams, many developers who need one clear pattern, long-lived apps |
| Risk | no structure by default, so teams must agree on one | more code for small things |
My honest rule: if you cannot say why you need a store, you do not need one. If you do, start with the smallest one that works for your team.
Choosing where state lives: a decision table
Use this top to bottom, and stop at the first row that fits:
| The state is... | Put it in | Example |
|---|---|---|
| Needed by one component | useState in that component | input text, open/closed menu |
| Needed by a few siblings | Lift to the closest common parent | accordion active index |
| Can be calculated from other state | Do not store it. Calculate in render | count = items.length, filtered list |
| Many fields, many kinds of updates | useReducer | a form wizard, a task list |
| Needed by many distant components, rarely changes | Context | theme, current user, language |
| Copy of server data | A query cache (lesson 5) | user profile, product list |
| Shared client state, changes often, many readers | A store with selectors | cart, editor state |
| Should survive refresh or be shared as a link | The URL (search params) | page number, filters, selected tab |
| Should survive a visit, not shared | Local storage (wrapped in try/catch) | draft text, last sort order |
The URL row is the one people forget. A filter and a page number in the URL give you the back button, bookmarks and shared links for free.
Trap: The most common mistake in big apps is copying server data into a global store "so that every component can read it" and then writing code to keep the copy in sync. A query cache already is that store. Putting API data in Redux by hand duplicates the cache and brings back all the stale-data bugs from lesson 5.
Try it
- Write a component with
const [items, setItems] = useState([])andconst [count, setCount] = useState(0). Add items with a button. Show a bug wherecountanditems.lengthdisagree. Fix it by calculatingcount. - Make a context
AppCtxwith value{ theme, user }in one object where the provider has its ownuseStatefor both. Add a render counter to a component that reads onlytheme. Changeuser. Count renders. Then split the context into two and count again. - Put a provider inside an
Appthat has an unrelatedtickstate. Use amemoconsumer. Count consumer renders whentickchanges. Then wrap the value withuseMemo. Count again. - Write the tasks reducer above and test it in plain JavaScript with three actions, without React.
- Rewrite a small counter with Zustand. Add two components that read different fields. Use selectors. Then change one to
useStore()with no selector and see what happens to its render count. - For each of these, decide where the state lives: (a) a search text, (b) logged-in user, (c) products from
/api/products, (d) the selected page number of a list, (e) an unsaved draft of a blog post, (f) which of two tabs is open on one settings page.
Answers
- If you add an item and forget to update
count, or update it twice, they disagree. The fix isconst count = items.lengthwith no second state. - With one big context object, the
themereader renders again when you changeuser(my measurement: ThemeLabel went 1 render on a click that only changed the count). With two contexts, it does not. - With a new object in the value each render, the consumer renders on each
tick(my result: 2 renders for 2 updates). WithuseMemoon the value, it renders 0 times. - Expected: toggled flips
done, added appends, deleted removes. The point is that the reducer is a pure function, so a test needs no rendering. - The component with a selector re-renders only when its field changes. The component with no selector re-renders on any change.
- (a) local
useStateunless something else needs it. (b) context, since many distant components need it and it rarely changes. (c) a query cache, not a store. (d) the URL search params, so it survives reload and can be shared. (e) localuseState, saved to local storage if you want drafts to survive. (f) localuseStatein the settings page.
Interview questions
Where should a piece of state live? (Must know)
As low in the tree as possible, in the lowest component that covers everyone who needs it. If one component uses it, it is local useState. If siblings need to agree, I lift it to their closest common parent. If it can be calculated from other state, I do not store it. If many distant components need it and it rarely changes, I use context. For server data, a query cache. For shared client state that changes often, a store with selectors. And if it should survive reload or be shareable, the URL. I move up only when forced, because higher state means more components that can break and more things re-rendering.
What is prop drilling and when is it a problem? (Must know)
Prop drilling is passing a prop through several components that do not use it, only to reach a deep one. A little is fine and keeps data flow clear. It is a problem when it makes components know about data they do not care about, and each change touches many files. Before context, I try to pass finished JSX or children so the middle layers do not need the data. If that is not enough, I use context.
Why does a context change re-render components even when they are wrapped in memo? (Trap)
Because memo only compares props. A context read is not a prop. When the context value changes, React marks every consumer to re-render, and memo cannot stop it. I measured it: two memo-wrapped labels read one big context, I changed only the count, and the theme label rendered too. To fix it I split the context into pieces that change at different speeds, memoise the provider value, or use a store with selectors.
Your context provider value is { user, login, logout }. What is wrong with it? (Senior signal)
It creates a new object every time the provider renders, so every consumer re-renders whenever the provider's parent re-renders, even when user did not change. In my test, a memo consumer rendered 2 times for 2 unrelated parent updates, and 0 times when I wrapped the value in useMemo. I would wrap the value in useMemo (and the functions in useCallback), or let the React Compiler do it. If the functions change rarely and the user changes often, I might split them into two contexts.
When would you use useReducer instead of useState? (Must know)
When one state has many related fields or many kinds of updates, or when the next state depends on the old one in a complicated way. A reducer puts all of the update rules in one pure function, which I can test without React. It also pairs well with context: I put the state in one context and dispatch in another, so components that only send actions do not re-render when the state changes, because dispatch has a stable identity.
Is context a replacement for Redux? (Trap)
No, not by itself. Context only carries a value down the tree. The state still comes from useState or useReducer. Context also re-renders every consumer on any change of the value and has no selectors, so it is not good for frequently changing shared state. useReducer plus context is enough for many medium apps. A store like Redux Toolkit or Zustand adds selectors (render only the slice you read), access outside React, and tools such as devtools and middleware.
How does a Zustand component decide when to re-render? (Senior signal)
It re-renders when the value returned by its selector changes. In my test, useStore(s => s.a) re-rendered only when a changed, while useStore() with no selector re-rendered on every change because it reads the whole store. If I need several fields, I use useShallow so a new object with the same values does not cause a render. The rule is to select the smallest piece. In Zustand 5, selecting a new object each time without useShallow is worse than extra renders. I tried useStore(s => ({ a: s.a })) and it threw "Maximum update depth exceeded" (React also logged that the result of getSnapshot should be cached).
What does Redux Toolkit give you over plain Redux? (Senior signal)
It removes the boilerplate. configureStore sets up the store with good defaults. createSlice generates action creators and the reducer together. And it lets me write code that looks like mutation (state.a++), because Immer turns it into a safe immutable update. It also ships RTK Query for data fetching. The Redux Toolkit docs describe it as the standard way to write Redux logic. The mutation-looking syntax is only safe inside createSlice and createReducer. It is not a general React feature.
Why is it a bad idea to copy API data into a global store? (Trap)
Because you now have two copies of server data, the real cache (or the server) and your store. You must write code to fetch, refresh, invalidate and sync them, which is exactly what a query library already does. The copy goes stale and users see old data. I keep server state in a query cache and keep the store for real client state, like UI choices and the current cart before checkout.
What is derived state, and why avoid storing it? (Must know)
Derived state is a value you can calculate from state or props, like the length of a list or a filtered list. Storing it creates a second source of truth that must be kept in sync, and sooner or later they disagree. I calculate it during render. If the calculation is expensive I memoise it with useMemo, but it is still not state. The same goes for copying a prop into state: the copy does not follow later prop changes.
What are the cheapest ways to reduce re-renders caused by state, before using memo? (Senior signal)
Move state down, closer to where it is used, so fewer components re-render. Or pass the heavy part as children, so it keeps the same element identity and does not re-render when the parent's state changes. I measured the second one: with state next to the heavy child it rendered once per click, and with the child passed as children it rendered zero times. Both cost nothing and need no memo, so I try them first.
Why would you keep a filter in the URL instead of state? (Senior signal)
State in useState is lost on reload and cannot be shared. State in the URL survives reload, works with the back button, can be bookmarked, and can be sent to a colleague. So filters, sorting, page numbers and selected tabs usually belong in the URL's search params. I keep truly private UI state (like a hover or a draft in progress) in component state.
Key takeaways
- Start with local
useState. Lift state to the closest common parent only when siblings must agree. - Do not store what you can calculate. One fact lives in one place.
- Try moving state down and passing
childrenbefore reaching for context or memo. In my test the child went from 1 render per click to 0. - Context re-renders every consumer when its value changes, and
memodoes not stop it. I measured an unrelated label rendering after a click. Split contexts, and put state and dispatch in different contexts. - Memoise the provider value when the provider can re-render for unrelated reasons: 2 useless renders became 0 in my test.
useReducerplus context is a complete solution for many apps, and the reducer is a pure function you can test without React.- A store (Zustand or Redux Toolkit) is for shared client state that changes often. Always use selectors. In my tests, components with a selector rendered only for their own slice.
- Server data belongs in a query cache, filters belong in the URL, and a store holds real client state.
What's next
Next: Performance: Memoization, Lists, Code Splitting and the React Compiler. You have now seen exactly when components render and why. In the next lesson we use the Profiler to find slow renders, then fix them with memo, useMemo and useCallback (with before and after counts), a 10,000-row list rendered with and without virtualisation in a real browser, lazy loading with chunk sizes, transitions, and what the React Compiler actually generates. To practise now, do exercises 2 and 3 above and write down your render counts.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- “The API Is Fast but the UI Is Slow”: How to Solve It · Scenario Debugging
- Frontend and React Scenario Bank · Scenario Debugging
- Unnecessary Re-renders: Finding and Fixing Them · Scenario Debugging
Part of the study guide
Related series