React in Depth

State and Rendering: What Really Happens When State Changes

State, re-renders, batching, how React compares trees, keys, and the render-then-commit steps, with a traced example.

Lesson 0115 min read#react#state#rendering#reconciliation

On this page

  1. Words used in this lesson
  2. The three steps: trigger, render, commit
  3. useState: remembering a value
  4. Batching: one render for many updates
  5. Setting the same value, and "one more render"
  6. Objects and arrays: never mutate state
  7. When a parent renders, its children render too
  8. How React matches the old tree to the new one
  9. Keys: telling React who is who
  10. Strict Mode: twice in development
  11. Lazy initial state
  12. Where should state live?
  13. Try it
  14. Interview questions
  15. Key takeaways
  16. What's next

In one line: state is a value React remembers for a component between renders. When you change it, React calls your component again with the new value, builds a new description of the screen, compares it with the old one, and updates only what differs. Every state surprise (the "stale" number, the input that shows the wrong text) comes from not seeing those steps.

In lesson 0 components were pure functions of props. They could show things, but not remember anything. A like button needs to remember a number. A text box needs to remember what you typed. That memory is state.

This lesson is the one that makes the rest of React easy, so I will trace it in detail. We use real logs to see exactly when React calls your function, how many times, what it does when you set state to the same value, why setCount(count + 1) three times adds only one, and why a wrong key puts your text in someone else's input box. The next lesson, Effects and the Lifecycle, builds on this.

What I ran / checked. Node 22.22.0, React 19.3.0, react-dom 19.3.0, jsdom 29.1.1 as a fake browser, esbuild 0.28.2 for JSX, all run in React's development build. Each snippet mounts a component with createRoot inside jsdom, simulates a click, and prints what React did. I wrapped updates in React's act() so renders finish before I print. I read these pages of the reactjs/react.dev repository (raw markdown, in my own words below): State: A Component's Memory, Render and Commit, State as a Snapshot, Queueing a Series of State Updates, Updating Objects in State, Preserving and Resetting State, Rendering Lists, and the Strict Mode and useState references. Log line formats are mine. Counts such as "renders = 5" come from counters I added. Your numbers will match mine for these programs, but warning text can change between React versions.

Words used in this lesson

  • State is data a component remembers between renders. You get it from useState.
  • Hook is a function whose name starts with use that lets a component use a React feature, like state. We go deeper in lesson 3.
  • Re-render means React calling your component function again, usually because state changed.
  • Trigger, render, commit are the three steps of an update. Trigger: something asks for an update. Render: React calls your components. Commit: React changes the real page.
  • Snapshot means the props and state a render sees are fixed for that render, like a photo. They do not change while the function runs.
  • Batching means React groups several state changes into one re-render.
  • Updater function is a function you pass to a setter, like setCount(c => c + 1). React calls it with the latest value.
  • Reconciliation is how React compares the new tree of elements with the old one to decide what to change.
  • Mount, update, unmount are the three life moments of a component: first shown, shown again with new data, removed.
  • Key is an identity tag for list items (and for any component) that tells React which one is which.
  • Immutable update means making a new object or array instead of changing the old one.

The three steps: trigger, render, commit

React always updates a page in the same three steps. The official docs use a restaurant picture: a customer orders (trigger), the kitchen cooks (render), the waiter brings the dish (commit). I will use that picture too, but with real logs.

Trigger. Two things start an update: the first render of the app (root.render(...)), and a state change in a component (a call to a setter like setCount).

Render. React calls your component functions to get the new description (elements). On the first render it starts from the root. On an update it starts from the component whose state changed, and calls it and its descendants. Render here does not mean drawing. It means "calling your functions". Nothing on the screen has changed yet.

Commit. React takes the result and updates the real DOM. On the first render it creates all the nodes. On later renders it changes only what is different. The browser then paints.

I built a tiny tree to see the order, with logs in the render body, in useLayoutEffect, in useEffect and in a ref callback. (Effects are lesson 2. For now just read them as "code React runs after the commit".)

