Post

Beyond the UI: Understanding Modern Frontend Engineering · Part 7

New to this series? Start with Part 1

Re-Renders Explained: What Actually Happens When Frontend State Changes

One of the most common pieces of advice in frontend development is:

Avoid unnecessary re-renders.

You hear it when working with React, when discussing component performance, and when someone suggests memo, useMemo, or useCallback; eventually, it can start sounding as though re-rendering is inherently bad, but it isn’t.

Re-rendering is how modern UI frameworks keep the screen synchronized with application state.

Consider a simple counter:

1
2
3
4
5
6
7
8
9
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

The browser initially displays:

1
Count: 0

Then the user clicks the button.

1
2
3
4
5
Count: 0
    │
    │ click
    ▼
setCount(1)

The component re-renders.

Eventually, the browser displays:

1
Count: 1

That process sounds simple, but there are several steps hidden in between.

React doesn’t immediately replace the entire DOM.

The browser doesn’t repaint the entire page just because a state variable changed.

Instead, the framework performs work to determine what the UI should now look like, compares that result with what existed before, and updates only the parts of the real DOM that actually changed.

A simplified mental model looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
State Change
    │
    ▼
Component Re-renders
    │
    ▼
New UI Description
    │
    ▼
Compare With Previous Result
    │
    ▼
Determine Actual Changes
    │
    ▼
Update DOM
    │
    ▼
Browser Rendering Pipeline

Understanding this process is important because it helps separate two ideas developers often confuse: React rendering and browser rendering, which are not the same thing.


React Rendering Is Not Browser Rendering

In the first article in this series, we explored the browser rendering pipeline:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DOM
 │
 ▼
Style
 │
 ▼
Layout
 │
 ▼
Paint
 │
 ▼
Composite
 │
 ▼
Pixels

That process belongs to the browser.

React’s rendering happens before that.

React decides what the DOM should look like.

The browser decides how that DOM becomes pixels.

Conceptually:

1
2
3
4
5
6
7
8
9
10
11
12
13
Application State
      │
      ▼
     React
      │
      ▼
DOM Changes
      │
      ▼
   Browser
      │
      ▼
Layout / Paint / Composite

This distinction matters.

A React component can re-render without causing any DOM update at all.

And if the DOM doesn’t change, the browser may have very little visual work to do.


What Happens When State Changes?

Let’s use a slightly larger example.

1
2
3
4
5
6
7
8
9
10
11
12
13
function Profile() {
  const [name, setName] = useState("Billy");

  return (
    <section>
      <h1>Hello, {name}</h1>

      <button onClick={() => setName("William")}>
        Change Name
      </button>
    </section>
  );
}

Initially, React produces a UI description conceptually similar to:

1
2
3
4
5
section
├── h1
│   └── "Hello, Billy"
└── button
    └── "Change Name"

After:

1
setName("William");

React renders the component again.

Now the result looks like:

1
2
3
4
5
section
├── h1
│   └── "Hello, William"
└── button
    └── "Change Name"

Most of the structure is identical.

Only one text value changed.

So React does not need to rebuild the entire DOM.

It only needs to update:

1
2
3
"Hello, Billy"
       ↓
"Hello, William"

The process can be understood as three broad stages:

1
2
3
4
5
6
7
Render
   │
   ▼
Reconciliation
   │
   ▼
Commit

Let’s look at each one.


The Render Phase

When a component re-renders, React executes the component function again.

That point is worth emphasizing.

Consider:

1
2
3
4
5
6
7
function Counter() {
  console.log("Counter rendered");

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

  return <p>{count}</p>;
}

Whenever React renders Counter, the function executes.

That means:

1
console.log("Counter rendered");

runs again.

So rendering does not mean React simply reuses the previous function result.

The component runs again to determine what the UI should look like for the current state and props.

Conceptually:

1
2
3
4
5
6
State = 0

Counter()
   │
   ▼
<p>0</p>

Then:

1
2
3
4
5
6
State = 1

Counter()
   │
   ▼
<p>1</p>

React now has a new description of the UI.

But no DOM changes necessarily happened yet.

That distinction becomes very important.


Reconciliation

Once React has the new result, it compares it with the previous one.

This process is known as reconciliation.

Suppose before:

1
2
3
4
<div>
  <h1>Products</h1>
  <p>3 items</p>
</div>

and after:

