Skip to content
Chapter 23Lesson 1

What triggers a render

The first look at React's render model, the mechanism that decides when your components re-run.

Picture a <UserCard user={...} /> sitting in a dashboard, with a search box above it. Every time you type a single character into that search box, the <UserCard> re-renders: its whole function runs again, even though the user is the same user, with the same name and the same email. Nothing about that card changed on screen, and nothing about its data changed either. So why did it run?

That question trips up almost everyone the first time they meet it, and the answer changes how you picture a component. 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 in 2026 you write the obvious code and let a compiler clean up after you. The last chapter said that “the function runs again with new props” without explaining why, and this lesson explains it.

So far you’ve pictured a component as a template: a chunk of markup React keeps on screen and quietly edits in place when something changes. Set that picture aside, because the rest of the chapter rests on a different one. A component is a function, and rendering is simply 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 just a tree of plain JavaScript objects describing what should be on screen. React takes that tree, compares it to the tree from the previous call, and applies the smallest set of DOM changes needed to make the real page match. That’s the entire mechanism: run the function, get a tree, diff it, patch the DOM.

That splits into two phases worth naming:

  • Render: React calls your function and gets back the JSX tree. This is pure computation. No DOM is touched. It’s just JavaScript building objects.
  • 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 React team guarantees this. The function ran, but the screen didn’t flicker. Holding onto that gap between “the function ran” and “the screen changed” is what makes the worry you’ll feel later, won’t that re-render everything?, mostly misplaced.

cause Trigger state, ancestor, or context changed no DOM
phase 1 Render call your function, get a JSX tree no DOM
phase 2 Reconcile diff against the previous tree no DOM
phase 3 Commit apply changes to the DOM touches the DOM
The trigger is the cause; render and commit are the two phases. Render and reconcile are pure computation, and only commit touches the DOM.

We won’t spend more on commit here. It matters more in a later chapter, once effects enter and the render/commit split starts to determine when your side effects run. For now, commit is just “the DOM write,” and render is the part you can reason about as plain function calls.

That frames the whole chapter. A component is a function, and its output depends on one thing: its inputs.

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. That’s the contract, and it has a name.

The shorthand is UI = f(state): the UI is a function of state. Same inputs in, same JSX out. 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. Keep this phrase nearby, because the chapter returns to it at the end as the thread tying everything together.

So a component re-runs when React calls it again. The next question is what makes React call it again.

There are exactly three reasons. A component re-runs when:

  1. Its own state updated. You called a useState or useReducer setter, and React schedules the component to run again with the new value.
  2. 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.
  3. A context it subscribes to changed. The component reads a shared value with useContext , and that value updated.

That’s the complete list. There is no fourth trigger. One omission catches almost everyone, so it’s worth stating directly: a prop changing is not on the list.

This misconception quietly derails every optimization story, so let’s clear it up. When you see a child re-render and its props are different, 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 does two things at once. It re-executes the child (trigger 2), and while doing so it produces the new prop value and hands it down. The prop didn’t trigger anything; the parent re-rendering did. The new 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. In the tree below, Dashboard holds a SearchBox and a UserCard. Click each trigger and watch the render badges tick, paying attention to how far each one spreads.

What makes a box re-run?

That answers the opening puzzle. When you typed in that search box, you weren’t typing into SearchBox in isolation. The keystroke updated state in Dashboard, because that’s where the search value lived, so Dashboard re-rendered, and re-rendering Dashboard re-ran every child under it. The <UserCard> ran because its parent ran. The user never changed; the parent did.

One more distinction explains when this chapter’s bugs show up. The very first time a component renders, there’s no previous tree to diff against, because React is building DOM from nothing. That first render is called a mount. Every render after it is an update, and an update always has a previous tree to compare to. Nearly every bug in this chapter only appears on updates: the mount has nothing to compare against, so there’s nothing to get wrong yet. The trouble starts on the second render.

Let’s 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’s function runs again on every click, even though text is the same string it has always been. That’s trigger 2: the parent re-rendered, so the child re-ran. (useState here is borrowed; the setter schedules a re-render, and the full hook gets its own treatment in the next chapter, so don’t expect depth on it yet.)

<Label text="I never change" /> 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 when 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.

When React is allowed to skip re-running a child whose parent just re-rendered (we’ll get to when in a moment), it has to decide whether the child’s props actually changed. To decide, 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. There is no other.

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') is true. Object.is(3, 3) is true. Two strings with the same characters are the same value, and two equal numbers are the same value. (It’s === with two edge cases tidied up: NaN equals itself, and +0 and -0 don’t. Neither edge case matters here.)
  • Objects, arrays, and functions compare by reference. Object.is({}, {}) is false. Two object literals with byte-for-byte identical contents are different values as far as Object.is is concerned, because they’re different objects in memory. The comparison asks “are these the same object?”, not “do these look the same?”
Object.is('hi', 'hi'); // true — same string value
Object.is(3, 3); // true — same number value
Object.is({}, {}); // false — two different objects
Object.is([1], [1]); // false — two different arrays
const fn = () => {};
Object.is(fn, fn); // true — literally the same function

That last line is the key one. Object.is(fn, fn) is true not because the function “looks the same” but because it is the same function: the same object in memory, compared to itself. Two functions written out separately, even character-for-character identical, would be false. What matters is identity, not appearance.