JavaScript
function Child({ n }) {  L(`render Child n=${n}`);  useLayoutEffect(() => { L(`layoutEffect Child n=${n}`); return () => L(`cleanup layout Child n=${n}`); });  useEffect(() => { L(`effect Child n=${n}`); return () => L(`cleanup effect Child n=${n}`); });  return <span ref={(el) => { L(`ref Child ${el ? "attached" : "detached"}`); }}>{n}</span>;}function Parent() {  const [n, setN] = useState(0);  L(`render Parent n=${n}`);  useEffect(() => { L(`effect Parent n=${n}`); });  return <div><button onClick={() => setN(n + 1)}>+</button><Child n={n} /></div>;}

First render, then one click on the button:

TEXT
--- mountrender Parent n=0render Child n=0ref Child attachedlayoutEffect Child n=0effect Child n=0effect Parent n=0--- clickrender Parent n=1render Child n=1ref Child detachedcleanup layout Child n=0ref Child attachedlayoutEffect Child n=1cleanup effect Child n=0effect Child n=1effect Parent n=1

Read the mount part from top to bottom:

  1. Render phase: Parent is called, then Child. Parent first, because Parent's result contains Child.
  2. Commit phase: the DOM is created, then the ref is attached, then the layout effect, then (after the browser can paint) the normal effects. Notice that effects of a child run before its parent's. The children are finished first.

On the click, the same story: render Parent, render Child, then commit. The old effects' cleanups run before the new effects. In the commit part you also see the ref callback "detached" then "attached" again. That is because I wrote the ref as an inline arrow function, which is a new function on every render, so React detaches the old one and attaches the new one. It is harmless here, but it shows how React treats new function identities.

Why does this matter?

  • Render must be pure (lesson 0), because it can run without a commit following it.
  • Effects see the finished DOM, because they run after commit.
  • If React finds that nothing changed in the output, it skips the DOM work. A re-render does not mean the DOM is touched.

A common wrong belief is "a re-render rebuilds the whole page". It does not. Render is cheap (calling functions and making objects). The commit step touches only the differences. The cost of a re-render is mostly your own code running again.

useState: remembering a value

JavaScript
import { useState } from "react";function Counter() {  const [count, setCount] = useState(0);  return <button onClick={() => setCount(count + 1)}>Clicked {count}</button>;}

useState(0) returns a pair. The first item is the current value. The second is a setter function. The 0 is the initial value, used only on the first render. A normal variable can not do this job, for two reasons: it is thrown away each time the function runs, and changing it does not tell React to re-render. State fixes both.

How does React know which useState call is which, when it has no variable names? By order. The first useState call in a component is slot 1, the second is slot 2, and so on. That is why hooks must be called in the same order every time, the "rules of hooks" in lesson 3.

State is a snapshot

This is the idea most people get wrong first. Each render has its own fixed count. Setting state does not change the variable in the code that is already running. It asks React to render again with a new value.

I wrote a counter with four buttons and printed render counts. The important one adds 5 and then logs count right away:

JavaScript
<button onClick={() => { setCount(count + 5); console.log("  inside handler, count is still:", count); }}>+5</button>

The sequence of clicks was: "+3 wrong", "+3 right", "same" twice, "+5". Output:

TEXT
after mount: count = 0 renders = 1after +3 wrong: count = 1 renders = 2after +3 right: count = 4 renders = 3after same value: renders = 4after same value again: renders = 4  inside handler, count is still: 4after +5: count = 9 renders = 5

Look at the last two lines. The handler set count + 5, but the next line still sees 4. The variable count in that handler belongs to the render that created the handler. It is a photograph. The new value 9 shows up in the next render.

Why "+3 wrong" adds only 1

The first button does this:

JavaScript
setCount(count + 1);setCount(count + 1);setCount(count + 1);

In this render count is 0, so all three lines mean setCount(1). Three requests for the same answer. The result is 1, and the output above agrees: count = 1.

The fix is an updater function: instead of a value, pass a function that receives the latest queued value.

JavaScript
setCount(c => c + 1);setCount(c => c + 1);setCount(c => c + 1);

React puts the three functions in a queue and runs them in order during the next render: 1, then 2, then 3. The output: count = 4 (we started from 1, so 1 + 3 = 4). The rule I use: if the new value depends on the old value, use the updater form.

