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.
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.
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.
- 1State changessetCount(1)
- 2RenderReact calls your components
- 3Elementsa new description of the UI
- 4Reconcilecompare with last time
- 5Commitpatch only what changed in the DOM
- 6Paintthe browser draws pixels
DOM, all at once.Rendering is calling your function
Matching is by type and position
key. Get that wrong and state ends up attached to the wrong row (§05).Three reasons to re-render
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.
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.
Components, elements and instances
Most confusion about React comes from mixing up three words. Using the film set from §01:
| Word | What it is | Film-set version | Who creates it | How long it lives |
|---|---|---|---|---|
| Component | A function, like Counter | The script for one kind of scene | You, once | As long as your code |
| Element | A plain object: { type, props, key } | A storyboard sketch | Your component, on every render | Until the next render replaces it |
| Instance | React's record for one placed copy, holding its state | The built scene on set | React, when the element first appears at a spot | Until 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.
<h1 className="title">Hello, {name}</h1>import { jsxs as _jsxs } from "react/jsx-runtime";
_jsxs("h1", {
className: "title",
children: ["Hello, ", name]
});{
$$typeof: Symbol(react.transitional.element),
type: "h1",
key: null,
props: {
className: "title",
children: [
"Hello, ",
"Ada"
]
}
}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:
typeis 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.- Creating an element doesn't run your component.
<Greeting name="Ada" />only records "Greeting, with these props". React decides when (and whether) to callGreeting. keyisn't a prop. It's stored next toprops, 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.
Render, then commit
| Phase | What happens | Your code that runs | Touches the DOM? |
|---|---|---|---|
| Render | React calls your components and compares the result with last time | The component body | No |
| Commit | React applies the differences to the DOM | Nothing of yours (then layout effects, see the note below) | Yes — and only here |
| Effects | Follow-up work outside React | useEffect callbacks | Only if you do it |
Step through one click on a counter. Keep an eye on which column changes.
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}1213function Label({ n }) {14 return <b>{n}</b>;15}
Clicked 0. React remembers the elements it produced last time and the DOM nodes it created for them. Now the user clicks.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.
What the log proves, every time:
setCountdoesn't changecount. Right after calling it, the handler still sees the old value. The new value only exists in the next render (§08 explains why).- During render, the DOM still shows the old value.
Labelruns while the page still says the old number. The DOM changes later, in the commit. useLayoutEffectsees the new DOM, and runs before the browser paints.useEffectruns 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.
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>;
}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.
Reconciliation: comparing two trees
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:
- Elements of different types produce different trees. If a
divbecame asection, or<Counter>became<Timer>, React doesn't look inside. It removes the old subtree and builds the new one. - Within a list of children, a
keysays which item is which. Without keys, React goes by position (§05).
Walk through one comparison
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}
user.name = "Ada", editing = false. After: the name changed and editing became true. React compares the trees top-down, position by position.At each position React asks one question first: is the type the same?
| Before → after, at the same position | What React does | State below it |
|---|---|---|
div → div | Keep the DOM node, update only changed attributes | Kept |
Counter → Counter | Keep the instance, call it with the new props | Kept |
p → form | Remove p and everything inside, create form | Lost |
Counter → Timer | Unmount Counter (its effects clean up), mount Timer | Lost |
something → null / false | Remove it | Lost |
Try it: same position, different type
{fancy
? <TapCounter label="fancy" />
: <TapCounter label="plain" />}{wrap
? <section><TapCounter /></section>
: <div><TapCounter /></div>}<TapCounter key={person}
label={person} />TapCounter at the same spot, so React sees the same component with new props.div ↔ section), so the whole subtree was destroyed and rebuilt.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.
Keys: telling React who is who
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}.
{items.map((item, index) => (
<FruitRow key={index} item={item} />
))}- 🍎 Appleinstance created for: Apple
- 🍌 Bananainstance created for: Banana
- 🍒 Cherryinstance created for: Cherry
Here's the "Add to top" case with index keys, written out:
| Key | Before | After | What 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
Stable
Unique among siblings
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.
Why a component re-renders
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.
Own state
setState with a different value. React re-renders that component and everything below it.Parent re-rendered
Context changed
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.
style={{ color }} — a new object each timeWhat you should see:
| You did | Re-rendered | Why |
|---|---|---|
| Typed in App's box | App, Header, memo(Card), Counter, CounterLabel | Own state (App), then parent re-rendered for its children |
| (same) | not memo(Header), memo(ThemeBadge), Sidebar | memo saw the same props (§07); Sidebar's element came from outside App |
| Counter: +1 | Counter, CounterLabel | Own state (Counter) — only its subtree, never App |
| Toggle theme | App, 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 value | nothing is committed | Same 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.
Three fixes, from simplest:
- Split contexts by how often things change together:
ThemeContext,UserContext. A consumer subscribes only to what it reads. (The "split" toggle above.) - 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 whenthemeitself changes — that's what splitting is for. - 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.
Skipping work with memo — and measuring it
memo(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 frontThe 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.
- 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
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 ms | 300 → 0 |
| 3,000 | ≈ 3–4.5 ms | ≈ 1.1–1.4 ms | 3,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:
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>
));
}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:
- Move state down. If only the search box needs
query, putuseStatein a smallSearchBoxcomponent. Typing then re-rendersSearchBox, not the page. - Lift content up. Pass the expensive part in as
childrenfrom a component that doesn't re-render, so the element object stays the same.
Batching: setState is a request
setState 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 3Try every case
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);What we measured with React 19.3 (and what you should see):
| Handler | Renders | Result |
|---|---|---|
setCount(count + 1) ×3 | 1 | +1 — all three said "0 + 1" |
setCount(c => c + 1) ×3 | 1 | +3 |
| Two different setters | 1 | both applied together |
Two setters inside setTimeout | 1 | batched too |
Two setters after an await | 1 | batched too |
flushSync around each | 2 | one 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.
StrictMode: why everything runs twice in dev
<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.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
createRoot(document.getElementById("root")).render(
<StrictMode> {/* ← development checks for everything inside */}
<App />
</StrictMode>
);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.
useEffect(() => {
const id = setInterval(tick, 1000);
// nothing returned: the interval runs forever,
// and StrictMode leaves you with two
}, []);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.
Concurrent rendering and transitions
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:
- 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
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:
| Mode | onChange 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
useEffectstill 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.)
In production: profiling, the compiler, and VDOM costs
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:
- Open the Profiler tab, press record, do the slow thing (type in the search box, open the menu), stop.
- 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.
- 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.
- 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:
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>
);
}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:
| Situation | Why diffing hurts | Better |
|---|---|---|
| Something moves with the mouse or scroll, 60× per second | A render + diff per frame for one transform value | Write the style through a ref inside a requestAnimationFrame callback, or use a CSS animation |
| A list with 10,000+ rows | Rendering, diffing and laying out rows nobody can see | Render only the visible rows (a virtualized list) |
A chart or editor drawing on <canvas> | No DOM to diff | Let the library draw; React only renders the container |
| Data updating many times per second (tickers, live logs) | Each update re-renders a big parent | Keep 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.
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.
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.
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 arrayReact 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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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 oncefunction App() {
const [n, setN] = useState(0);
return (
<>
<button onClick={() => setN(n + 1)}>{n}</button>
<Header /> {/* takes no props */}
<Footer year={2026} />
</>
);
}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?
function Page({ compact }) {
return compact
? <div><SearchBox /></div>
: <main><SearchBox /></main>;
}Which of these is safe to do directly in a component's body (during render)?
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?
Cheat sheet
| Symptom | Likely cause | Fix |
|---|---|---|
| Typed text / checkbox in the wrong row after sorting | key={index} | key={item.id} (§05) |
| State resets when a layout flag flips | Element type changed at that position | Keep the type; change props (§04) |
| Inputs lose focus on every keystroke | Component defined inside another component | Move it to the top level (§04) |
console.log prints twice, effect runs twice | StrictMode in development | Make render pure; add cleanups (§09) |
| Logged state is "one behind" | Reading the snapshot after setState | Use the value you computed, or an updater (§08) |
memo component still re-renders | New object/function/children prop each time | Stable props, or move state down (§07) |
| Everything re-renders on a theme change | One big context value | Split contexts (§06) |
| Typing lags behind a big list | Urgent update renders the slow list | startTransition / useDeferredValue (§10) |
| Not sure what's slow | Guessing | React DevTools Profiler on a profiling build (§11) |