Advanced0 questionsFull Guide

React Rendering & Performance Interview Questions

Understand React's render cycle, reconciliation, and every optimization tool — React.memo, virtualization, code splitting, and the Profiler.

The Mental Model

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.

The Explanation

When does a component re-render?

A component re-renders in exactly four situations:

  1. Its own state changes — any useState or useReducer setter is called
  2. Its parent re-renders — by default, all children re-render when the parent does
  3. Its context value changes — any useContext call creates a subscription
  4. A hook it calls re-renders — custom hooks that internally use state/effects
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>
}

React.memo — skipping child re-renders

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} />

The reconciliation algorithm

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>

Avoid layout thrashing

// ✗ 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

Code splitting with React.lazy and Suspense

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>
}

Virtualization — rendering only what's visible

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>
  )
}

The React Profiler — finding actual bottlenecks

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.

State colocation — the best optimization

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
}

Common Misconceptions

⚠️

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.

Where You'll See This in Real Code

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.

Interview Cheat Sheet

  • Component re-renders when: its state changes, parent re-renders, context changes, or a hook it uses re-renders
  • Re-rendering (JS) is cheap — DOM mutation is expensive. Optimize the right thing.
  • React.memo: skips re-render if props are shallowly equal. Pair with useMemo/useCallback for stable props.
  • Keys control identity — different key = destroy and remount. Same key = reuse and update.
  • State colocation: move state close to usage — often eliminates need for memoization entirely
  • Children-as-props pattern: ExpensiveTree passed as children doesn't re-render on parent state changes
  • Code splitting: React.lazy + Suspense — lazy load routes and heavy components on demand
  • Virtualization: react-window/react-virtual — render only visible items, not the entire list
  • React Profiler: measure before optimizing. Profile → find bottleneck → fix → measure again.
  • Layout thrashing: batch all DOM reads before any DOM writes
  • useMemo for expensive computations — only when profiler confirms it's the bottleneck
  • React Compiler (React 19): auto-memoizes — reduces need for manual useMemo/useCallback
💡

How to Answer in an Interview

  • 1.The most important performance answer: "I always profile before optimizing. React DevTools Profiler shows me which components render most and take longest. Without that data, adding memoization is guesswork and often makes things worse." This signals senior-level discipline.
  • 2.State colocation is the underrated answer. When asked how to prevent unnecessary re-renders, mention colocation before memo: "The best optimization is often moving state down — if only one component uses a piece of state, it should live there. Fewer components own the state, fewer components re-render when it changes."
  • 3.The children-as-props trick is a differentiator: "You can prevent a child from re-rendering without React.memo by passing it as children from a parent that doesn't own the re-rendering state. Children are evaluated in the outer scope — the parent passing them doesn't change them when its own state changes."
  • 4.For virtualization questions, connect to metrics: "Without virtualization, rendering 5,000 list items creates 5,000 DOM nodes — causing 800ms+ layout time on a Moto G4. With react-window, only ~15 nodes exist at any time. Time to interactive drops from 800ms to under 100ms."
  • 5.When asked about keys: always give both use cases — list reconciliation (the expected answer) AND forced remounting (the surprise answer). "Key prop controls component identity. Changing a component's key makes React unmount the old instance and mount a fresh one — this is the cleanest way to reset internal state when a prop like userId changes."

Practice Questions

No questions tagged to this topic yet.

Related Topics

useCallback Hook — Complete React Interview Guide
Intermediate·4–8 Qs
react rendering reconciliation interview questions
Advanced·4–8 Qs
React Fiber Architecture Interview Questions
Advanced·4–8 Qs
Concurrent Rendering (React 18) Interview Questions
Advanced·4–8 Qs
useMemo Hook — Complete React Interview Guide
Intermediate·4–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz