Skip to content
Software Atlas

How React Works

What actually happens between setState and pixels: elements, render vs commit, reconciliation and keys, the three reasons a component re-renders, batching, StrictMode, and concurrent rendering — with live React demos you can poke.

⏱ ~50 min read14 sections12 interactive widgetsJSX · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

You've built a page or two with React. You've also probably hit one of these: you sort a list and the text you typed jumps into the wrong row; or you add a console.log and discover a component ran 300 times while you typed one word. Both make sense once you know what React actually does between setState and the screen.

In one sentenceYou describe what the screen should look like for the current data. When the data changes, React calls your function again, compares the new description with the old one, and changes only the parts that differ.

People write this as UI = f(state): the screen is a function of the data. You never write "find the third <li> and change its text". You write a function that returns the whole list, and React works out the edits for you.

One update, start to finish
React's half is the middle four boxes. You write the function; React does the rest.
  1. 1
    State changes
    setCount(1)
  2. 2
    Render
    React calls your components
  3. 3
    Elements
    a new description of the UI
  4. 4
    Reconcile
    compare with last time
  5. 5
    Commit
    patch only what changed in the DOM
  6. 6
    Paint
    the browser draws pixels
Render phase (2–4): pure calculation. Nothing on screen changes yet.
Commit phase (5): React touches the real DOM, all at once.
Browser (6): style, layout, paint — see Browser Rendering.
1

Rendering is calling your function

A Render means React calls your component to get a fresh description. It does not touch the page. It can happen often, and that's usually fine.
2

Matching is by type and position

React decides "same thing as last time" by what kind of element sits at each position — or by its key. Get that wrong and state ends up attached to the wrong row (§05).
3

Three reasons to re-render

A component re-renders when its own state changes, when its parent re-renders, or when a context it reads changes. "Its props changed" isn't on the list (§06).

Here's the smallest example. Each click asks React for a new render; React calls Counter again, sees that only the number changed, and updates one text node.

Counter.jsx
import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);         // ← data this component remembers
  return (
    <button onClick={() => setCount(count + 1)}>  {/* ← asks React for a re-render */}
      Clicked {count} times
    </button>
  );
}

The rest of this topic opens up each box of the diagram: what an element is (§02), the two phases (§03), how the comparison works (§04–05), why components re-render and how to stop it (§06–07), and what React 18 and 19 changed (§08–11). For the hooks themselves in depth, read React Hooks, Honestly next.

Foundations§02

Components, elements and instances

In one sentenceA component is your function. An element is the small object it returns. An instance is the placed copy on the page, and that's where state lives.

Most confusion about React comes from mixing up three words. Using the film set from §01:

WordWhat it isFilm-set versionWho creates itHow long it lives
ComponentA function, like CounterThe script for one kind of sceneYou, onceAs long as your code
ElementA plain object: { type, props, key }A storyboard sketchYour component, on every renderUntil the next render replaces it
InstanceReact's record for one placed copy, holding its stateThe built scene on setReact, when the element first appears at a spotUntil it's removed from that spot

Two <Counter /> on a page are one component, two instances (two separate counts), and a fresh pair of elements on every render.

JSX is just function calls

Browsers can't run JSX. Your build tool rewrites every tag into a call to a function from react/jsx-runtime, and that call returns an element. Pick an example below. The compiled code is what this site's own build tool (Vite 8) produces, and the object on the right was created by React 19 in your browser just now.

JSX → function call → plain object
The middle column is real output of this site's build tool; the right column is the real object React 19 just created in your browser.
1 · You write (JSX)
<h1 className="title">Hello, {name}</h1>
2 · The build turns it into
import { jsxs as _jsxs } from "react/jsx-runtime";

_jsxs("h1", {
  className: "title",
  children: ["Hello, ", name]
});
3 · Calling it returns
{
  $$typeof: Symbol(react.transitional.element),
  type: "h1",
  key: null,
  props: {
    className: "title",
    children: [
      "Hello, ",
      "Ada"
    ]
  }
}
The type is the string "h1": a built-in DOM tag. Text and {name} became the children array.
react.transitional.element is just React 19's internal name for the element marker; it has nothing to do with transitions (§10). React also attaches a few hidden debugging fields to elements in development builds; they're left out here.

Three things to notice:

  1. type is either a string or your function. A lowercase tag ("h1", "div") means "a built-in DOM element". A capitalized name means "call this component". That's why components must start with a capital letter: <greeting /> would be treated as an unknown HTML tag.
  2. Creating an element doesn't run your component. <Greeting name="Ada" /> only records "Greeting, with these props". React decides when (and whether) to call Greeting.
  3. key isn't a prop. It's stored next to props, for React's own use. We'll see why in §05.

Elements are cheap, disposable descriptions

An element is a few fields in an object. Making thousands of them costs microseconds, and nothing on the page changes when you do. They're also treated as Immutable: in development builds React freezes them (Object.isFrozen(element) is true; we checked with React 19.3), so code that tries to edit one fails loudly. To "change" an element you render again and return a new one.

That's the trick behind the whole library: describing the UI is cheap, so React lets you re-describe all of it on every change, and only pays for the real DOM work where the description actually differs.

State belongs to instances, not to functions

When Counter runs, useState doesn't keep the number inside the function (a function's local variables vanish when it returns). React keeps it in the instance for that spot in the tree, and hands it back on the next call. Internally React calls these instance records fibers.

So far: your function returns a tree of elements. Next, what React does with that tree, and when the page actually changes.

Foundations§03

Render, then commit

In one sentenceEvery update happens in two phases: render (React calls your functions to get a new description) and commit (React changes the real DOM). Effects run after the commit.
PhaseWhat happensYour code that runsTouches the DOM?
RenderReact calls your components and compares the result with last timeThe component bodyNo
CommitReact applies the differences to the DOMNothing of yours (then layout effects, see the note below)Yes — and only here
EffectsFollow-up work outside ReactuseEffect callbacksOnly if you do it

Step through one click on a counter. Keep an eye on which column changes.

One click, step by step
Step with ← →. Watch which column changes: your code, the DOM, or the screen.
Code
1function Counter() {
2 const [count, setCount] = useState(0);
3 useEffect(() => {
4 document.title = `Clicked ${count} times`;
5 });
6 return (
7 <button onClick={() => setCount(count + 1)}>
8 Clicked <Label n={count} />
9 </button>
10 );
11}
12
13function Label({ n }) {
14 return <b>{n}</b>;
15}
Render phaseyour functions run
React isn't rendering
Real DOMchanged only in the commit
<button>Clicked <b>0</b></button>
Effects & screen
screen shows: Clicked 0
The page is showing Clicked 0. React remembers the elements it produced last time and the DOM nodes it created for them. Now the user clicks.
1 / 8

The same thing, for real

That walkthrough is a drawing. Here is the same component running live, with a timestamped log. Click it a few times.

The same update, live
This is real React 19 on this page. Each click clears the log and records what happens, in order.
Click the button.

What the log proves, every time:

  1. setCount doesn't change count. Right after calling it, the handler still sees the old value. The new value only exists in the next render (§08 explains why).
  2. During render, the DOM still shows the old value. Label runs while the page still says the old number. The DOM changes later, in the commit.
  3. useLayoutEffect sees the new DOM, and runs before the browser paints. useEffect runs after it.

Rendering must be pure

Because the render phase is just a calculation, React treats it as one. It may call your component more than once for one update (StrictMode does it on purpose, §09), call it and throw the result away (concurrent rendering, §10), or skip calling it entirely (§07). That's only safe if your component is a pure function of its props and state.

✕ Side effects in the body
function Profile({ user }) {
  document.title = user.name;          // touches the page during render
  fetch(`/api/seen/${user.id}`);       // may fire 2× or for a render that's never shown
  return <h1>{user.name}</h1>;
}
✓ Compute in the body, act in handlers and effects
function Profile({ user }) {
  const title = user.name.trim();      // pure calculation: fine
  useEffect(() => {
    document.title = title;            // after the commit
  }, [title]);
  return <h1>{title}</h1>;
}

A Side effect is anything that changes the world outside the return value: the DOM, the network, a variable outside the component, the console. Event handlers and effects are where they belong. Rendering is for deciding what the UI should look like.

Practitioner§04

Reconciliation: comparing two trees

In one sentenceReact compares the new tree with the old one position by position. Same type at the same position: keep it and update it. Different type: throw the old one away (state and all) and build a new one.

After a render, React holds two trees of elements: last time's and this time's. Reconciliation is the job of turning the difference into DOM operations.

Two words we'll use a lot from here on: React mounts a component when it creates a new instance (and its DOM) at a spot, and unmounts it when it removes that instance, throwing away its state.

A perfect "smallest number of edits between two trees" algorithm is far too slow for UI work (it grows roughly with the cube of the number of nodes). So React uses two simple rules that are fast (one pass over the tree) and right almost all the time:

  1. Elements of different types produce different trees. If a div became a section, or <Counter> became <Timer>, React doesn't look inside. It removes the old subtree and builds the new one.
  2. Within a list of children, a key says which item is which. Without keys, React goes by position (§05).

Walk through one comparison

Reconciling two renders
Same component, new props. Step with ← →.
Code
1function Profile({ user, editing }) {
2 return (
3 <div className="card">
4 <h2>{user.name}</h2>
5 <Counter />
6 {editing ? <form>…</form> : <p>{user.bio}</p>}
7 </div>
8 );
9}
Previous render
div.card
├ h2 “Ada”
├ Counter count = 3
└ p “Writes code”
This render
div.card
├ h2 “Ada L.”
├ Counter
└ form
Decision → DOM work
not compared yet
Two renders of the same component. Before: user.name = "Ada", editing = false. After: the name changed and editing became true. React compares the trees top-down, position by position.
1 / 6

At each position React asks one question first: is the type the same?

Before → after, at the same positionWhat React doesState below it
div → divKeep the DOM node, update only changed attributesKept
Counter → CounterKeep the instance, call it with the new propsKept
p → formRemove p and everything inside, create formLost
Counter → TimerUnmount Counter (its effects clean up), mount TimerLost
something → null / falseRemove itLost

Try it: same position, different type

Same position, different type
Click +1 a few times in each box, then press ⇄. A green flash means React created a brand-new instance (fresh state).
A · Same type, different props
{fancy
  ? <TapCounter label="fancy" />
  : <TapCounter label="plain" />}
count = 0plain version
B · Different wrapper type
{wrap
  ? <section><TapCounter /></section>
  : <div><TapCounter /></div>}
count = 0inside <div>
C · Same type, new key
<TapCounter key={person}
            label={person} />
count = 0Ada's counter
A keeps the count. Both branches put a TapCounter at the same spot, so React sees the same component with new props.
B resets it. The parent's type changed (div ↔ section), so the whole subtree was destroyed and rebuilt.
C resets it on purpose. A different key means "a different item", so React builds a fresh one (§05).

Case B is the one that bites in real apps. Code like this looks harmless:

function Layout({ isMobile }) {
  if (isMobile) {
    return <div className="stack"><SearchBox /></div>;
  }
  return <main className="grid"><SearchBox /></main>;   // ← different type than <div>
}

Rotate a phone, isMobile flips, the wrapper type changes, and the user's half-typed search disappears. Keep the same element type and change its props instead:

function Layout({ isMobile }) {
  return (
    <div className={isMobile ? "stack" : "grid"}>   {/* ← same type in both cases */}
      <SearchBox />
    </div>
  );
}

What "position" means

Position is the path from the root: "the second child of the first child of App". A conditional that sometimes renders nothing keeps the positions after it stable, because false/null still occupy a slot:

<div>
  {showBanner && <Banner />}   {/* slot 0: Banner or false */}
  <Form />                     {/* slot 1: always Form, so its state survives */}
</div>

That's why toggling the banner doesn't reset the form. React compares slot 1 with slot 1, whatever happened in slot 0.

Practitioner§05

Keys: telling React who is who

In one sentenceA key tells React "this item is the same one as last time". Without a good key, React matches list items by position, and state stays at the position while your data moves.

In a list, "position" is a bad way to identify things. Sort the list and every item changes position. React needs another way to say "Apple's row is still Apple's row", and that's the key.

Watch the bug happen

Each row below has two kinds of state: the text box is an uncontrolled input (its text lives in the DOM element), and the ★ is React state (useState) inside the row. Each row also remembers which fruit it was created for. Start with key={index}.

Keys playground
Type something into Apple's notes and star it. Then add, sort or remove — and watch where your text and star end up.
{items.map((item, index) => (
  <FruitRow key={index} item={item} />
))}
  • 🍎 Appleinstance created for: Apple
  • 🍌 Bananainstance created for: Banana
  • 🍒 Cherryinstance created for: Cherry
With index keys, React matches rows by position: “key 0 before” = “key 0 now”, whatever fruit is there. The fruit names (props) move, but each row's typed text and ★ (state in the DOM and in React) stay at their old position. A green flash marks a newly created row — notice where it appears.

Here's the "Add to top" case with index keys, written out:

KeyBeforeAfterWhat React concludes
0🍎 Apple🥝 Kiwi"Row 0 got new props" → keeps Apple's row, input text and ★
1🍌 Banana🍎 Apple"Row 1 got new props" → keeps Banana's row
2🍒 Cherry🍌 Banana"Row 2 got new props" → keeps Cherry's row
3—🍒 Cherry"New key" → creates a new row at the bottom

React did exactly what it was told: it compared key 0 with key 0. The fruit names are props, so they updated. The typed text and the ★ belong to the row instances, which didn't move. The data and the state are now out of sync, and nothing warns you.

With key={item.id} the same action reads: "key 4 is new → create it and insert it at the top; keys 1, 2, 3 are the same items in the same order → leave them alone". One DOM insert, and every row keeps its own text.

Rules for keys

1

Stable

The same item gets the same key on every render. An id from your data is ideal.
2

Unique among siblings

Two children of the same parent can't share a key. Keys only need to be unique within that one list.
3

Not random

key={Math.random()} is a new key every render, so React destroys and recreates every row every time.
// ✓ ids from the data
{todos.map((todo) => <TodoRow key={todo.id} todo={todo} />)}

// ✓ no id? make one ONCE, when the item is created, not during render
const addTodo = (text) =>
  setTodos((ts) => [...ts, { id: crypto.randomUUID(), text }]);

Keys outside lists: resetting state on purpose

A key works on any element, not just list items. Changing it tells React "this is a different thing now", so React throws away the old instance and builds a fresh one with fresh state. That's case C from §04, and it's the cleanest way to reset a form when the thing it edits changes:

function ContactPage({ contact }) {
  // A new contact id → a fresh EditForm with empty fields, no stale draft from the last contact.
  return <EditForm key={contact.id} contact={contact} />;
}

The alternative, an effect that watches contact.id and clears every field, renders the stale form first and then corrects it. The key version never shows stale data.

Practitioner§06

Why a component re-renders

In one sentenceA component re-renders for one of three reasons: its own state changed, its parent re-rendered, or a context it reads changed. That's the whole list.

Notice what's missing: "its props changed". Props are just the arguments a parent passes. They can only change because the parent rendered again, and when the parent renders again, React calls all its children again by default, whether their props changed or not.

1

Own state

You called its setState with a different value. React re-renders that component and everything below it.
2

Parent re-rendered

Its parent ran again and returned a fresh element for it. By default React re-renders every child, even with identical props.
3

Context changed

It calls useContext(X) and the nearest X.Provider got a different value. This works even through memo.

In the film set: when the script for a scene changes, the director re-sketches that scene and every shot inside it, without first checking whether each inner shot's notes changed. Re-sketching is cheap, and checking costs time too.

Watch the three reasons

Each box below is a real component with a live count of its renders. Try each control and predict which boxes will flash before you click.

Why did that re-render?
Real components. Each box counts its commits and flashes when it re-renders.
Apprenders:
owns state: text, theme
Headerrenders:
no props that change (only a stable log helper)
memo(Header)renders:
same props every time
memo(Card)renders:
gets style={{ color }} — a new object each time
memo(ThemeBadge)renders:
reads context: theme = dark
Counterrenders:
has its own state
CounterLabelrenders:
child of Counter, n = 0
Sidebar (passed as children)renders:
element created by App's parent, not by App

What you should see:

You didRe-renderedWhy
Typed in App's boxApp, Header, memo(Card), Counter, CounterLabelOwn state (App), then parent re-rendered for its children
(same)not memo(Header), memo(ThemeBadge), Sidebarmemo saw the same props (§07); Sidebar's element came from outside App
Counter: +1Counter, CounterLabelOwn state (Counter) — only its subtree, never App
Toggle themeApp, Header, memo(Card), Counter, CounterLabel, and memo(ThemeBadge)App's own state and parent re-render as before, plus context changed for ThemeBadge — memo doesn't block it
Set text to the same valuenothing is committedSame value (Object.is) → React bails out. It may call App once more to compare, then stops before App's children and changes nothing

Two of those deserve a closer look.

memo(Card) re-renders anyway. Its parent passes style={{ color: "white" }}, and that { … } creates a brand-new object on every render. memo compares props with Object.is, and two different objects are never the same value, even with identical contents. More in §07.

The Sidebar never re-renders, although it sits inside App and isn't wrapped in memo. It's passed in as children:

function Page() {
  return (
    <App>
      <Sidebar />   {/* ← this element is created here, by Page */}
    </App>
  );
}

function App({ children }) {
  const [text, setText] = useState("");   // typing changes App's state…
  return <div>{/* …input… */}{children}</div>;  // …but `children` is the same element object as before
}

When App re-renders, it returns children — the same element object Page created last time. React sees an identical element and skips it. Only Page re-rendering would make a new one. This is the cheapest optimization in React: move the state down, or pass the slow part in from above, and the slow part stops re-rendering.

Context, up close

Context lets a value skip the props chain: a Provider high up, useContext anywhere below. The cost: every component that reads a context re-renders when its value changes, wherever it sits and whatever memo says. React decides "changed" with Object.is on the value prop.

The classic mistake is one context holding everything:

const AppContext = createContext(null);

function AppProvider({ children }) {
  const [theme, setTheme] = useState("dark");
  const [user, setUser] = useState(null);
  return (
    <AppContext.Provider value={{ theme, user, setTheme, setUser }}>  {/* ← new object every render */}
      {children}
    </AppContext.Provider>
  );
}

Changing theme produces a new value object, so a component that only shows the user's name re-renders too. Try it: all three children below are wrapped in memo, so only a context can re-render them.

One big context vs split contexts
All three children are wrapped in memo, so the only thing that can re-render them is a context they read.
Providerrenders:
one context: { theme, user }
ThemeLabelrenders:
reads theme = dark
UserLabelrenders:
reads user = Ada
Footerrenders:
reads no context

Three fixes, from simplest:

  1. Split contexts by how often things change together: ThemeContext, UserContext. A consumer subscribes only to what it reads. (The "split" toggle above.)
  2. Memoize the value with useMemo(() => ({ theme, user }), [theme, user]), so that a re-render of the provider for some other reason doesn't create a new object. This doesn't help when theme itself changes — that's what splitting is for.
  3. Use a store with selectors when many components each need a small slice of frequently changing data. That's a state-management question, covered in State Management.
Practitioner§07

Skipping work with memo — and measuring it

In one sentencememo(Component) tells React: "when my parent re-renders, compare my props with last time's, and skip me if they're all the same". It saves time only if the skipped render was expensive and the props really stay the same.

By default a parent re-render re-renders every child (§06). React.memo adds a check in front of one component: for each prop, is the new value the same value as before (Object.is)? If every prop passes, React reuses last time's result and skips that whole subtree.

import { memo } from "react";

function Row({ item }) {
  return <li>{item.name}</li>;
}

export const MemoRow = memo(Row);   // ← same component, plus a props check in front

The 300-renders-per-keystroke component, measured

Here's the opening puzzle from §01: a parent with a text box, and a list of rows that don't even use the text. Every keystroke re-renders the parent, so every row re-renders. Try both sizes and both versions; the numbers are measured live in your browser.

Does memo help here? Measure it.
A parent with a text box, and a list of rows that never use the text. Each keystroke re-renders the parent.
Type a few characters.
  • row 0
  • row 1
  • row 2
  • row 3
  • row 4
  • row 5
  • row 6
  • row 7
  • row 8
  • row 9
  • row 10
  • row 11
  • row 12
  • row 13
  • row 14
  • row 15
  • row 16
  • row 17
  • row 18
  • row 19
  • row 20
  • row 21
  • row 22
  • row 23
  • row 24
  • row 25
  • row 26
  • row 27
  • row 28
  • row 29
  • row 30
  • row 31
  • row 32
  • row 33
  • row 34
  • row 35
  • row 36
  • row 37
  • row 38
  • row 39
  • row 40
  • row 41
  • row 42
  • row 43
  • row 44
  • row 45
  • row 46
  • row 47
  • row 48
  • row 49
  • row 50
  • row 51
  • row 52
  • row 53
  • row 54
  • row 55
  • row 56
  • row 57
  • row 58
  • row 59
  • row 60
  • row 61
  • row 62
  • row 63
  • row 64
  • row 65
  • row 66
  • row 67
  • row 68
  • row 69
  • row 70
  • row 71
  • row 72
  • row 73
  • row 74
  • row 75
  • row 76
  • row 77
  • row 78
  • row 79
  • row 80
  • row 81
  • row 82
  • row 83
  • row 84
  • row 85
  • row 86
  • row 87
  • row 88
  • row 89
  • row 90
  • row 91
  • row 92
  • row 93
  • row 94
  • row 95
  • row 96
  • row 97
  • row 98
  • row 99
  • row 100
  • row 101
  • row 102
  • row 103
  • row 104
  • row 105
  • row 106
  • row 107
  • row 108
  • row 109
  • row 110
  • row 111
  • row 112
  • row 113
  • row 114
  • row 115
  • row 116
  • row 117
  • row 118
  • row 119
  • row 120
  • row 121
  • row 122
  • row 123
  • row 124
  • row 125
  • row 126
  • row 127
  • row 128
  • row 129
  • row 130
  • row 131
  • row 132
  • row 133
  • row 134
  • row 135
  • row 136
  • row 137
  • row 138
  • row 139
  • row 140
  • row 141
  • row 142
  • row 143
  • row 144
  • row 145
  • row 146
  • row 147
  • row 148
  • row 149
  • row 150
  • row 151
  • row 152
  • row 153
  • row 154
  • row 155
  • row 156
  • row 157
  • row 158
  • row 159
  • row 160
  • row 161
  • row 162
  • row 163
  • row 164
  • row 165
  • row 166
  • row 167
  • row 168
  • row 169
  • row 170
  • row 171
  • row 172
  • row 173
  • row 174
  • row 175
  • row 176
  • row 177
  • row 178
  • row 179
  • row 180
  • row 181
  • row 182
  • row 183
  • row 184
  • row 185
  • row 186
  • row 187
  • row 188
  • row 189
  • row 190
  • row 191
  • row 192
  • row 193
  • row 194
  • row 195
  • row 196
  • row 197
  • row 198
  • row 199
  • row 200
  • row 201
  • row 202
  • row 203
  • row 204
  • row 205
  • row 206
  • row 207
  • row 208
  • row 209
  • row 210
  • row 211
  • row 212
  • row 213
  • row 214
  • row 215
  • row 216
  • row 217
  • row 218
  • row 219
  • row 220
  • row 221
  • row 222
  • row 223
  • row 224
  • row 225
  • row 226
  • row 227
  • row 228
  • row 229
  • row 230
  • row 231
  • row 232
  • row 233
  • row 234
  • row 235
  • row 236
  • row 237
  • row 238
  • row 239
  • row 240
  • row 241
  • row 242
  • row 243
  • row 244
  • row 245
  • row 246
  • row 247
  • row 248
  • row 249
  • row 250
  • row 251
  • row 252
  • row 253
  • row 254
  • row 255
  • row 256
  • row 257
  • row 258
  • row 259
  • row 260
  • row 261
  • row 262
  • row 263
  • row 264
  • row 265
  • row 266
  • row 267
  • row 268
  • row 269
  • row 270
  • row 271
  • row 272
  • row 273
  • row 274
  • row 275
  • row 276
  • row 277
  • row 278
  • row 279
  • row 280
  • row 281
  • row 282
  • row 283
  • row 284
  • row 285
  • row 286
  • row 287
  • row 288
  • row 289
  • row 290
  • row 291
  • row 292
  • row 293
  • row 294
  • row 295
  • row 296
  • row 297
  • row 298
  • row 299