1
2
3
4
<div>
  <h1>Products</h1>
  <p>4 items</p>
</div>

The structure did not change.

The div still exists.

The h1 is the same.

Only the paragraph text changed.

React can therefore conclude:

1
2
3
4
Keep div
Keep h1
Keep p
Update text

instead of:

1
2
Delete everything
Recreate everything

This is the heart of reconciliation.

React is determining the minimum DOM work needed to make the actual interface match the new component output.


The Commit Phase

Once React knows what needs to change, it performs those mutations during the commit phase.

This is where the real DOM is updated.

So the flow becomes:

1
2
3
4
5
6
7
8
9
10
11
12
13
State Change
    │
    ▼
Render Phase
Component functions run
    │
    ▼
Reconciliation
Compare old and new
    │
    ▼
Commit Phase
Update actual DOM

Only after actual DOM changes occur does the browser potentially need to perform its own rendering work.

1
2
3
4
5
6
7
8
9
10
11
12
React Commit
     │
     ▼
DOM Changed
     │
     ▼
Browser
     │
     ├── Style?
     ├── Layout?
     ├── Paint?
     └── Composite?

This connects directly to everything we learned about reflow and repaint.


Re-Rendering Does Not Mean DOM Mutation

This is one of the biggest misconceptions in React performance discussions.

Suppose:

1
2
3
4
5
function Greeting({ name }) {
  console.log("render");

  return <h1>Hello {name}</h1>;
}

React may execute this component again.

But if:

1
name = "Billy"

both before and after the render, the resulting UI may be identical.

React can determine:

1
2
3
Previous: <h1>Hello Billy</h1>

New:      <h1>Hello Billy</h1>

No meaningful DOM change is required.

So:

1
2
3
Component Render
      ≠
DOM Update

And:

1
2
3
DOM Update
      ≠
Full Browser Repaint

These are separate stages.

That is why the statement:

“This component rendered again!”

doesn’t automatically mean:

“We have a serious performance problem.”

The cost depends on what happens during that render and what actual work follows.


Why Child Components Re-Render

This is another area that confuses many developers.

Consider:

1
2
3
4
5
6
7
8
9
10
11
12
13
function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>

      <Header />
    </>
  );
}

Header doesn’t use count.

Yet when App re-renders, Header may also render again.

Why?

Because Header is part of the component tree produced by App.

Conceptually:

1
2
3
4
5
App re-renders
    │
    ├── Button
    │
    └── Header

React walks through that subtree to determine what the updated UI should look like.

Again, this doesn’t necessarily mean the DOM represented by Header changes.

The component can execute again while producing exactly the same result.


Parent Re-Render Does Not Mean Everything Changed

Suppose:

1
2
3
4
5
6
7
function Header() {
  return (
    <header>
      <h1>My Store</h1>
    </header>
  );
}

Every time the parent renders, this may execute again.

But React compares:

1
2
3
4
5
Previous Header

<header>
  <h1>My Store</h1>
</header>

with:

1
2
3
4
5
New Header

<header>
  <h1>My Store</h1>
</header>

Nothing changed.

So no DOM mutation is required.

This is why React can tolerate far more component renders than developers often assume.

The framework is designed around the idea that rendering should generally be cheap.

Problems appear when rendering itself becomes expensive.


When Re-Renders Actually Become Expensive

Imagine a component does this:

1
2
3
4
5
function Analytics({ transactions }) {
  const report = generateComplexReport(transactions);

  return <ReportView report={report} />;
}

If generateComplexReport() processes hundreds of thousands of records, every render can become expensive.

Now suppose a parent re-renders frequently.

1
2
3
4
5
6
7
Parent changes
     │
     ▼
Analytics renders
     │
     ▼
Expensive calculation

Even if the resulting DOM doesn’t change, the expensive JavaScript already ran.

This is where unnecessary rendering becomes a real performance problem.

The issue isn’t:

1
DOM updated too much

It might instead be:

1
JavaScript executed too much

That connects directly to the Event Loop article.

Heavy component rendering occupies the main thread just like any other JavaScript.


A Simple Example of Expensive Rendering

Consider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function ProductList({ products, search }) {
  const results = products
    .filter(product =>
      product.name
        .toLowerCase()
        .includes(search.toLowerCase())
    )
    .sort((a, b) => a.price - b.price);

  return (
    <ul>
      {results.map(product => (
        <li key={product.id}>
          {product.name}
        </li>
      ))}
    </ul>
  );
}

