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:
useCallbackdoes 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.
