DEV Community

Cover image for How React Works (Part 3)? How React Finds What Actually Changed
Sam Abaasi
Sam Abaasi

Posted on

How React Works (Part 3)? How React Finds What Actually Changed

How React Finds What Actually Changed

Series: How React Works Under the Hood
Part 1: Motivation Behind React Fiber: Time Slicing & Suspense
Part 2: Why React Had to Build Its Own Execution Engine
Prerequisites: Read Parts 1 and 2 first.


Where We Left Off

In Part 2 we understood the engine — Fiber gives React a custom call stack it controls, and the Scheduler decides what to run and when.

Now the question is: what does React actually do with that engine?

Every time state changes, React has a problem. It knows the old version of your UI and the new version. But it's working with a tree of potentially thousands of components. Re-creating or re-rendering all of them on every update would be catastrophically slow.

React needs to answer one question as efficiently as possible:

"Of everything that could have changed, what actually did?"

The algorithm that answers that question is called the Reconciler.


The Problem: Re-rendering Everything Is Too Slow

Let's make the problem concrete. You have an app with 500 components. A user clicks a button that changes one counter inside one component. Naively, React would need to re-run every component function, create new JSX, and compare it to the old DOM — 500 times — to figure out that exactly one <span> needs its text updated.

That's what old-school frameworks did. It's why they felt slow.

React's insight was: most of the tree didn't change. If React can quickly identify which parts of the tree could possibly have changed, it can skip everything else and only do real work on the parts that matter.

The Reconciler is that skip logic.


The First Skip: Following the Trail

When you call setState inside a component, React doesn't just mark that one component for update. It marks a trail — like breadcrumbs from that component all the way back to the root of the tree.

Every ancestor gets a small flag saying: "one of your descendants has work to do."

etState on a deep component — trail of flags bubbles up to root

Then when React walks the tree to find what needs re-rendering, it uses those flags like a GPS. At every node it asks: "Does this node have work? No? Do any of its children have work? Also no?" If both answers are no, React skips that entire subtree in one step — not just the node, but every component inside it.

This is how a click inside one deeply nested component avoids touching the 490 components that had nothing to do with it. React walks straight down the trail of flags, skips every branch that's clean, and only stops at the one component that actually changed.


The Second Skip: Props Haven't Changed

React made it to the component that has work. But before actually re-running it, React checks one more thing: did this component's inputs (props) change?

If a parent re-renders, it might generate new props for its children. But "new" doesn't always mean "different." React compares the old props to the new props. If they're the same object reference — same pointer in memory — React skips re-rendering that component entirely.

This is reference equality, not deep equality. React doesn't compare every field inside the props object. It just checks: is this the exact same object we had before?

If yes — skip. The component can't possibly look different, so there's nothing to do.

This is also exactly how React.memo works. React.memo wraps a component and adds one step: when React would normally re-render because the props reference changed, React.memo does a shallow comparison of each prop value. If they all match, it skips. If even one is different, it re-renders normally.

And this is why passing a new object as a prop breaks memoization:

// This breaks React.memo — new object created every render
<Button style={{ color: 'red' }} />

// This works — same reference if color hasn't changed
const style = useMemo(() => ({ color: 'red' }), []);
<Button style={style} />
Enter fullscreen mode Exit fullscreen mode

The {} on the first line creates a brand-new object every single render. Even though its contents are identical, it's a different object in memory. React sees "different reference" and re-renders.


When React Does Need to Re-render: Type vs Content

React made it to a component that needs updating. It runs the component function, gets new JSX back, and now needs to figure out what changed by comparing old output to new output.

The first thing React checks is the type of each element. Not the content — the type.

If you had a <div> and now you have a <span>, React doesn't try to figure out how to transform one into the other. It destroys the <div> completely — the DOM node, the fiber, all state inside it — and creates a fresh <span> from scratch.

This is a hard rule: same type means update in place, different type means destroy and recreate.

same type — update, different type — destroy and create fresh

This rule has a real consequence that surprises developers:

// Every time isLoggedIn changes, the entire form loses its state
{isLoggedIn ? <UserForm /> : <GuestForm />}

// UserForm and GuestForm are different types — React destroys one and creates the other
// Any input values typed into the form are gone
Enter fullscreen mode Exit fullscreen mode

If you want to keep state when switching between two components, they need to be the same type, rendered at the same position in the tree. Different types always means starting over.


The List Problem: Why key Exists

Single elements are straightforward to diff. Lists are where it gets genuinely hard.

Imagine you have a list of 5 items. Now you add a new item at the top. From React's perspective, looking position by position: position 1 changed, position 2 changed, position 3 changed, position 4 changed, position 5 changed, position 6 is new. It looks like every single item changed — even though you just prepended one.

Without any extra information, React would re-render all 5 existing items unnecessarily.

list without keys — prepend causes all items to look different by position

This is what key solves. A key is a stable identifier that tells React: "this item is this item, regardless of where it appears in the list."

With keys, React builds a lookup table of all existing items keyed by their identifier. Then for each new item in the list, it looks up: "do I already have a fiber for this key?" If yes, reuse it. If no, create it fresh. Items that were in the old list but aren't in the new one get deleted.

list with keys — React looks up by key, only new item is created

The practical result: prepend one item to a list of 1000, and React creates exactly one new fiber. The other 999 are reused instantly.

Why index as key breaks things:

// Dangerous — index as key
items.map((item, i) => <Row key={i} data={item} />)
Enter fullscreen mode Exit fullscreen mode

When you prepend a new item, every index shifts. The new item gets key=0, which used to belong to the old first item. React thinks it's reusing the old component — but it's actually showing different data through the same fiber. State from the old item leaks into the new item. Form inputs show wrong values. This is a genuinely hard bug to track down.

The fix is always the same: use something stable and unique as the key — an ID from your data, a slug, anything that doesn't change when the list is reordered.


After Finding the Changes: Marking, Not Doing

Here's a subtle but important detail about how React works.

During the Reconciler's walk of the tree, React does not touch the real DOM. It doesn't insert nodes, update text, or remove elements. It only marks fibers with notes about what needs to happen:

  • This fiber needs a new DOM node inserted
  • This fiber's props changed and the DOM needs updating
  • This fiber was removed and its DOM node should be deleted

Think of it like a doctor marking a patient's chart before surgery — deciding what needs to happen, not doing it yet.

Only after the entire tree has been walked and all decisions are made does React move to the Commit phase — where it reads those marks and makes the actual DOM changes, all at once, in a single synchronous pass.

This separation is what makes React's updates feel atomic. You never see a half-updated UI. Either the whole update is done, or none of it is.


The Full Picture in One Paragraph

Every state change triggers a tree walk. React follows a trail of flags to find what might have changed, skips every clean subtree instantly, checks whether props actually changed before re-running anything, compares old and new output by type first and content second, uses key props to track list items regardless of position, and marks everything that needs updating — without touching the DOM. Then, in one final pass, it applies all the marks at once.

That's reconciliation. It's why updating one input in a form with 200 fields is fast, why React.memo works, and why key is not optional for lists.


What's Coming in Part 4

In Part 4 we look at one of the most interesting ideas React borrowed from academic computer science to make Suspense and ErrorBoundary work — algebraic effects. It's simpler than it sounds, and understanding it will make how Suspense actually works finally click.


🎬 Watch These

JSer (jser.dev) — How does React re-render internally?
Source-level walkthrough of the re-render process — the trail of flags, props comparison, and the two-phase render/commit split.

JSer (jser.dev) — How does React bailout work?
Why most of your component tree gets skipped on every render, and what exactly triggers a re-render vs a skip.

JSer (jser.dev) — How does 'key' work internally?
The full list diffing algorithm — how React uses keys to match old and new items, and why the wrong key causes bugs.


🙏 Sources & Thanks


Part 4 is next — algebraic effects, and how Suspense actually works under the hood. 🔧


Tags: #react #javascript #webdev #tutorial

Top comments (0)