Times are measured in your browser with performance.now(), from the keystroke handler to the end of React's commit. They don't include the browser's own layout and paint.

Our measurements with this exact widget, in a production build of this site in headless Chromium on this machine (median of 30 keystrokes, two runs; render + commit only):

Rows<Row> (no memo)memo(Row)Row calls per keystroke
300≈ 0.5–0.6 ms≈ 0.3–0.4 ms300 → 0
3,000≈ 3–4.5 ms≈ 1.1–1.4 ms3,000 → 0

So yes, typing ran the row function 300 times per keystroke. And it cost about half a millisecond, out of a Frame budget of about 16.7 ms. Those 300 renders were never the problem. With 3,000 rows memo saves 2–3 ms per keystroke: real, but still small.

Notice that memo(Row) isn't free either: the parent still creates 3,000 elements and React still compares each one's props, about 1 ms here. memo starts to matter when each skipped render does real work (big subtrees, heavy calculations, charts) and the parent re-renders often.

Why memo silently stops working

memo only helps if the props are the same value from one render to the next. Three common ways they aren't:

✕ New values every render: memo never skips
function List({ items }) {
  return items.map((item) => (
    <MemoRow
      key={item.id}
      item={item}
      style={{ padding: 4 }}           // new object each render
      onSelect={() => select(item.id)} // new function each render
    >
      <Icon />                         {/* new element each render */}
    </MemoRow>
  ));
}
✓ Stable values: memo can skip
const rowStyle = { padding: 4 };       // created once, outside
const icon = <Icon />;                 // an element made once, outside

function List({ items }) {
  const [selectedId, setSelectedId] = useState(null);
  return items.map((item) => (
    <MemoRow
      key={item.id}
      item={item}
      style={rowStyle}
      icon={icon}                      // MemoRow renders {icon}
      onSelect={setSelectedId}         // a state setter is the same function every render
    />                                 // MemoRow calls onSelect(item.id)
  ));
}

Cheaper than memo: change the shape

Before reaching for memo, try the two moves from §06. They need no comparisons at all:

  1. Move state down. If only the search box needs query, put useState in a small SearchBox component. Typing then re-renders SearchBox, not the page.
  2. Lift content up. Pass the expensive part in as children from a component that doesn't re-render, so the element object stays the same.
Practitioner§08

Batching: setState is a request

In one sentencesetState doesn't change anything immediately. It queues an update and asks for a render. React collects every update from the same event into one render.

Each render sees a snapshot

Inside one render, count is a plain constant. Your event handler is a Closure created during that render, so it sees that render's count forever. Calling setCount doesn't reach back and change it:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);   // "set it to 0 + 1"
    setCount(count + 1);   // "set it to 0 + 1" again
    setCount(count + 1);   // and again
    console.log(count);    // 0 — this render's value never changes
  }
  // After the click: ONE render, and count is 1, not 3.
  return <button onClick={handleClick}>{count}</button>;
}

To build on the previous update, pass an updater function. React queues the functions and runs them in order, each receiving the result of the one before:

setCount((c) => c + 1);   // 0 → 1
setCount((c) => c + 1);   // 1 → 2
setCount((c) => c + 1);   // 2 → 3   — one render, count is 3

Try every case

How many renders does one click cost?
Pick a handler, run it, and read the result. All real React 19 calls.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
BatchTargetrenders:
the component whose state we update
count = 0 · flag = false
Run a handler to see the result.

What we measured with React 19.3 (and what you should see):

HandlerRendersResult
setCount(count + 1) ×31+1 — all three said "0 + 1"
setCount(c => c + 1) ×31+3
Two different setters1both applied together
Two setters inside setTimeout1batched too
Two setters after an await1batched too
flushSync around each2one render per flushSync

The setTimeout and await rows are new since React 18 ("automatic batching"). Before that, React only batched inside its own event handlers, so the same two lines inside a timer caused two renders. If you read older blog posts, that's why they disagree.

The escape hatch: flushSync

Occasionally you need the DOM updated now, before the next line runs: for example, to scroll to an item you just added.

import { useRef, useState } from "react";
import { flushSync } from "react-dom";

function Chat() {
  const [messages, setMessages] = useState([]);
  const listRef = useRef(null);              // ← React will put the <ul> DOM node in listRef.current

  function addMessage(text) {
    flushSync(() => {
      setMessages((ms) => [...ms, { id: crypto.randomUUID(), text }]);   // render + commit happen inside this call
    });
    listRef.current.lastElementChild.scrollIntoView();   // the new <li> is in the DOM now
  }

  return (
    <ul ref={listRef}>
      {messages.map((m) => <li key={m.id}>{m.text}</li>)}
    </ul>
  );
}

flushSync forces a synchronous render and commit, defeating batching. It's rare and costs performance; most code never needs it.

Practitioner§09

StrictMode: why everything runs twice in dev

In one sentenceIn development, <StrictMode> deliberately calls your components twice and runs your effects, cleans them up, and runs them again on mount. In production it does nothing.

Almost every new React project (Vite's template included) wraps the app in <StrictMode>. Then a console.log in a component prints twice, or an effect fetches twice, and people assume React is broken. It's a test, run on every render, that catches two real bugs early.

main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")).render(
  <StrictMode>     {/* ← development checks for everything inside */}
    <App />
  </StrictMode>
);
What StrictMode does to one component
This page is a production build, so StrictMode's checks are switched off.
Live log (this page)
Press Mount.
Measured in development, inside <StrictMode>, on mount
render Probe (n=0)
render Probe (n=0)
effect: setup (subscribe)
effect: cleanup (unsubscribe)
effect: setup (subscribe)
Same component in a production build: one render, one setup. (React 19.3, headless Chromium.)

Check 1: rendering twice catches impure components

§03 said render must be pure, because React may call your function more than once. StrictMode makes that happen every time, so impurity shows up while you're developing instead of as a rare bug in production:

let nextId = 0;

function Comment({ text }) {
  const id = nextId++;    // ← changes a variable outside the component: impure
  return <p id={`c${id}`}>{text}</p>;
}
// Every render takes a new id, so the same comment's id changes whenever it re-renders.
// StrictMode makes that obvious from the first render: the ids jump by 2.