For:

1
50 products

this probably doesn’t matter.

For:

1
500,000 products

it might.

Now imagine an unrelated state change causes the component to render repeatedly.

1
2
3
4
5
6
7
8
9
10
11
12
13
Theme toggled
     │
     ▼
Parent re-render
     │
     ▼
ProductList executes
     │
     ▼
Filter 500,000 products
     │
     ▼
Sort 500,000 products

Even if the product list itself didn’t actually change, substantial work occurred.

This is where memoization can help.


What useMemo Actually Does

Consider:

1
2
3
4
5
const filteredProducts = useMemo(() => {
  return products.filter(product =>
    product.name.includes(search)
  );
}, [products, search]);

The goal of useMemo is not:

Make React faster.

Its purpose is more specific.

It says:

Recalculate this value only when these dependencies change.

Conceptually:

1
2
3
4
5
6
7
8
9
10
11
Render
  │
  ▼
Did products or search change?
      │
   ┌──┴───┐
   │      │
  Yes     No
   │      │
   ▼      ▼
Calculate  Reuse

If some unrelated state changes:

1
sidebarOpen

the component may still re-render.

But the expensive product filtering can be skipped.

This distinction is important.

useMemo doesn’t necessarily prevent rendering.

It prevents recalculating a memoized result when dependencies haven’t changed.


Do Not Memoize Everything

After learning about useMemo, it’s tempting to do this:

1
2
3
const name = useMemo(() => {
  return `${firstName} ${lastName}`;
}, [firstName, lastName]);

But calculating:

1
`${firstName} ${lastName}`

is extremely cheap.

Now we’ve added:

1
2
3
4
Memoization logic
Dependency tracking
Additional code
Mental overhead

to avoid a trivial string concatenation.

Optimization itself has a cost.

A useful rule is:

Memoize expensive computations or values whose identity meaningfully matters, not every expression in your component.

Measure before making everything more complicated.


React.memo

Now suppose the expensive work happens inside a child component.

1
2
3
4
5
6
7
8
9
10
function Dashboard() {
  const [sidebarOpen, setSidebarOpen] = useState(false);

  return (
    <>
      <Sidebar open={sidebarOpen} />
      <ExpensiveChart />
    </>
  );
}

When sidebarOpen changes:

1
2
3
4
Dashboard re-renders
      │
      ├── Sidebar
      └── ExpensiveChart

But ExpensiveChart receives no changing props.

We may wrap it:

1
2
3
const ExpensiveChart = React.memo(function ExpensiveChart() {
  return <Chart />;
});

Now React can compare its props and potentially skip rendering the component when those props haven’t changed.

Conceptually:

1
2
3
4
5
6
7
8
9
10
11
Parent re-renders
      │
      ▼
Check child props
      │
   ┌──┴───┐
   │      │
Changed  Same
   │      │
   ▼      ▼
Render   Skip

Again, the point isn’t that every child should use React.memo.

It is useful when:

  • The component renders frequently
  • Rendering is meaningfully expensive
  • Its props often remain unchanged

Otherwise, the additional complexity may provide little benefit.


Why React.memo Sometimes Doesn’t Work

Consider:

1
2
3
4
5
6
7
8
9
10
const ProductCard = React.memo(function ProductCard({
  product,
  onSelect
}) {
  return (
    <button onClick={() => onSelect(product.id)}>
      {product.name}
    </button>
  );
});

The parent does:

1
2
3
4
<ProductCard
  product={product}
  onSelect={(id) => setSelectedId(id)}
/>

Every parent render creates a new function:

1
(id) => setSelectedId(id)

Even though the function does the same thing, its identity is new.

Conceptually:

1
2
3
4
5
6
7
Previous render:

onSelect = Function A

New render:

onSelect = Function B

So from a shallow prop-comparison perspective:

1
Function A !== Function B

and the memoized child may render again.

This is where useCallback enters the picture.


What useCallback Actually Does

You might write:

1
2
3
const handleSelect = useCallback((id) => {
  setSelectedId(id);
}, []);

Then:

1
2
3
4
<ProductCard
  product={product}
  onSelect={handleSelect}
/>

Now the function reference can remain stable across renders unless its dependencies change.

Conceptually:

1
2
3
4
5
6
7
8
Render 1
handleSelect = Function A

Render 2
handleSelect = Function A

Render 3
handleSelect = Function A

This can help when function identity matters, especially when passing callbacks into memoized child components.

But again:

useCallback does not magically make a function faster.

It memoizes the function reference.

If nothing depends on that identity, it may provide no practical benefit.


useMemo vs useCallback

The difference is simple.

useMemo memoizes a value.

1
2
3
const total = useMemo(() => {
  return calculateTotal(items);
}, [items]);

useCallback memoizes a function reference.

1
2
3
const handleSave = useCallback(() => {
  save(items);
}, [items]);

Conceptually:

1
2
3
4
5
6
7
8
9
10
useMemo
   │
   ▼
Remember result


useCallback
   │
   ▼
Remember function

Both are performance tools.

Neither should automatically appear everywhere.


Object Identity Causes Similar Problems

Suppose:

1
2
3
<Chart
  options=
/>

Every render creates a new object.

1
2
3
Render 1 → Object A

Render 2 → Object B

Even though:

1
2
3
Object A contents
      =
Object B contents

their references differ.

If Chart is memoized and relies on shallow prop comparison, it may still render again.

One solution can be:

1
2
3
4
5
6
const options = useMemo(() => ({
  showLegend: true,
  responsive: true
}), []);

<Chart options={options} />

Now the object identity remains stable.

But the same warning applies:

Only optimize where the identity actually causes meaningful work.


Keys and Reconciliation

Keys are another important part of how React reasons about changes.

Consider:

1
2
3
4
5
6
{products.map(product => (
  <ProductCard
    key={product.id}
    product={product}
  />
))}

The key helps React identify which item corresponds to which previous item.

Suppose:

1
2
3
4
5
Before

A
B
C

then:

1
2
3
4
5
6
After

A
X
B
C

With stable keys, React can understand:

1
2
3
4
A → same
X → new
B → same
C → same

Without useful identity, React has a harder time reasoning about which items moved, appeared, or disappeared.

This matters not only for performance but also for preserving component state correctly.


Why Array Index Keys Can Be Problematic

Consider:

1
2
3
items.map((item, index) => (
  <Row key={index} item={item} />
));

Suppose:

1
2
3
0 → Alice
1 → Billy
2 → John

Then Alice is removed.

Now:

1
2
0 → Billy
1 → John

React sees:

1
2
key 0 still exists
key 1 still exists

even though the underlying items associated with those positions changed.

This can cause surprising state behaviour in interactive lists.

Stable business identifiers are usually better:

1
2
3
4
<Row
  key={user.id}
  item={user}
/>

Keys are fundamentally about identity.

They tell React:

This element represents the same conceptual thing as before.


State Position Matters

React associates state with a component’s position and identity in the rendered tree.

Imagine:

1
2
3
4
5
{loggedIn ? (
  <Dashboard />
) : (
  <Login />
)}

When the condition changes, one component leaves and another enters.

Its state lifecycle changes accordingly.

Similarly, changing a component’s key can cause React to treat it as a new component.

For example:

1
<Profile key={userId} userId={userId} />

Changing:

1
userId = 1

to:

1
userId = 2

can intentionally reset Profile state because React treats the new key as a different identity.

This can be useful for cases such as resetting forms when switching records.


Render Phase vs Effects

Now consider:

1
2
3
4
5
6
7
8
9
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <ProfileView user={user} />;
}

The effect doesn’t run in the middle of calculating the component’s JSX.

Rendering determines what the UI should look like.

Effects are for synchronizing with things outside the pure render calculation, such as:

1
2
3
4
5
Network
Browser APIs
Subscriptions
Timers
Third-party libraries

This distinction helps explain why render functions should ideally remain pure.

Given the same state and props:

1
2
3
4
same input
   │
   ▼
same UI description

That makes React’s rendering model much easier to reason about.


Why Side Effects During Render Are Dangerous

Imagine:

1
2
3
4
5
function BadComponent() {
  fetch("/api/analytics");

  return <div>Hello</div>;
}

Every render triggers a network request.

If the component renders three times:

1
2
3
4
5
Render 1 → request

Render 2 → request

Render 3 → request

Now application behavior depends on how often React happens to render.

That is fragile.

Rendering should describe UI, not perform unrelated side effects.

Those operations belong in appropriate event handlers, effects, or data-fetching mechanisms.


Strict Mode Can Make This More Visible

During development, React Strict Mode may intentionally invoke certain logic more than once to help expose impure rendering and incorrect effect handling.

Developers sometimes see:

1
Why is this component rendering twice?

and assume React is broken.

But development behavior may deliberately stress-test assumptions.

That is another reason not to attach important side effects directly to render execution.

A component should remain safe to evaluate when React needs to understand what the UI should look like.


Re-Renders and Context

Suppose:

1
2
3
4
5
<AppContext.Provider
  value=
>
  <App />
</AppContext.Provider>

Many components consume this context.

Then:

1
notifications changes

The provider’s value changes.

Consumers may need to render again, including ones primarily interested in other parts of that context.

This is why very large contexts can sometimes become performance hotspots.

A better architecture might separate concerns:

1
2
3
4
5
6
7
UserContext

ThemeContext

CartContext

NotificationContext

or use a store with selector-based subscriptions.

Again, the principle from the previous state-management articles applies:

State distribution affects how much of your component tree responds when state changes.


Global State and Selectors

Imagine a store containing:

1
2
3
4
5
6
user
cart
theme
notifications
sidebar
products

A component needs only:

1
cart.length

A selector lets it subscribe to that specific information.

Conceptually:

1
2
3
4
5
6
7
Global Store
    │
    ├── Component A → user
    │
    ├── Component B → cart count
    │
    └── Component C → theme

Now:

1
user changes

doesn’t necessarily require the cart-count component to render.

This is one reason state architecture and rendering performance are deeply connected.

The question isn’t only:

Where do we store state?

It is also:

Which components are notified when that state changes?


Server State Can Trigger Re-Renders Too

From the previous article, we know server-state libraries maintain cached data.

Suppose:

1
2
3
4
const { data: products } = useQuery({
  queryKey: ["products"],
  queryFn: fetchProducts
});

The cache receives updated server data.

1
2
3
4
5
6
7
8
9
10
Old Products
      │
      ▼
Query Refetch
      │
      ▼
New Products
      │
      ▼
Subscribed Components Re-render

That is expected.

The important thing is that components interested in unrelated queries don’t need to respond.

Well-designed subscriptions help contain updates to the parts of the interface that actually care about them.


Rendering Large Lists

One place re-render costs become very visible is large collections.

Suppose:

1
2
3
4
5
6
{transactions.map(transaction => (
  <TransactionRow
    key={transaction.id}
    transaction={transaction}
  />
))}

For:

1
20 transactions

probably fine.

For:

1
100,000 transactions

you have a different problem.

Even if React efficiently reconciles the list, rendering and maintaining tens of thousands of DOM elements is expensive.

This is where virtualization becomes useful.

Instead of rendering every row:

1
Rows 1 → 100,000

render only the ones currently visible:

1
Rows 420 → 450

Conceptually:

1
2
3
4
5
6
7
8
9
10
11
12
Dataset

100,000 rows
     │
     ▼
Virtualizer
     │
     ▼
Visible viewport
     │
     ▼
~30 DOM rows

This reduces both framework rendering work and browser DOM/rendering work.

Libraries such as TanStack Virtual and react-window use this approach.


Component Boundaries Matter

Imagine one enormous component:

1
2
3
function Dashboard() {
  // 2,000 lines of state, calculations and JSX
}

Any state change causes that component’s render logic to execute again.

Breaking the interface into meaningful components can create clearer boundaries:

1
2
3
4
5
6
7
Dashboard
│
├── Header
├── BalanceCard
├── Transactions
├── SpendingChart
└── Goals

This does not automatically guarantee fewer renders.

But it creates opportunities for:

1
2
3
4
5
Independent state ownership
Memoization
Selective subscriptions
Smaller render workloads
Clearer architecture

Good component design is therefore partly about defining sensible rendering boundaries.


Should You Fear Re-Renders?

No.

This is probably the most important practical lesson in the article.

A component doing this:

1
2
3
function Name({ name }) {
  return <p>{name}</p>;
}

can execute extremely quickly.

Spending hours preventing that component from rendering again may make your code harder to maintain without producing any measurable user benefit.

Meanwhile, a single component containing:

1
2
3
4
Expensive calculations
Large lists
Complex charts
Heavy parsing

