Understanding Re-renders
React beginners often fear re-renders. Experienced React developers understand them. Knowing exactly what triggers a re-render — and what does not — is the single most useful mental model for writing performant React applications.
What Triggers a Re-render?
A component re-renders for exactly four reasons:
State update — calling the setter from
useStateoruseReducerinside or affecting this componentParent re-render — when a parent component re-renders, all its children re-render by default, regardless of whether their props changed
Context update — when a context value changes, every consumer of that context re-renders
Force update —
useReducerdispatch with the same state, or callingforceUpdatein a class component
import { useState } from 'react'
function Parent() {
const [count, setCount] = useState(0)
// Every click re-renders Parent — AND re-renders Child,
// even though Child receives no props at all.
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<Child />
</div>
)
}
function Child() {
console.log('Child rendered') // Fires every time Parent re-renders
return <p>I am a child</p>
}What Does NOT Trigger a Re-render?
Ref changes —
ref.current = newValueis a mutation that React never observes. No re-render.Event handler changes — creating a new function on each render (without useCallback) does not trigger a re-render on its own; it only matters if passed as a prop to a memoized child
External variable mutations — mutating a non-state variable directly (e.g.
myArray.push(item)) does not trigger a re-renderSame state value — if you call
setStatewith the exact same value React bails out (Object.is comparison) and skips the re-render
import { useRef, useState } from 'react'
function Demo() {
const renderCount = useRef(0)
const [text, setText] = useState('')
renderCount.current++ // mutation — does NOT cause a re-render
// Calling setState with the same value bails out:
const [flag, setFlag] = useState(false)
const triggerBailout = () => setFlag(false) // false → false, no re-render
return (
<div>
<p>Renders: {renderCount.current}</p>
<input value={text} onChange={e => setText(e.target.value)} />
</div>
)
}The Render → Reconcile → Commit Pipeline
A "re-render" in React is actually three distinct phases:
Render — React calls your component function (or class render method) to get the new JSX description. This is pure — no side effects, no DOM mutations. In concurrent mode this may happen multiple times.
Reconcile (diffing) — React compares the new Virtual DOM tree with the previous one to compute the minimal set of changes.
Commit — React applies the DOM mutations, runs
useLayoutEffectsynchronously, lets the browser paint, then runsuseEffect.
Visualizing a Component Tree Re-render
Imagine this component tree. When App state changes, the default behaviour without any memoization is:
App (state changes)
├── Header ← re-renders (parent re-rendered)
│ └── Logo ← re-renders (parent re-rendered)
├── Sidebar ← re-renders (parent re-rendered)
│ ├── NavItem ← re-renders (parent re-rendered)
│ └── NavItem ← re-renders (parent re-rendered)
└── MainContent ← re-renders (parent re-rendered)
├── ArticleList ← re-renders (parent re-rendered)
│ └── Article ← re-renders (parent re-rendered)
└── Pagination ← re-renders (parent re-rendered)
Every single node re-renders. With React.memo on stable components,
only the nodes whose props actually changed would re-render.The "Renders Are Cheap" Philosophy
React's official guidance is that renders are intentionally cheap. A render is just a JavaScript function call that produces a plain object (the Virtual DOM). Modern JavaScript engines execute millions of such calls per second. The real cost is DOM mutation, which React minimises through diffing.
This means you should not optimise re-renders by default. The React team explicitly discourages wrapping everything in React.memo, useMemo, and useCallback pre-emptively — these optimisations have their own overhead (memory for cached values, equality comparisons) and make code harder to read.
When Renders Become Expensive
Renders become a real problem when:
The render function itself is slow — heavy computations, large array transformations, expensive formatting in the function body
Too many components re-render from a single update — a context at the root re-renders hundreds of subscribers
Render happens too frequently — a
mousemoveevent firing 60 times/second triggering a large subtreeRender triggers heavy child mounts/unmounts — not just updates, but full lifecycle cycles
// Expensive render — sort runs on every re-render
function ProductList({ products, searchTerm }) {
// This runs every render, even when products hasn't changed
const filtered = products
.filter(p => p.name.includes(searchTerm))
.sort((a, b) => a.price - b.price)
return filtered.map(p => <ProductCard key={p.id} product={p} />)
}
// Fixed — memoize the expensive computation
import { useMemo } from 'react'
function ProductList({ products, searchTerm }) {
const filtered = useMemo(
() =>
products
.filter(p => p.name.includes(searchTerm))
.sort((a, b) => a.price - b.price),
[products, searchTerm] // only re-compute when these change
)
return filtered.map(p => <ProductCard key={p.id} product={p} />)
}Context Updates
Context is a common source of surprise re-renders. Every time the context provider re-renders with a new value, every consumer component re-renders — even if the part of the value they use did not change.
// Problem: inline object creates a new reference every render
function App() {
const [user, setUser] = useState({ name: 'Alice', role: 'admin' })
const [theme, setTheme] = useState('dark')
// This object is new on every App render, even if user hasn't changed
// Every ThemeContext consumer re-renders unnecessarily
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<Router />
</UserContext.Provider>
</ThemeContext.Provider>
)
}
// Fix: split contexts so consumers only subscribe to what they need
// Fix: useMemo to stabilise the context value object
const themeValue = useMemo(() => ({ theme, setTheme }), [theme])Re-renders Are Not Always Bad
A re-render does not mean the DOM changed. If the new Virtual DOM output is identical to the previous one, React commits nothing. The render phase ran, but the user sees no visual change and no DOM was touched.
Scenario | Re-render fires? | DOM update? | Performance impact |
|---|---|---|---|
State changes | Yes | Only changed nodes | Low (usually) |
Parent re-renders, same props | Yes (without memo) | No | Very low |
Context updates | Yes (all consumers) | Only changed nodes | Medium |
Same state value set | No | No | None |
Ref mutation | No | No | None |