React uses only one of the two results, so a pure component behaves exactly the same. (If the doubled console.log lines bother you, the React DevTools browser extension has a setting to dim or hide logs from the second call.)

Check 2: mount, unmount, mount catches missing cleanups

When a component first mounts, StrictMode runs its effects: setup → cleanup → setup. If your effect subscribes to something and the cleanup doesn't unsubscribe, you'll now see two subscriptions, for example two chat messages arriving for every one sent. The fix is always the same: return a cleanup that undoes the setup.

✕ No cleanup: a leak that StrictMode exposes
useEffect(() => {
  const id = setInterval(tick, 1000);
  // nothing returned: the interval runs forever,
  // and StrictMode leaves you with two
}, []);
✓ Setup and cleanup mirror each other
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);   // ← undo exactly what setup did
}, []);

Why simulate an unmount at all? Because real apps unmount and remount components all the time: navigating away and back, a list item scrolling out and in, and React's <Activity> component (part of React 19.3, which this site uses), which hides a part of the UI and restores it later with its state kept. An effect that survives setup → cleanup → setup is ready for all of them.

Staff-level§10

Concurrent rendering and transitions

In one sentenceSince React 18, rendering can be interrupted: React works in small slices, checks whether something more urgent (like a keystroke) is waiting, and can throw a half-finished render away. Commits are still all-or-nothing.

Before React 18, once a render started it ran to the end. JavaScript has one Main thread, so a 120 ms render meant 120 ms in which the browser couldn't paint your keystroke. Concurrent rendering doesn't add threads. It makes the render phase pausable, which is safe precisely because rendering is pure (§03): nothing has touched the DOM yet, so stopping halfway leaves no mess.

You opt in per update: transitions

React can't guess which updates may wait. By default every update is urgent. You mark the non-urgent ones as a Transition:

import { useState, useTransition } from "react";

function Search() {
  const [text, setText] = useState("");     // what's in the box: urgent
  const [query, setQuery] = useState("");   // what the slow list filters by: can wait
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    setText(e.target.value);                // ← urgent: the box must show the keystroke now
    startTransition(() => {
      setQuery(e.target.value);             // ← non-urgent: render the list when there's time
    });
  }

  return (
    <>
      <input value={text} onChange={onChange} />
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <SlowList query={query} />          {/* wrapped in memo, so it only re-renders when query changes */}
      </div>
    </>
  );
}

Feel the difference. Type a word quickly in each mode:

Urgent update vs transition
A text box filters a list of 250 rows that each take ~0.4 ms to render (about 125 ms per list render, as measured).
Type a few characters quickly.
list: up to date
  • apple-0
  • banana-1
  • cherry-2
  • date-3
  • elder-4
  • fig-5
  • grape-6
  • kiwi-7
  • lemon-8
  • mango-9
  • nectar-10
  • olive-11
  • peach-12
  • quince-13
  • rasp-14
  • straw-15
  • apple-16
  • banana-17
  • cherry-18
  • date-19
  • elder-20
  • fig-21
  • grape-22
  • kiwi-23
  • lemon-24
  • mango-25
  • nectar-26
  • olive-27
  • peach-28
  • quince-29
  • rasp-30
  • straw-31
  • apple-32
  • banana-33
  • cherry-34
  • date-35
  • elder-36
  • fig-37
  • grape-38
  • kiwi-39
  • lemon-40
  • mango-41
  • nectar-42
  • olive-43
  • peach-44
  • quince-45
  • rasp-46
  • straw-47
  • apple-48
  • banana-49
  • cherry-50
  • date-51
  • elder-52
  • fig-53
  • grape-54
  • kiwi-55
  • lemon-56
  • mango-57
  • nectar-58
  • olive-59
  • peach-60
  • quince-61
  • rasp-62
  • straw-63
  • apple-64
  • banana-65
  • cherry-66
  • date-67
  • elder-68
  • fig-69
  • grape-70
  • kiwi-71
  • lemon-72
  • mango-73
  • nectar-74
  • olive-75
  • peach-76
  • quince-77
  • rasp-78
  • straw-79
  • apple-80
  • banana-81
  • cherry-82
  • date-83
  • elder-84
  • fig-85
  • grape-86
  • kiwi-87
  • lemon-88
  • mango-89
  • nectar-90
  • olive-91
  • peach-92
  • quince-93
  • rasp-94
  • straw-95
  • apple-96
  • banana-97
  • cherry-98
  • date-99
  • elder-100
  • fig-101
  • grape-102
  • kiwi-103
  • lemon-104
  • mango-105
  • nectar-106
  • olive-107
  • peach-108
  • quince-109
  • rasp-110
  • straw-111
  • apple-112
  • banana-113
  • cherry-114
  • date-115
  • elder-116
  • fig-117
  • grape-118
  • kiwi-119
  • lemon-120
  • mango-121
  • nectar-122
  • olive-123
  • peach-124
  • quince-125
  • rasp-126
  • straw-127
  • apple-128
  • banana-129
  • cherry-130
  • date-131
  • elder-132
  • fig-133
  • grape-134
  • kiwi-135
  • lemon-136
  • mango-137
  • nectar-138
  • olive-139
  • peach-140
  • quince-141
  • rasp-142
  • straw-143
  • apple-144
  • banana-145
  • cherry-146
  • date-147
  • elder-148
  • fig-149
  • grape-150
  • kiwi-151
  • lemon-152
  • mango-153
  • nectar-154
  • olive-155
  • peach-156
  • quince-157
  • rasp-158
  • straw-159
  • apple-160
  • banana-161
  • cherry-162
  • date-163
  • elder-164
  • fig-165
  • grape-166
  • kiwi-167
  • lemon-168
  • mango-169
  • nectar-170
  • olive-171
  • peach-172
  • quince-173
  • rasp-174
  • straw-175
  • apple-176
  • banana-177
  • cherry-178
  • date-179
  • elder-180
  • fig-181
  • grape-182
  • kiwi-183
  • lemon-184
  • mango-185
  • nectar-186
  • olive-187
  • peach-188
  • quince-189
  • rasp-190
  • straw-191
  • apple-192
  • banana-193
  • cherry-194
  • date-195
  • elder-196
  • fig-197
  • grape-198
  • kiwi-199
  • lemon-200
  • mango-201
  • nectar-202
  • olive-203
  • peach-204
  • quince-205
  • rasp-206
  • straw-207
  • apple-208
  • banana-209
  • cherry-210
  • date-211
  • elder-212
  • fig-213
  • grape-214
  • kiwi-215
  • lemon-216
  • mango-217
  • nectar-218
  • olive-219
  • peach-220
  • quince-221
  • rasp-222
  • straw-223
  • apple-224
  • banana-225
  • cherry-226
  • date-227
  • elder-228
  • fig-229
  • grape-230
  • kiwi-231
  • lemon-232
  • mango-233
  • nectar-234
  • olive-235
  • peach-236
  • quince-237
  • rasp-238
  • straw-239
  • apple-240
  • banana-241
  • cherry-242
  • date-243
  • elder-244
  • fig-245
  • grape-246
  • kiwi-247
  • lemon-248
  • mango-249
