Understand React's render cycle, reconciliation, and every optimization tool — React.memo, virtualization, code splitting, and the Profiler.
Think of a React component as a function that maps state to UI. Every time state or props change, React calls that function again — this is a re-render. React then compares the new output with the previous output (reconciliation) and surgically updates only the parts of the real DOM that changed. The key insight: re-rendering a component is cheap. React works with JavaScript objects — comparing two trees of plain JS objects is fast. What is expensive is touching the real DOM. React's entire architecture is designed to batch and minimize real DOM mutations. Performance problems in React have three distinct causes. First: rendering too often — components re-rendering when nothing relevant to them changed. Second: rendering too much — a single render that does too much work (sorting 10,000 items on every keystroke). Third: rendering too large a tree — a component that touches the DOM for 1,000 nodes when only 10 are visible. Each cause has its own set of tools: React.memo and useCallback for unnecessary re-renders, useMemo for expensive computations per render, and virtualization (react-window) for large DOM trees. The React Profiler tells you which problem you actually have before you start optimizing.
A component re-renders in exactly four situations:
function Parent() {
const [count, setCount] = useState(0)
return (
<div>
<Child /> {/* re-renders every time Parent re-renders */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
</div>
)
}
function Child() {
console.log('Child rendered') // logs on every Parent state update
return <div>I am static</div>
}
Wrapping a component in React.memo makes React skip re-rendering it if its props haven't changed (shallow comparison).
// Without memo — re-renders on every parent update
function Child({ name }) { console.log('render'); return <p>{name}</p> }
// With memo — skips re-render if name prop is the same reference/value
const Child = React.memo(function Child({ name }) {
console.log('render')
return <p>{name}</p>
})
// Custom comparator — control what "same" means
const Child = React.memo(Child, (prev, next) => prev.name === next.name)
Common trap: React.memo only helps if the props are actually stable. Object/array/function props created inline are new references every render — memo never skips.
// ✗ Defeats React.memo — style is a new object every render
<Child style={{ color: 'red' }} />
// ✓ Stable reference — React.memo can skip
const style = useMemo(() => ({ color: 'red' }), [])
<Child style={style} />
React diffs the new virtual DOM tree against the previous one using two heuristics:
Heuristic 1 — Different element type = destroy and rebuild the subtree:
// Before: <Counter /> inside <div>
// After: <Counter /> inside <section>
// React destroys the div subtree and rebuilds — Counter's state is lost!
Heuristic 2 — Keys identify list elements across renders:
// Without keys — React diffs by index
// Inserting at start: ALL items appear to change → O(n) DOM updates
[A, B, C] → [X, A, B, C]
// With keys — React matches by identity
// Inserting at start: only X is new → O(1) DOM update
<li key="a">A</li><li key="b">B</li> → <li key="x">X</li><li key="a">A</li>
// ✗ Layout thrashing — forces browser to recalculate layout repeatedly
elements.forEach(el => {
const height = el.offsetHeight // read (forces layout)
el.style.height = height * 2 + 'px' // write (invalidates layout)
// Next iteration's read forces layout recalculation again!
})
// ✓ Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight) // all reads
heights.forEach((h, i) => elements[i].style.height = h * 2 + 'px') // all writes
import { lazy, Suspense } from 'react'
// Route-level code splitting — bundle only loaded when route is visited
const Dashboard = lazy(() => import('./Dashboard'))
const Analytics = lazy(() => import('./Analytics'))
const Settings = lazy(() => import('./Settings'))
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}
// Component-level splitting — heavy component loaded on demand
const HeavyChart = lazy(() => import('./HeavyChart'))
function Page() {
const [showChart, setShowChart] = useState(false)
return showChart
? <Suspense fallback={<Spinner />}><HeavyChart /></Suspense>
: <button onClick={() => setShowChart(true)}>Load Chart</button>
}
import { FixedSizeList } from 'react-window'
// ✗ Renders all 10,000 DOM nodes — catastrophic for performance
function BadList({ items }) {
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)} {/* 10,000 DOM nodes */}
</ul>
)
}
// ✓ Only renders ~15 visible rows — 10,000 items, ~15 DOM nodes
function GoodList({ items }) {
return (
<FixedSizeList
height={600} // visible container height
itemCount={items.length}
itemSize={50} // height of each row
width="100%"
>
{({ index, style }) => (
<div style={style} key={items[index].id}>
{items[index].name}
</div>
)}
</FixedSizeList>
)
}
import { Profiler } from 'react'
function onRenderCallback(id, phase, actualDuration, baseDuration) {
// id: component name
// phase: 'mount' or 'update'
// actualDuration: time this render took (ms)
// baseDuration: estimated time without memoization
console.log({ id, phase, actualDuration, baseDuration })
}
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList products={products} />
</Profiler>
Use the React DevTools Profiler tab to record a session, identify which components render most and take longest, and find the actual bottleneck before adding any optimizations.
Moving state as close to where it's used as possible is often more effective than adding memoization:
// ✗ State too high — every count change re-renders ExpensiveTree
function App() {
const [count, setCount] = useState(0)
return <><Counter count={count} onChange={setCount} /><ExpensiveTree /></>
}
// ✓ Colocate state — count change only re-renders Counter
function App() {
return <><Counter /><ExpensiveTree /></>
}
function Counter() {
const [count, setCount] = useState(0) // moved into Counter
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
// ✓ Lift content — ExpensiveTree is passed as children, doesn't re-render
function App() {
return <Counter><ExpensiveTree /></Counter>
}
function Counter({ children }) {
const [count, setCount] = useState(0)
return <div><button onClick={() => setCount(c => c + 1)}>{count}</button>{children}</div>
// children (ExpensiveTree) is created by App — doesn't re-render when count changes
}Many developers think re-renders are always expensive — but re-rendering (calling the component function) is cheap. React works in JavaScript objects. The expensive part is DOM mutation. React's reconciler batches and minimizes actual DOM changes. Unnecessary re-renders only become a problem at scale.
Many developers think React.memo should be applied to all components by default — React.memo has overhead (shallow comparison on every render). It only helps if the prevented re-render cost exceeds the comparison cost. Apply it surgically where the Profiler shows actual slowness.
Many developers think keys only matter for lists — keys also control component identity. Giving the same component a different key forces React to destroy and remount it, resetting all state. This is the cleanest way to reset a component's internal state when a prop changes.
Many developers think code splitting with React.lazy always improves performance — lazy loading adds a network round trip when the chunk is first needed. If a component is always used immediately on page load, eager loading is faster. Lazy loading helps for routes and features users may never visit.
Many developers think useCallback and useMemo are the first tools for performance — they are the last resort after state colocation, component extraction, and React.memo. Premature memoization adds complexity and often negative performance impact for components that weren't actually bottlenecks.
Flipkart's product listing page uses virtualization for the product grid — showing thousands of results with only ~20 DOM nodes at any time. Without react-window, the initial paint would show thousands of DOM nodes causing layout, paint, and memory issues on low-end Android devices.
Chat applications use React.memo on message components. A new incoming message updates the message list state — without memo, every previous message re-renders. With memo keyed to message ID, only the new message renders.
Code editors in browser (like CodeSandbox) lazy-load the Monaco editor chunk (~2MB) only when the user navigates to the code tab. The bundle split keeps the initial load fast; the large chunk loads on demand when actually needed.
E-commerce cart sidebars use state colocation — the cart open/close state lives in the cart component itself, not at the root. Toggling the cart doesn't re-render the entire product page, only the cart overlay.
Dashboard applications with live data streams use the React Profiler to identify that the header re-renders on every WebSocket message — because the live data context contains the header's unrelated user data. Splitting the live data context from the user context fixes 60fps jank.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.