Do not read state right after setting it and expect the new value. The old value is still in the closure. If you need "the new value" for something else in the same handler, compute it into a local variable first: const next = count + 5; setCount(next); doSomething(next);.

Batching: one render for many updates

If a click handler calls setA(...) and setB(...), does React render twice? No. React waits until the handler finishes and then renders once with both changes. That is called batching.

In older React (17 and before) batching only happened inside React event handlers. Since React 18 it happens everywhere: timeouts, promises, native events. I tested the case that used to be different, two setters inside setTimeout:

JavaScript
function Batch() {  const [a, setA] = useState(0); const [b, setB] = useState(0);  log.push(`Batch a=${a} b=${b}`);  return <button onClick={() => {    setTimeout(() => { setA(x => x + 1); setB(x => x + 1); }, 0);  }}>go</button>;}
TEXT
two setStates in a setTimeout: [ 'Batch a=1 b=1' ]

One render, and it saw both values already updated. There is never a render with a=1 b=0. This is why you can safely keep related values in separate useState calls without them going out of sync on screen.

Setting the same value, and "one more render"

Look again at the "same" button. After "+3 right" the count was 4. The button calls setCount(count) with the same 4. The output says renders = 4, up from 3, so React did render once more. The second click on "same" did nothing: renders = 4 again.

The reason: React compares the new value with the old one using Object.is. If they are equal, it skips re-rendering the children and does not touch the DOM. But sometimes React needs to call the component one more time to be sure, before it stops. The docs say this plainly: React may call your component once more before bailing out, and it will not go deeper into the tree. So: same value means no DOM work and no child work, but you may still see one extra call of the component itself. Do not build logic that relies on counting renders.

Objects and arrays: never mutate state

The comparison is Object.is, which for objects checks if it is the same object in memory, not if the contents are equal. This causes the most common state bug of beginners. I stored an object in state and changed a field of it directly:

JavaScript
function Mut() {  const [user] = useState({ name: "Ada" });  log.push("Mut render name=" + user.name);  return <button onClick={() => { user.name = "Grace"; }}>{user.name}</button>;}

I clicked the button. The log and the page:

TEXT
after mutating state object: [] | DOM shows: Ada

No render happened (the log is empty) and the page still says Ada. The object was changed in memory, but React was never told, so nothing re-rendered. Worse, if some other update triggers a render later, the page will suddenly jump to "Grace", which looks random.

The rule: treat state as read-only. To change it, make a new object or array and pass that to the setter.

JavaScript
// object: copy with spread, then override the fieldsetUser({ ...user, name: "Grace" });// array: addsetItems([...items, newItem]);// array: removesetItems(items.filter(i => i.id !== id));// array: change onesetItems(items.map(i => i.id === id ? { ...i, done: true } : i));

The ... (spread) copies the old fields into a new object, and you then override what changed. The new object has a different identity, so Object.is says "changed". Methods like push, splice, sort and reverse mutate the array. Use [...items].sort(), toSorted() or toReversed() (available in Node 20+ and modern browsers) instead. For deep nesting, people use a helper such as Immer, but the idea is the same.

When a parent renders, its children render too

Here is a fact that surprises people: when a component re-renders, all its children re-render by default, even if their props did not change. React does not compare props unless you ask it to. I measured it with a plain child and a child wrapped in memo (lesson 7 covers memo fully):

JavaScript
function Child({ label }) { log.push("Child " + label); return <span>{label}</span>; }const MemoChild = memo(function MemoChild({ label }) { log.push("MemoChild " + label); return <span>{label}</span>; });function Parent() {  const [n, setN] = useState(0);  log.push("Parent n=" + n);  return <div><button onClick={() => setN(n + 1)}>{n}</button><Child label="plain" /><MemoChild label="memo" /></div>;}
TEXT
mount: [ 'Parent n=0', 'Child plain', 'MemoChild memo' ]click: [ 'Parent n=1', 'Child plain' ]