The number is measured live on your device. With setQuery(v), the text box can't update until the whole list has rendered. With a transition, React commits the text first and renders the list in interruptible slices.

Our measurements, with the same list on a stand-alone page (production build, headless Chromium on this machine, 10 keystrokes typed 20 ms apart). On this site's full page the urgent version measured slower still, around 200 ms:

ModeonChange handler → text box committed (median)Time for 10 keystrokes to be handled
setQuery(v) (urgent)≈ 125 ms≈ 1.5 s
startTransition(() => setQuery(v))≈ 0.2 ms≈ 0.4 s

With the urgent version, every keystroke waits for a full list render, and the keystrokes queue up behind each other. With the transition, React commits the text box immediately, starts the list render, and abandons it whenever a newer keystroke arrives. While you keep typing, the intermediate lists are thrown away; a list is committed once you pause long enough for one to finish.

The same idea without owning the setter: useDeferredValue

If the value comes from props and you can't wrap its setter, useDeferredValue gives you a copy that "lags behind" during urgent updates:

function Results({ query }) {
  const deferredQuery = useDeferredValue(query);   // ← old value first, then the new one in the background
  return <SlowList query={deferredQuery} />;        // SlowList is memo'd, so the urgent render skips it
}

What concurrency does not do

  • It doesn't make slow components faster. The list still takes about 125 ms of CPU to render; it just stops blocking typing. Fix genuinely slow renders with §07 or by rendering less (pagination, virtualized lists).
  • It doesn't help work outside React's render phase: a slow event handler or a heavy useEffect still blocks the thread.
  • It isn't parallelism. Everything still runs on the one main thread, in slices. (Why one thread: Async / Await → the event loop.)
Staff-level§11

In production: profiling, the compiler, and VDOM costs

In one sentenceDon't guess which components are slow: record a real interaction with the Profiler. Then fix the few that matter — or let the compiler do the memoizing — and keep high-frequency visual updates out of React's render cycle.

Step 1: profile a real interaction

The React Profiler lives in the React Developer Tools browser extension (it adds "Components" and "Profiler" tabs to DevTools). The workflow:

  1. Open the Profiler tab, press record, do the slow thing (type in the search box, open the menu), stop.
  2. You get one bar per commit. Pick the tall ones. Each shows a Flame graph of the components that rendered in that commit and how long each took. Grey ones didn't render.
  3. In the Profiler's settings, turn on recording why each component rendered. Selecting a component then shows the reason: "Hook 1 changed", "Props changed: (style)", "The parent component rendered". These map directly onto the three reasons from §06.
  4. The Components tab settings can also highlight updates when components render, flashing them on the page like the demos in this topic.

The same numbers are available in code through the <Profiler> component, handy for logging render times from automated tests:

import { Profiler } from "react";

function onRender(id, phase, actualDuration, baseDuration) {
  // phase: "mount" | "update" | "nested-update"
  // actualDuration: ms spent rendering this commit (memo'd parts skipped)
  // baseDuration:   ms a full re-render of the subtree would take, with no skipping
  console.log(id, phase, actualDuration.toFixed(1), baseDuration.toFixed(1));
}

<Profiler id="ProductList" onRender={onRender}>
  <ProductList />
</Profiler>

Step 2: let the React Compiler memoize for you

Getting memo, useMemo and useCallback right everywhere (§07) is tedious and easy to break. The React Compiler is a build-time plugin that does it automatically. Version 1.0.0 of babel-plugin-react-compiler is the current release on npm (checked October 2026), and React 19 ships the small runtime it needs (react/compiler-runtime). This site doesn't use it; here is its real output for a small component:

✕ What you write
export function List({ rows, query }) {
  const visible = rows.filter((r) => r.name.includes(query));
  return (
    <ul>
      {visible.map((r) => <Row key={r.id} row={r} />)}
    </ul>
  );
}
✓ What the compiler emits (trimmed)
export function List(t0) {
  const $ = _c(7);                 // a per-instance cache with 7 slots
  const { rows, query } = t0;
  let t1;
  if ($[0] !== query || $[1] !== rows) {
    // …filter and map, only when an input changed…
    $[0] = query; $[1] = rows; $[2] = t1;
  } else {
    t1 = $[2];                     // ← reuse last time's elements
  }
  let t2;
  if ($[5] !== t1) {
    t2 = _jsx("ul", { children: t1 });
    $[5] = t1; $[6] = t2;
  } else {
    t2 = $[6];                     // ← return the SAME element object
  }
  return t2;
}

When rows and query haven't changed, List returns the same element object as last time. That's the "children" trick from §06 applied automatically: React sees an identical element and skips the whole subtree, with no memo call written by hand. The compiler relies on your components following the rules (pure render, no mutating props or state); when it spots code that breaks them, it leaves that component as it was instead of guessing. Adoption details: React Hooks, Honestly.

Step 3: know when the virtual DOM is overhead

Reconciliation is work on top of the DOM changes: render the components, then compare the trees, then patch. That pays off when you can't easily tell what changed. It's pure overhead when you already know exactly what changed, and that happens in a few places:

SituationWhy diffing hurtsBetter
Something moves with the mouse or scroll, 60× per secondA render + diff per frame for one transform valueWrite the style through a ref inside a requestAnimationFrame callback, or use a CSS animation
A list with 10,000+ rowsRendering, diffing and laying out rows nobody can seeRender only the visible rows (a virtualized list)
A chart or editor drawing on <canvas>No DOM to diffLet the library draw; React only renders the container
Data updating many times per second (tickers, live logs)Each update re-renders a big parentKeep the fast value in a small leaf component, or in a store that updates only subscribers

Some other frameworks (Solid and Svelte, for example) skip the diff entirely by tracking at build or run time exactly which DOM node each piece of state feeds. React chose the simpler model of "re-run the function and compare", and is closing the cost gap with the compiler instead.

Practitioner§12

Pitfalls

Every one of these comes straight from a rule earlier in the topic. If something in your app "acts weird", check this list first.

01

Index keys on a list that changes

Typed text, checkboxes or animations stick to a position while the data moves (§05). Use an id from the data: key={item.id}. If items have no id, give them one when they're created, never during render.

02

Mutating state instead of replacing it

const add = (item) => {
  items.push(item);   // ✕ same array object, just changed
  setItems(items);    // same object (Object.is) → React bails out: nothing is committed
};
const addFixed = (item) => setItems((xs) => [...xs, item]);   // ✓ a new array

React decides "did state change?" with Object.is. Changing the inside of an object and passing the same object back looks like "no change" (§06). Always create a new object or array.

03

A component defined inside another component

The inner function is a new type on every render, so React destroys and rebuilds it (and its state and focus) every time (§04). Move it to the top level of the file and pass what it needs as props.

04

Switching the wrapper element type

isMobile ? <div><Form/></div> : <section><Form/></section> resets Form whenever isMobile flips (§04). Keep the type the same and change className or other props.

05

setState during render

function Profile({ user }) {
  const [name, setName] = useState("");
  setName(user.name);   // ✕ every render asks for another render
  return <h1>{name}</h1>;
}

React stops this loop with the error Too many re-renders. React limits the number of renders to prevent an infinite loop. Usually the state isn't needed at all: compute it during render (const name = user.name;). If it must reset when a prop changes, use a key (§05).

06

Reading state right after setting it

setCount(count + 1); console.log(count); logs the old value. The handler holds this render's snapshot (§08). Compute the next value into a variable first, or use the updater form setCount(c => c + 1) when stacking updates.

07

One giant context with a new object every render

Every consumer re-renders on every change of anything in it, and memo can't stop it (§06). Split contexts by how often things change, and memoize the value objects.

08

memo with props that are never the same

Inline {} objects, inline arrow functions and JSX children are new values on every render, so a memo component re-renders anyway, and you pay for the comparison too (§07). Move constants out, use useCallback/useMemo for the rest, or don't memo at all.

09

Side effects in the component body

Fetching, logging analytics or touching the DOM during render runs twice under StrictMode (§09) and again for every discarded concurrent render (§10). Put them in event handlers or effects.

10

Measuring performance in a development build

Development builds are many times slower and double every render under StrictMode. Profile a production or profiling build (§11) before optimizing anything.

Practitioner§13

Check yourself: predict what React does

Commit to an answer before you click. If you miss one, go back to the live demo for that rule and make it happen in front of you.

QUESTION 1 / 6
What does this log?
function Counter() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    console.log(count);
  }
  return <button onClick={handleClick}>{count}</button>;
}
// the user clicks once
QUESTION 2 / 6
Which components re-render?
function App() {
  const [n, setN] = useState(0);
  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Header />            {/* takes no props */}
      <Footer year={2026} />
    </>
  );
}
QUESTION 3 / 6
Typed text in the wrong row

A todo list renders todos.map((t, i) => <TodoRow key={i} todo={t} />). Each row has an <input> with no value prop. You type in the first row, then sort the list. What happens?

QUESTION 4 / 6
Where did the state go?
function Page({ compact }) {
  return compact
    ? <div><SearchBox /></div>
    : <main><SearchBox /></main>;
}
QUESTION 5 / 6
Render vs commit

Which of these is safe to do directly in a component's body (during render)?

QUESTION 6 / 6
Context and memo

ThemeLabel is wrapped in memo and calls useContext(AppCtx). Its parent never re-renders. The provider's value changes from { theme: "dark", user } to { theme: "dark", user: newUser }. Does ThemeLabel re-render?

Answered 0 / 6
Foundations§14

Cheat sheet

🎬
UI = f(state)You return a description (elements); React compares it with the last one and changes only what differs.
🧩
Component · element · instanceYour function · the plain object it returns · the placed copy where state lives.
✏️
Render, then commitRender calls your functions (pure, may repeat, may be thrown away). Commit changes the DOM. Then effects run.
🔍
Same type + same position = same thingDifferent type at a position → the old subtree and its state are destroyed.
🏷️
Keys are name tagsStable ids, unique among siblings. Index keys attach state to positions. Change a key to reset on purpose.
🔁
Three reasons to re-renderOwn state · parent re-rendered · a context it reads changed. Not "props changed".
🧮
memo: measure firstSkips a re-render only if every prop is Object.is-equal. Inline objects, functions and children break it.
📨
setState is a requestBatched into one render (everywhere since React 18). The handler keeps its snapshot; use c => c + 1 to stack.
🧪
StrictMode doubles in dev onlyTwo renders and setup → cleanup → setup on mount, to expose impure renders and missing cleanups.
⏳
Transitions keep typing faststartTransition / useDeferredValue mark updates React may interrupt. Same thread, better priorities.
SymptomLikely causeFix
Typed text / checkbox in the wrong row after sortingkey={index}key={item.id} (§05)
State resets when a layout flag flipsElement type changed at that positionKeep the type; change props (§04)
Inputs lose focus on every keystrokeComponent defined inside another componentMove it to the top level (§04)
console.log prints twice, effect runs twiceStrictMode in developmentMake render pure; add cleanups (§09)
Logged state is "one behind"Reading the snapshot after setStateUse the value you computed, or an updater (§08)
memo component still re-rendersNew object/function/children prop each timeStable props, or move state down (§07)
Everything re-renders on a theme changeOne big context valueSplit contexts (§06)
Typing lags behind a big listUrgent update renders the slow liststartTransition / useDeferredValue (§10)
Not sure what's slowGuessingReact DevTools Profiler on a profiling build (§11)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserReact Hooks, Honestly
Read →