What triggers a render
Your first look at React's render model, the mechanism that decides when components re-run.
Picture a <UserCard user={...} /> in a dashboard, with a search box above it.
Type one character into that search box and the <UserCard> re-renders: its function runs again, even though the user, the name, and the email are all unchanged.
Nothing on screen moved and no data changed.
So why did it run?
By the end of this lesson you’ll know exactly what makes a component re-run, why writing { name, email } inline counts as a brand-new value every time, and why the fix is to write the obvious code and let a compiler handle the rest.
The previous chapter said “the function runs again with new props” without saying why; this is the why.
Rendering is React calling your function
Section titled “Rendering is React calling your function”So far you’ve pictured a component as a template: markup React keeps on screen and edits in place when something changes. The rest of the chapter rests on a different picture. A component is a function, and rendering is React calling it.
When React renders your component, it invokes the function. The function returns JSX, which, as you saw in the last chapter, is a tree of plain JavaScript objects describing what should be on screen. React compares that tree to the one from the previous call and applies the smallest set of DOM changes needed to make the page match.
That splits into two phases worth naming:
- Render: React calls your function and gets back the JSX tree. This is pure computation, just JavaScript building objects. No DOM is touched.
- Commit: React applies the diff to the real DOM. This is the commit phase that actually moves pixels.
One consequence carries the rest of this lesson: if a render produces the same output it did last time, React commits nothing. It runs your function, sees the tree matches, and leaves the DOM untouched. The function ran, but the screen didn’t change. That gap between “the function ran” and “the screen changed” is why the worry you’ll feel later, won’t that re-render everything?, is mostly misplaced.
Commit matters more in a later chapter, once effects make the render/commit split determine when your side effects run. For now, treat commit as “the DOM write” and render as the part you reason about as plain function calls.
function Greeting({ name }: { name: string }) { return <h1>Hello {name}</h1>;}This function is what React calls on every render.
Give it name, it returns a tree. Same name, same tree.
The shorthand is UI = f(state): the UI is a function of state.
Your component is the f, and its props, state, and context are the inputs.
Render the same component with the same inputs twice and you get the same tree both times.
The three reasons a component re-renders
Section titled “The three reasons a component re-renders”A component re-runs when React calls it again. So what makes React call it again?
There are exactly three reasons. A component re-runs when:
- Its own state updated. You called a
useStateoruseReducersetter, and React schedules the component to run again with the new value. - An ancestor re-rendered. When a component re-renders, React re-runs its children by default, and their children, all the way down the subtree.
- A context it subscribes to changed. The component reads a shared value with useContext , and that value updated.
That’s the complete list. One omission catches almost everyone: a prop changing is not on it.
When you see a child re-render with different props, the instinct is “the prop changed, so the child re-rendered.” That’s backwards. The prop changing and the child re-rendering are both consequences of the same cause: the parent re-ran. When the parent re-runs, it re-executes the child (trigger 2) and, while doing so, produces the new prop value and hands it down. The prop is a passenger, not the driver.
Once this clicks, the rest of the chapter falls into place. Miss it and you’ll spend hours trying to “stop the prop from changing” when the thing to look at is one level up.
The clearest way to see this is to watch which boxes light up.
Below, Dashboard holds a SearchBox and a UserCard.
Click each trigger and watch how far the renders spread.
That answers the opening puzzle.
Typing in the search box didn’t update SearchBox in isolation.
The search value lived in Dashboard, so the keystroke updated Dashboard’s state, Dashboard re-rendered, and re-rendering it re-ran every child below.
UserCard ran because its parent ran. The user never changed; the parent did.
One more distinction explains when this chapter’s bugs show up. A component’s very first render is called a mount: React builds DOM from nothing, so there’s no previous tree to compare against. Every render after that is an update, and an update always has a previous tree to diff. Nearly every bug in this chapter appears only on updates, because the mount has nothing to compare and so nothing to get wrong yet. The trouble starts on the second render.
Now make trigger 2 concrete with the smallest example: a parent that re-renders, and a child whose props never change.
const Counter = () => { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(count + 1)}>Clicked {count} times</button> <Label text="I never change" /> </div> );};Click the button and Label runs again every time, even though text is the same string it always was.
That’s trigger 2: the parent re-rendered, so the child re-ran.
Label running again is harmless.
It returns the same tree, React diffs it, sees no difference, and commits nothing, so there’s no flicker.
But it raises the question the rest of this lesson answers: if React can tell a child’s output is unchanged, can it skip re-running the child at all?
Sometimes it can, and the rule that decides is worth getting exactly right.
Reference identity and Object.is
Section titled “Reference identity and Object.is”When React is allowed to skip re-running a child whose parent just re-rendered, it has to decide whether the child’s props actually changed.
It walks each prop and compares the new value to the old one with a single function: Object.is .
This is React’s only equality rule.
You already know most of how Object.is behaves, because you know ===. It splits the world in two:
- Primitives compare by value.
Object.is('hi', 'hi')istrue;Object.is(3, 3)istrue. Two strings with the same characters are the same value, and so are two equal numbers. (It’s===with two edge cases that don’t matter here:NaNequals itself, and+0and-0don’t.) - Objects, arrays, and functions compare by reference.
Object.is({}, {})isfalse. Two object literals with identical contents are different values toObject.is, because they’re different objects in memory. The question is “are these the same object?”, not “do these look the same?”
Object.is('hi', 'hi'); // true — same string valueObject.is(3, 3); // true — same number valueObject.is({}, {}); // false — two different objectsObject.is([1], [1]); // false — two different arraysconst fn = () => {};Object.is(fn, fn); // true — literally the same functionThe last line is the one that matters.
Object.is(fn, fn) is true because it’s the same function in memory compared to itself, not because it “looks the same.”
Two separately written functions, even character-for-character identical, would be false.
What counts is identity, not appearance.
Why doesn’t React compare the contents instead, walking into the object and checking each field?
Deep comparison is slow when it runs on every prop of every component, and it’s ambiguous: how deep should it go, and what about nested functions or circular references?
So React never does it automatically.
The default is Object.is, which asks one question: same reference?
That comparison only happens when a child is memoized, when something has told React it may skip the child if its props match.
Without that, trigger 2 is unconditional: the parent re-rendered, so the child re-runs, no comparison involved.
Object.is runs only at memoization boundaries, deciding whether a skippable child actually skips.
Inline literals create a new reference every render
Section titled “Inline literals create a new reference every render”Here is where Object.is starts to matter in practice.
Three of the most ordinary props you will write produce a brand-new reference on every render:
<Child style={{ color: 'red' }} />: the object literal runs again each render, building a new object.<Child items={[...list, 'extra']} />: the spread builds a new array each render, from the samelistand'extra'.<Child onClick={() => save(id)} />: the arrow is a function expression, evaluated to a new function each render.
Nothing changes on screen: the color is still red, the items are the same, the click still saves.
But under Object.is, all three differ from their previous values every render, because each render builds a fresh object, array, or function in memory.
The rule: any object, array, or function written as a literal in JSX is a new value every render.
That is why a memoized child re-renders “for no reason.”
React runs Object.is on the style prop, compares this render’s object to last render’s, sees two different references, and concludes the prop changed.
The child can’t skip, even though nothing is actually different.
const Profile = ({ user }: { user: User }) => ( <Avatar title="Profile" style={{ borderColor: 'red' }} tags={[...user.tags, 'verified']} onClick={() => openProfile(user.id)} />);A plain string, compared by value, so it’s the same value every render under Object.is. A primitive prop is never the reason a child re-renders.
const Profile = ({ user }: { user: User }) => ( <Avatar title="Profile" style={{ borderColor: 'red' }} tags={[...user.tags, 'verified']} onClick={() => openProfile(user.id)} />);Every render executes { borderColor: 'red' } again, building a new object. The new reference is !== last render’s, even though the color never changed.
const Profile = ({ user }: { user: User }) => ( <Avatar title="Profile" style={{ borderColor: 'red' }} tags={[...user.tags, 'verified']} onClick={() => openProfile(user.id)} />);The spread builds a new array every render. Same user.tags, same 'verified', but a fresh array in memory, so a different reference each time.
const Profile = ({ user }: { user: User }) => ( <Avatar title="Profile" style={{ borderColor: 'red' }} tags={[...user.tags, 'verified']} onClick={() => openProfile(user.id)} />);The arrow is a function expression. Each render evaluates it to a new function: identical body, different reference.
Put the rule to work before we resolve it. Predict what this prints, and watch for the instinct that says two identical-looking objects should be equal.
Object literals are fresh references; primitives compare by value. Predict what this program prints, then press Check.
function makeStyle() { return { color: 'red' };}
const a = makeStyle();const b = makeStyle();
console.log(Object.is(a, b));console.log(Object.is('red', 'red'));console.log(Object.is(a, a));makeStyle() builds a new object each call, so a and b are different references — false, even though their contents are identical. The string 'red' is a primitive, compared by value — true. And a compared to itself is the same reference — true. This is exactly what happens to an inline style={{ color: 'red' }} across two renders: same contents, different object, “changed” to React.
That false on the first line is the whole problem in miniature, and the next section resolves it.
The compiler memoizes for you
Section titled “The compiler memoizes for you”Inline objects churn, and memoized children re-render for no real reason.
The fix is to make those references stable: hand React the same object across renders so Object.is returns true.
For years you did this by hand.
Recognize the old way on sight, nothing more.
You’d wrap the object in a hook that memoizes it, and wrap the callback in another: the hooks were useMemo and useCallback.
Every inline object was a candidate, and each carried a dependency array you kept in sync by hand.
Get the array wrong and you cached a stale value.
It was overhead on every component, mostly to fix a problem the code didn’t have.
Now you skip it. The project ships with the React Compiler turned on, a flag set once in the project config. From then on it reads your components at build time and inserts that memoization for you: the inline object, the array spread, and the inline callback each get a stable identity across renders, with no hooks and no dependency arrays in your source. The one condition is that the component be pure (same inputs, same output), the subject of “The purity contract” later in this chapter.
That collapses to one rule:
Write the natural code. Inline the object, inline the callback. Let the compiler memoize.
Don’t reach for useMemo or useCallback as a precaution; in this stack it’s just noise.
Manual memoization survives only as a last resort, for the rare component the compiler can’t optimize, which DevTools flags.
Nine times out of ten you do nothing and move on.
Flip between the tabs below.
The source is identical on both sides: a Toolbar passing an inline callback to a memoized SaveButton.
With the compiler off, the parent’s re-render hands down a fresh callback, so the child can’t skip and lights up too.
With the compiler on, the callback’s identity stays stable, so the child stays dark.
Renders are cheaper than you think
Section titled “Renders are cheaper than you think”When “a parent re-render re-runs the whole subtree” first lands, the reflex is worry. The whole subtree, on every keystroke? Isn’t that wasteful? It’s a fair concern, and the answer is: mostly, no.
Rendering is calling functions and diffing a tree of plain JavaScript objects, which is fast. The expensive part of getting something on screen is the DOM commit, and React already minimizes it: it touches only the nodes that genuinely differ from last time and skips the rest. Recall the strip from the top of the lesson: a re-render that produces an unchanged tree commits nothing. So “the subtree re-rendered” usually means “some functions ran and React confirmed there was nothing to change,” which costs almost nothing.
This changes the discipline. Optimizing renders is not a default habit; it’s a last resort, reached for only after a measurement shows a specific render is too slow. The order is fixed: profile first, then chase the prop identity that’s churning. React’s DevTools Profiler shows you exactly which components rendered and which of their props changed, so you don’t guess. You measure, find the churning reference, and only then decide whether it’s worth pinning down.
That returns us to UI = f(state).
You know the function (your component), its inputs (props, state, context), and the three things that make React call it again: its own state, an ancestor, or a context.
That’s the full render trigger model.
What we’ve left untouched is the return value. When your function hands React a new tree, how does React match each box in the new tree to one in the old, so it knows what to keep, discard, or update? That matching is called reconciliation , and getting it right, or wrong, is the next lesson.