can create real problems.

So instead of:

How do I stop all re-renders?

ask:

Which renders are actually expensive?

That is a much healthier performance mindset.


The Wrong Way to Optimize

A codebase can quickly become:

1
2
3
4
5
6
7
8
9
const Component = memo(({ value }) => {
  const result = useMemo(() => calculate(value), [value]);

  const onClick = useCallback(() => {
    ...
  }, []);

  ...
});

everywhere.

Memoization becomes the default rather than a targeted optimization.

Now every developer has to reason about:

1
2
3
4
5
Dependencies
Stable references
Memoization boundaries
Stale closures
Prop identity

even when the underlying render takes microseconds.

You’ve optimized the framework workload while increasing human workload.

That’s not always a good trade.


Measure Before Optimizing

The browser and React both provide tools for identifying performance problems.

React DevTools includes profiling capabilities that can help answer questions such as:

1
2
3
4
5
6
7
Which component rendered?

How long did it take?

Why did it render?

Which parts of the tree were expensive?

Browser performance tools can then show what happened afterward:

1
2
3
4
5
6
7
8
9
10
JavaScript
     │
     ▼
DOM Commit
     │
     ▼
Layout
     │
     ▼
Paint

Combining the two gives you a much clearer picture.

Perhaps React rendering is expensive.

Perhaps React is fast and browser layout is the real problem.

Perhaps neither is the bottleneck and you’re waiting on a network request.

Optimization should follow evidence.


A Practical Example

Suppose a dashboard has:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Dashboard() {
  const [sidebarOpen, setSidebarOpen] = useState(false);

  return (
    <>
      <Sidebar
        open={sidebarOpen}
        onClose={() => setSidebarOpen(false)}
      />

      <TransactionsTable />

      <LargeAnalyticsChart />
    </>
  );
}

Toggling the sidebar causes the dashboard to re-render.

If both TransactionsTable and LargeAnalyticsChart are cheap, stop there.

There may be nothing worth optimizing.

But suppose profiling reveals:

1
2
3
LargeAnalyticsChart render

180ms

every time the sidebar changes.

Now there is a measurable problem.

If the chart’s props remain unchanged, memoization may be appropriate:

1
2
3
4
5
const LargeAnalyticsChart = memo(
  function LargeAnalyticsChart({ data }) {
    return <Chart data={data} />;
  }
);

Perhaps chart data also requires an expensive transformation:

1
2
3
const chartData = useMemo(() => {
  return calculateAnalytics(transactions);
}, [transactions]);

Now the optimization has a reason.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Before

Sidebar toggle
     │
     ▼
Expensive analytics calculation
     │
     ▼
Chart render


After

Sidebar toggle
     │
     ▼
Cached analytics
     │
     ▼
Chart skipped / cheaper work

That’s much better than adding memoization everywhere “just in case.”


Rendering vs Committing

Another useful distinction is that React may perform rendering work that never reaches the DOM.

In modern React, rendering work can sometimes be interrupted, restarted, or discarded before it is committed.

That means:

1
2
3
4
5
6
7
8
9
10
11
Render work
    │
    ▼
Does React commit it?
    │
 ┌──┴───┐
 │      │
Yes     No
 │      │
 ▼      ▼
DOM    Discard

This is one reason render logic must remain pure.

React may evaluate a component without guaranteeing that the result becomes visible.

Side effects during rendering would therefore become unpredictable.

The commit phase is the point at which actual DOM changes are applied.


Re-Renders and the Event Loop

Everything we’ve discussed ultimately happens as JavaScript work on the main thread.

Suppose several expensive components render after one state update.

1
2
3
4
5
6
7
8
9
10
11
State Update
     │
     ▼
Component A     30ms
     │
Component B     40ms
     │
Component C     50ms
     │
     ▼
Total JS        120ms

The Event Loop article taught us what that means.

While this work runs:

1
2
3
4
User click       waiting
Animation        waiting
Rendering        waiting
Other JS         waiting

The issue isn’t merely that React “rendered too many times.”

The user-visible problem is that the main thread was occupied for too long.

That is why frontend performance concepts keep connecting back to one another.


Re-Renders and the Browser Rendering Pipeline

Now suppose React finishes reconciliation and commits:

1
2
3
4
width: 300px
      │
      ▼
width: 600px

React’s work is finished.

But the browser may now need:

1
2
3
4
5
6
7
8
9
10
Style
  │
  ▼
Layout
  │
  ▼
Paint
  │
  ▼
Composite

Alternatively, React might commit:

1
2
3
4
opacity: 0
     │
     ▼
opacity: 1

which may be handled much more cheaply.

So frontend performance has at least two separate dimensions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Framework Work

Component rendering
Reconciliation
DOM updates

        +

Browser Work

Style
Layout
Paint
Composite

Optimizing only one side can leave the actual bottleneck untouched.


A Better Mental Model

When state changes, don’t imagine:

1
2
3
4
State changes
     │
     ▼
Entire page rebuilt

Think:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
State changes
     │
     ▼
Affected component tree evaluated
     │
     ▼
New UI description
     │
     ▼
React reconciles
     │
     ▼
Actual differences identified
     │
     ▼
Necessary DOM mutations committed
     │
     ▼
Browser processes those changes

Every arrow represents a potential cost.

But none should automatically be assumed to be expensive.


Practical Rules for Re-Render Performance

A few principles go a long way: keep state as close as practical to the components that actually need it, avoid placing rapidly changing local state unnecessarily high in the tree, and avoid repeating expensive calculations on every render when memoization or restructuring can prevent that work.

Use stable keys for lists, apply memoization when profiling shows meaningful savings, design Context boundaries and global store subscriptions intentionally, virtualize genuinely large lists, and avoid side effects during render.

Above all, do not optimize purely based on how many renders a console statement reports.

A render is only a problem when the work involved is actually expensive enough to matter.


Bringing It All Together

A state update begins inside your application:

1
2
3
4
State
  │
  ▼
Re-render

That re-render produces a new description of the UI.

1
2
3
4
Component Function
       │
       ▼
New Element Tree

React compares it with what came before.

1
2
3
4
5
Previous Tree
      │
      ├────► Reconciliation
      │
New Tree

Only necessary changes are committed.

1
2
3
4
Difference
    │
    ▼
DOM Mutation

Then the browser takes over.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DOM
 │
 ▼
Style
 │
 ▼
Layout
 │
 ▼
Paint
 │
 ▼
Composite
 │
 ▼
Pixels

That gives us the full chain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
State Change
      │
      ▼
Component Render
      │
      ▼
Reconciliation
      │
      ▼
Commit
      │
      ▼
DOM Change
      │
      ▼
Browser Rendering
      │
      ▼
Updated Pixels

Once you understand those boundaries, performance discussions become much clearer.

You can ask:

1
2
3
4
5
6
7
8
9
10
11
Is the component render expensive?

Is reconciliation expensive?

Are we committing too many DOM changes?

Is layout the real problem?

Is painting expensive?

Is the main thread blocked?

Those are much better questions than simply asking:

Why is React re-rendering?


Final Thoughts

Re-renders have a reputation they do not entirely deserve: they are not bugs, they are not automatically performance problems, and preventing every possible render is not the goal of frontend engineering.

Rendering is how declarative UI frameworks work.

You describe:

1
2
What the UI should look like
for the current state.

Then the framework figures out how to get there.

The real performance question is not whether components render, but whether the amount of work happening during those renders prevents the application from delivering a smooth experience.

Sometimes the answer is yes: a large calculation runs repeatedly, a huge list is rendered unnecessarily, or a global state update wakes up hundreds of components, and those cases deserve optimization.

But often, rendering is already cheap, and in those cases adding layers of memoization can make the code harder to understand without making the application noticeably faster.

So the next time you see a component render again, don’t immediately ask:

“How do I stop this?”

Ask:

“What work is this render actually doing, and is that work expensive enough to matter?”

That is the question that leads to better frontend performance decisions.


What’s Next?

We’ve now explored how frontend state changes propagate through a component tree and eventually reach the browser.

Many of the expensive calculations we discussed share another important characteristic: sometimes the problem is not that the calculation is too slow, but that we are doing the same calculation repeatedly.

That brings us to another important frontend concept:

Memoization Explained: When Caching Computation Actually Helps

We’ll explore how memoization works, referential equality, memoized components, useMemo, useCallback, selectors, cache invalidation, and why adding memoization everywhere can sometimes make an application more complicated without making it any faster.

Because caching work is useful only when the work was worth avoiding in the first place.

This post is licensed under CC BY 4.0 by the author.