After the click, Parent and Child ran again, although label="plain" is the same. MemoChild did not, because memo says "skip me if my props are shallowly equal". This is fine. Re-rendering is cheap for most components. But it explains why people reach for memo when a big subtree re-renders on every keystroke. It also shows that "my component re-rendered" does not mean "my props changed".

How React matches the old tree to the new one

After render, React has two trees of elements: the old and the new. Reconciliation is the diff between them. React uses two simple rules:

  1. Same type at the same position means "this is the same thing, update it". Its state and DOM node are kept.
  2. Different type at the same position means "this is a different thing, destroy the old one and create a new one". Its state is lost.

"Position" means the place in the tree (first child of a div, and so on), and also the key if there is one.

State belongs to a place in the tree

Here is a real demonstration. A text field Field has its own state (what you typed). Parent changes its label prop from "Ada" to "Bob". Same component type at the same place:

JavaScript
function Field({ label }) {  const [v, setV] = useState("");  return <label>{label}<input value={v} onChange={e => setV(e.target.value)} /></label>;}// Parent renders:  <Field label={who} />   // who: "Ada" -> "Bob"

I typed "hello", then switched who:

TEXT
same type, same spot, label changed: Bob | input = "hello"

The label says Bob, but the input still has "hello". React saw "same type, same spot" and kept the state. Sometimes that is what you want. For a chat app, switching from Ada's chat to Bob's chat with Ada's draft message still in the box is a bug.

Fix: give the component a key that changes with the identity. A different key means a different component, so React throws the old one away and creates a fresh one:

JavaScript
<Field key={who} label={who} />
TEXT
with key={who}: Bob | input = ""

State reset. This is the cleanest way to "reset state when a prop changes": use a key, not an effect. Remember it for lesson 2.

Defining a component inside a component

This one bites many people. I defined Inner inside Outer:

JavaScript
function Outer() {  const [n, setN] = useState(0);  function Inner() {                    // a NEW function on every Outer render!    const [t, setT] = useState("");    return <input value={t} onChange={e => setT(e.target.value)} />;  }  return <div><button onClick={() => setN(n + 1)}>{n}</button><Inner /></div>;}

I typed "typed", then clicked the button (which re-renders Outer):

TEXT
Inner defined inside, before parent re-render: "typed"after parent re-render: ""

The text vanished. On each render of Outer, Inner is a brand new function, so React sees a different type at the same position, and destroys and recreates it. Its state resets. The fix is simple: define components at the top level of the file. Treat "component defined inside a component" as a bug in code review.

Keys: telling React who is who

In a list, "position" is not enough, because items move. A key gives each item a stable identity so React matches them correctly. Here is the bug I promised in lesson 0. A list of three rows, each row has its own text box (state). I typed into Ada's box, then removed Ada from the list. I did this twice: with key={index} and with key={name}:

JavaScript
{names.map((n, i) => (  <Row key={useIndexKey ? i : n} name={n} onRemove={() => setNames(names.filter(x => x !== n))} />))}
TEXT
[key=index] before : Ada:"typed in Ada"  Bob:""  Cy:""[key=index] after  : Bob:"typed in Ada"  Cy:""[key=name ] before : Ada:"typed in Ada"  Bob:""  Cy:""[key=name ] after  : Bob:""  Cy:""

With index keys, after Ada was removed, Bob's row shows "typed in Ada". Why? React saw key 0, 1, 2 become key 0, 1. Key 0 still exists, so it kept that row's state (the typed text) and just changed the name prop to Bob. The state stayed at the position and the data moved. With name keys, React knew the Ada row was gone and removed its state with it.

This is exactly why a unique, stable ID is the right key. The index is a position. A position is not an identity.

Index keys do not show any bug until the list changes in the middle, and then the bug appears only for items with state (inputs, toggles, expanded panels) or animations. It passes casual testing. Use real IDs.

Strict Mode: twice in development

I mentioned Strict Mode in lesson 0. Here are exact numbers for state. I wrapped a component in <StrictMode> and counted calls of the render body, of the useState initializer function, and of an updater function:

JavaScript
function S() {  rend++;  const [n, setN] = useState(() => { init++; return 0; });  return <button onClick={() => setN(c => { upd++; return c + 1; })}>{n}</button>;}
TEXT
after mount: renders 2 initializer calls 2after one click: renders 4 updater calls 2

Mount: the body ran twice and the initializer ran twice. One click: the body ran twice more (2 + 2 = 4), and the updater ran twice. In every case, the second call exists to see if your code is pure. If your updater function pushes to an array or logs to a server, you will notice the extra call. This is only in development; in production each runs once. So:

  • Do not put side effects in the body, an initializer, or an updater function.
  • If your own code "does something twice" only in development, look for impurity before you look for a React bug.

Lazy initial state

Sometimes the initial value is expensive to compute, like reading from localStorage:

JavaScript
const [todos, setTodos] = useState(readTodosFromStorage());     // runs on EVERY renderconst [todos, setTodos] = useState(() => readTodosFromStorage()); // runs once

In the first line, readTodosFromStorage() is called on every render, and React uses the result only the first time. In the second line you pass a function, and React calls it only on mount. This is the "initializer" in the log above. Use the function form when the initial value is not trivial.

Where should state live?

A small set of rules I use, which will carry into lesson 6:

  • Keep state minimal. Do not store anything you can compute from other state or props. For example, store items and filter, and compute visibleItems in the render. No second state, no sync bugs. We use this again in lesson 2 ("you might not need an effect").
  • Put state in the lowest component that needs it, and move it up only when two siblings need to share it. This is called "lifting state up". The parent holds the state and passes values and setters down.
  • Do not copy props into state (useState(props.value)) unless you really want the initial value only. The copy will not update when the prop changes.

Try it

Run these in a Vite React project, or any sandbox with React 19.

  1. Make a counter with a button that calls setCount(count + 1) three times. Click once. What is the count? Change the code to get +3.
  2. After setCount(count + 5), add alert(count) on the next line. What does it show? Why?
  3. Store { name: "Ada" } in state and change user.name = "Grace" on a button click. Does the page change? Fix it using spread.
  4. Make a list of three rows, each with its own <input>. Use key={index}. Type in the first box and delete the first row. Which row shows your text? Switch to a name-based key and repeat.
  5. Render <Field label={who} /> where Field has input state. Change who. Does the input text stay? Add key={who}.
  6. Wrap the app in <StrictMode> and put console.log("render") in a component. How many logs on load?
  7. Make two components, a plain child and a memo child, inside a parent with a counter. Which logs when the parent state changes?

Answers

  1. The count goes up by 1, because the three calls all say setCount(0 + 1). Use setCount(c => c + 1) three times to get +3.
  2. It shows the old value, because the handler's count is a snapshot. The new value appears in the next render.
  3. No, the page stays the same, because React was not told. Use setUser({ ...user, name: "Grace" }).
  4. With the index key, the second person's row (now first) shows the text you typed for the first. With name keys, the text leaves with the deleted row.
  5. Yes, the text stays (same type at the same spot). With key={who} it resets.
  6. Twice in development, once in production. Strict Mode double renders.
  7. The plain child logs. The memo child does not, since its props did not change.

Interview questions

What happens, step by step, when I call setState? (Must know)

React puts the update in a queue and schedules a re-render. It does not change the variable in the running function. When it renders, it calls my component with the new state, gets a new tree of elements, and compares it to the previous one. That is the render phase and it is pure and has no DOM changes. Then in the commit phase React applies the differences to the DOM, runs layout effects, lets the browser paint, and then runs passive effects (useEffect). Trigger, render, commit.

Why does setCount(count + 1) called three times add only one? (Must know)

Because count is a snapshot from the current render. All three calls compute the same number from the same old value, so they queue three requests for the same result. To chain updates I use the updater form setCount(c => c + 1), which React runs in order with the latest queued value. I use the updater form whenever the new value depends on the previous one.

Is setState synchronous or asynchronous? (Must know)

It is better described as "queued". Calling the setter does not change the state variable in the current render, and the render happens after the handler finishes. So reading the state right after the call gives the old value. I do not need to say "async" because it is not a promise. It is just that the update applies on the next render.

What is batching? (Must know)

React groups several state updates into one re-render. Since React 18 this happens automatically everywhere, including timeouts, promises and native handlers, not only React events. I tested two setters inside a setTimeout and saw one render with both values updated. It means that I never see a half-updated screen from two related setState calls, and I do not need to worry about render counts for multiple setters.

Why should I not mutate state? (Must know)

React decides whether to re-render by comparing the old and new state with Object.is. For objects and arrays, that means identity. If I mutate in place, the identity is the same, so React sees no change, does not render, and the screen is stale. Also, memo and dependency arrays rely on identity, so mutation breaks them too. I copy with spread, map and filter, and give React a new object.

Does a re-render rebuild the whole DOM? (Trap)

No. Render means calling my functions to produce a description. React diffs it against the previous description and applies only the differences to the DOM in the commit phase. A re-render can finish with zero DOM changes. When someone says "it re-renders too much", the real cost is usually my own code running, plus child components being called. Not DOM rebuilding.

When a parent re-renders, do the children? (Must know)

By default yes, all descendants are called again, even if their props are the same, because React does not compare props unless I use memo. I printed it: after a parent state change the plain child logged, the memo child did not. This is usually fine. I only add memo after I measured a real cost, and I know it needs stable props (lesson 7).

Explain how React uses key and why index keys are risky. (Must know)

React matches elements by type and position, and a key overrides position with an identity. In a list, the key tells React which old item corresponds to which new item, so state and DOM nodes go with the right data when items are added, removed or reordered. With an index key, the identity is the position. When I removed the first row, the second row took key 0 and inherited the first row's input text. With unique IDs the state left with the deleted row.

How do I reset a component's state when a prop changes? (Senior signal)

Give the component a key that is tied to the thing that changed, for example <Profile key={userId} />. A changed key means React treats it as a new component, destroys the old one and mounts a fresh one with initial state. This is better than an effect that calls setState when the prop changes, because the effect version renders once with stale state first, and it is more code and more bugs. I showed this with an input: key={who} cleared the text.

Why does defining a component inside another component cause bugs? (Trap)

Every render of the outer component creates a new function object for the inner component. React compares the element type by identity, sees a different type in the same position, and unmounts the old subtree and mounts a new one. State resets, DOM nodes are recreated, and inputs lose focus. In my test, text typed into the inner input disappeared whenever the parent re-rendered. I define components at the module's top level and pass data as props.

What does Strict Mode do to state code, and what should I learn from it? (Senior signal)

In development it calls component bodies, useState initializers and updater functions twice, to flush out impure code. In my run, one mount gave 2 renders and 2 initializer calls, and one click gave 2 more renders and 2 updater calls. It does nothing in production. If something breaks because it ran twice, I made a side effect in a place that should be pure, like a mutation in an updater, and I should fix it, not turn off Strict Mode.

When do I use useState(() => ...)? (Senior signal)

When the initial value is expensive to compute, such as parsing JSON from localStorage. With useState(compute()) the computation runs on every render and React throws away the result after the first. With useState(() => compute()) React calls it only on mount. It is a small optimisation, but it also avoids doing something with side effects, like reading storage, on every render.

Key takeaways

  • Render, in React, means calling your components. Commit means changing the real DOM. A re-render can end with no DOM changes.
  • State is a snapshot. The variable in your handler does not change when you call the setter. The new value arrives in the next render.
  • Use the updater form (setX(x => ...)) when the new value depends on the old one. React batches updates into one render.
  • Never mutate state. Create a new object or array, so Object.is can see the change.
  • A parent re-render re-renders its children unless they are wrapped in memo.
  • Same type at the same position keeps state, different type resets it. Use key for identity: IDs for list items, and a changing key to reset a component.
  • Strict Mode runs renders, initializers and updaters twice in development to catch impure code.

What's next

Next: Effects and the Lifecycle, lesson 2. State handles things inside React. Effects are for connecting a component to the outside world, like a server, a timer or a chat connection. I will show the real order of setup and cleanup, the exact Strict Mode pattern in a browser, a fetch race condition reproduced and fixed, and when you should not use an effect at all. Practise now: build a to-do list with add, remove and "done" toggles, using immutable updates and IDs as keys.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series