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.

Lesson 0614 min read#react#context#state-management#zustand

On this page

  1. Words used in this lesson
  2. Step 1: keep state next to where it is used
  3. Step 2: lift state up when siblings must agree
  4. Step 3: the problem of prop drilling
  5. Step 4: context
  6. Step 5: useReducer for state with many kinds of updates
  7. Step 6: when you reach for a store
  8. Choosing where state lives: a decision table
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. 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 (or use).
  • 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" }.
  • useReducer is 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 create and 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:

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

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

JavaScript
// 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 status string ("idle" | "sending" | "done") to booleans isSending and isDone that 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:

TEXT
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 it

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

  1. Pass the pieces, not the whole thing. Sometimes Layout could receive a ready-made <Avatar user={user} /> element as a prop or as children, so it does not need to know about user.
  2. Extract components and pass JSX as children. If a layer only passes data down, it often means it should take children instead.

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.

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

JavaScript
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>;}
TEXT
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):

JavaScript
<ThemeCtx.Provider value="light">  <CountCtx.Provider value={count}>    <SetCount.Provider value={setCount}>{children}</SetCount.Provider>  </CountCtx.Provider></ThemeCtx.Provider>
TEXT
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:

JavaScript
const stable = useMemo(() => ({ user }), [user]);return <C value={memoise ? stable : { user }}>{children}</C>;
TEXT
new object each time -> Consumer renders after 2 parent updates: 2useMemo on value  -> Consumer renders after 2 parent updates: 0

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

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

TEXT
state next to child: Heavy renders per click = 1child passed as children: Heavy renders per click = 0

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

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

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

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

TEXT
[ '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:

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

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

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

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

ZustandRedux Toolkit
Setupone create call, no providerstore, slices, provider
Stylefree, you design the shapestrict: actions and reducers, one way to do it
Toolssmall middleware set (devtools, persist)strong Redux DevTools, large ecosystem, RTK Query
Best forsmall to medium shared client state, quick winslarge teams, many developers who need one clear pattern, long-lived apps
Riskno structure by default, so teams must agree on onemore 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 inExample
Needed by one componentuseState in that componentinput text, open/closed menu
Needed by a few siblingsLift to the closest common parentaccordion active index
Can be calculated from other stateDo not store it. Calculate in rendercount = items.length, filtered list
Many fields, many kinds of updatesuseReducera form wizard, a task list
Needed by many distant components, rarely changesContexttheme, current user, language
Copy of server dataA query cache (lesson 5)user profile, product list
Shared client state, changes often, many readersA store with selectorscart, editor state
Should survive refresh or be shared as a linkThe URL (search params)page number, filters, selected tab
Should survive a visit, not sharedLocal 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

  1. Write a component with const [items, setItems] = useState([]) and const [count, setCount] = useState(0). Add items with a button. Show a bug where count and items.length disagree. Fix it by calculating count.
  2. Make a context AppCtx with value { theme, user } in one object where the provider has its own useState for both. Add a render counter to a component that reads only theme. Change user. Count renders. Then split the context into two and count again.
  3. Put a provider inside an App that has an unrelated tick state. Use a memo consumer. Count consumer renders when tick changes. Then wrap the value with useMemo. Count again.
  4. Write the tasks reducer above and test it in plain JavaScript with three actions, without React.
  5. 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.
  6. 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

  1. If you add an item and forget to update count, or update it twice, they disagree. The fix is const count = items.length with no second state.
  2. With one big context object, the theme reader renders again when you change user (my measurement: ThemeLabel went 1 render on a click that only changed the count). With two contexts, it does not.
  3. With a new object in the value each render, the consumer renders on each tick (my result: 2 renders for 2 updates). With useMemo on the value, it renders 0 times.
  4. Expected: toggled flips done, added appends, deleted removes. The point is that the reducer is a pure function, so a test needs no rendering.
  5. The component with a selector re-renders only when its field changes. The component with no selector re-renders on any change.
  6. (a) local useState unless 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) local useState, saved to local storage if you want drafts to survive. (f) local useState in 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 children before 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 memo does 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.
  • useReducer plus 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

Part of the study guide

Related series