You might reasonably ask why React doesn’t just compare the contents, walking into the object and checking each field. Deep comparison is slow when run on every prop of every component, and it’s usually ambiguous anyway: how deep should it go, what about functions inside, what about circular references? So React never does it automatically. If you ever need it, it’s manual work you opt into by hand, which is rare and easy to get wrong. The default is Object.is, and Object.is asks one question: same reference?

One detail keeps this in proportion: the comparison only happens when a child is memoized, when something has told React “you’re allowed to skip this child if its props match.” Without that, React doesn’t compare props at all. The default behavior from trigger 2 is unconditional: parent re-rendered, child re-runs, no comparison involved. So Object.is isn’t running on every prop of every render. It runs only at the memoization boundaries, deciding whether a skippable child gets to actually skip.

Here’s where Object.is starts to matter in practice. The most ordinary, innocent-looking code you’ll write produces a brand-new reference on every render, and you’d never guess it from looking.

Watch these three props:

  • <Child style={{ color: 'red' }} />: that { color: 'red' } is an object literal. Every time the parent renders, that line executes again, and executing an object literal builds a new object. New object, new reference.
  • <Child items={[...list, 'extra']} />: the spread creates a new array each render. Same list, same 'extra', brand-new array.
  • <Child onClick={() => save(id)} />: that arrow is a function expression. Each render evaluates it and produces a new function. Identical body, different reference.

None of these change anything on screen. The color is still red, the items are still the same items, the click still saves. But under Object.is, all three differ from their previous selves on every single render, because each render builds a fresh object, array, or function in memory. The rule to remember is this: any object, array, or function written as a literal in JSX is a new value every render.

That is the reason a memoized child re-renders “for no reason.” React reaches the child, runs Object.is on the style prop, gets a new object versus last render’s object, sees they differ, and concludes the prop changed. So the child can’t skip. It re-runs, not because anything is actually different, but because the literal produced a new reference.

const Profile = ({ user }: { user: User }) => (
<Avatar
title="Profile"
style={{ borderColor: 'red' }}
tags={[...user.tags, 'verified']}
onClick={() => openProfile(user.id)}
/>
);

The primitive prop. A plain string, compared by value, so it’s the same value every render under Object.is. This one is stable, and it’s 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)}
/>
);

An object literal. Every render executes { borderColor: 'red' } again, building a brand-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 on 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 and produces a new function: identical body, different reference.

1 / 1

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));

If you predicted false, true, true, you’ve got it. The object built twice is two references, the primitive compared by value is equal, and the object compared to itself is equal. That false on the first line is the whole problem in miniature, and the next section resolves it.

The compiler writes the memoization for you

Section titled “The compiler writes the memoization for you”

So inline objects churn, memoized children re-render for no real reason, and the natural fix would be to make those references stable: somehow hand React the same object across renders so Object.is returns true. For years, that’s exactly what you had to do by hand.

It’s worth recognizing the old way on sight, and 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. They worked, but they spread across the codebase: every inline object was a candidate, and every one carried a dependency array you had to keep in sync by hand. Get the array wrong and you’d cache a stale value. It was a tax you paid on every component, mostly to fix a problem the code didn’t really have.

In 2026 you don’t pay it. The project ships with the React Compiler turned on. It’s a flag in the project config, set once, and from then on it reads your components at build time and inserts that memoization for you. For the inline object, the array spread, and the inline callback, the compiler gives each one a stable identity across renders, automatically, with no hooks and no dependency arrays in your source. It does this on one condition: the component has to be pure, meaning same inputs, same output, no surprises. That purity contract is the next lesson’s whole subject; here it’s just the thing the compiler quietly depends on.

That collapses to a rule you can adopt today and lean on for the rest of the course:

Write the natural code. Inline the object, inline the callback. Let the compiler memoize. Don’t reach for useMemo or useCallback as a precaution. That instinct is a reflex from the pre-compiler era, and in this stack it’s just noise. Manual memoization survives only as a last resort, for the rare component the compiler decides it can’t optimize. There’s a DevTools signal that flags those, and what to do about them comes later. The default, the thing you do nine times out of ten, is nothing. You write the obvious code and move on.

Flip between the tabs below to see the difference. The source is the same 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 reference, 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.

Same code, compiler off vs on

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, won’t it tank performance? It’s a natural concern, and experienced React developers have settled on the same answer: mostly, no.

The reason is that rendering is calling functions and diffing a tree of plain JavaScript objects. That’s fast, because JavaScript builds objects by the millions without breaking a sweat. The expensive part of getting something on screen is the DOM commit, and React already minimizes that carefully: it touches only the DOM 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. The function ran, but the page didn’t move. 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 you apply everywhere. It’s a last resort, reached for only after a measurement tells you a specific render is actually too slow. The order is fixed: profile first, then chase the prop identity that’s churning. React’s DevTools Profiler, the DevTools you installed back in the browser chapter, shows you exactly which components rendered and which of their props changed. You don’t guess; you measure, find the churning reference, and only then decide whether it’s worth pinning down. Measuring before you optimize is a habit that outlives any one React API.

That brings us back where we started: UI = f(state). You now know the function, your component, and you know the inputs: props, state, and context. And you know the three things that make React call the function again: its own state, an ancestor, or a context. That’s the full render trigger model, and it’s smaller than the worry suggested.

What we’ve left untouched is the return value. When your function hands React a new tree, how does React figure out which box in the new tree corresponds to which box in the old one, so it knows what to keep, what to throw away, and what to merely update? That matching process is called reconciliation , and getting it right, or getting it wrong, is the next lesson.