Skip to content

Virtual DOM and Reconciliation Interview Questions & Answers

15 questions Updated 2026-06-24 Share:

React virtual DOM and reconciliation interview questions — diffing algorithm, Fiber architecture, keys, bailout conditions, and how React decides what to re-render.

Read the in-depth guideReact Virtual DOM and Reconciliation — A Complete Guide(opens in new tab)
15 of 15

The virtual DOM is an in-memory JavaScript object tree that mirrors the real DOM structure. React keeps this lightweight copy and uses it as a scratchpad to figure out the minimum set of real DOM mutations needed to bring the UI up to date.

// After setState React builds a new VDOM tree like this in memory:
{ type: 'ul', props: { className: 'list' }, children: [
  { type: 'li', props: { key: 'a' }, children: ['Alpha'] },
  { type: 'li', props: { key: 'b' }, children: ['Beta'] },
]}
// Diffing against the previous tree finds only the changed nodes,
// then a single batched DOM update is issued.

Direct DOM manipulation is expensive because the browser must recalculate layout, style, and paint. By diffing two cheap JS objects first, React minimises the number of DOM operations — especially important when many state updates happen in quick succession.

Rule of thumb: The virtual DOM is a performance optimisation, not a fundamental requirement of React — React Native and React Three Fiber use the same reconciler with no DOM at all.

Reconciliation is the process React uses to decide how to update the real DOM (or host environment) when state or props change. It compares the newly rendered element tree against the previous one and computes a minimal diff.

React's reconciler applies two heuristics to keep the diff O(n) instead of O(n³):

  1. Different type → tear down. If the root element of a subtree changes type (e.g., <div><span>), React destroys the old subtree and mounts a fresh one.
  2. Same type → update in place. If the type is the same, React updates only the changed props and recurses into children.
// Same type → React patches props, does NOT unmount
<Button color="red" />  // prev
<Button color="blue" /> // next  → only color prop updated

// Different type → full unmount + remount
<div>Hello</div>  // prev
<span>Hello</span> // next → div destroyed, span created

Rule of thumb: Reconciliation is cheap for most apps, but mismatched keys or unstable component types (functions defined in render) can force unnecessary unmounts.

Fiber (React 16+) is a rewrite of React's reconciler that replaces the old synchronous, recursive "stack reconciler" with an interruptible, incremental system.

The old reconciler walked the component tree in one synchronous call that could not be paused — long trees blocked the main thread and dropped frames.

Fiber represents each unit of work as a lightweight fiber node (a plain JS object) linked in a tree. The reconciler can:

  • Pause work mid-tree and resume it later.
  • Abort lower-priority work when something more urgent arrives.
  • Reuse completed work across multiple renders.
Fiber node (simplified):
{
  type,          // component type or host string
  stateNode,     // real DOM node or class instance
  child,         // first child fiber
  sibling,       // next sibling fiber
  return,        // parent fiber
  pendingProps,
  memoizedProps,
  memoizedState,
  effectTag,     // INSERT / UPDATE / DELETE
}

This architecture unlocks concurrent features like startTransition, useDeferredValue, and Suspense — things that are impossible without the ability to pause and reprioritise work.

Rule of thumb: You rarely interact with Fiber directly, but understanding it explains why concurrent React can keep the UI responsive while doing heavy rendering work.

When React reconciles a list of children it needs a way to match old items to new items across renders. Without a stable identity, it falls back to index-based matching, which breaks as soon as items are reordered, inserted, or removed.

// Bad: using index as key
{items.map((item, i) => <Row key={i} data={item} />)}
// If item at index 0 is deleted, all subsequent rows get new data
// but keep their existing DOM nodes and state — visual corruption.

// Good: stable domain ID
{items.map(item => <Row key={item.id} data={item} />)}
// React tracks each Row by id; deletion only unmounts one node.

Wrong keys cause two classes of bugs:

  1. State leaking across items — a text input retains its value when the item it belongs to changes.
  2. Missed unmounts — components that should destroy (and clean up effects) are reused instead.

Keys also force a fresh mount when you want to reset state — give a component a different key and React treats it as a completely new element.

Rule of thumb: Keys must be stable, unique among siblings, and derived from your data — not from array index unless the list is static and never reordered.

A component re-renders when React decides its output may have changed. The four triggers are:

  1. State updatesetState / setter from useState is called with a value that is not referentially equal to the current one.
  2. Parent re-renders — by default, when a parent renders, all its children render too (unless bailed out by React.memo).
  3. Context change — any component subscribed to a context re-renders when the context value changes.
  4. forceUpdate — class-component escape hatch, rarely used.
function Child({ value }) {
  // Re-renders whenever Parent re-renders, even if value is the same,
  // unless wrapped in React.memo.
  return <div>{value}</div>
}

React does not automatically bail out based on whether props are shallowly equal — that optimisation requires React.memo or shouldComponentUpdate.

Rule of thumb: Unnecessary re-renders are usually harmless in small trees; profile before optimising, as React.memo and memoisation hooks add their own overhead.

React's update cycle has two distinct phases with very different guarantees.

Render phase (pure, may be interrupted):

  • React calls your component function (or render()) to get a new element tree.
  • Diffs the result against the previous tree.
  • Builds a list of effects to apply.
  • May be paused, aborted, or restarted in Concurrent Mode — so the render phase must be side-effect free.

Commit phase (synchronous, cannot be interrupted):

  • React applies DOM mutations from the effect list.
  • Runs useLayoutEffect callbacks (before the browser paints).
  • Lets the browser paint.
  • Runs useEffect callbacks (after the browser paints).
// Safe in render phase: pure calculation
const doubled = value * 2

// Safe only in commit phase: DOM mutation, network request
useEffect(() => {
  document.title = `Count: ${count}` // runs after commit
}, [count])

Rule of thumb: Never put side effects directly in your component body — they may run multiple times or be thrown away during concurrent rendering.

The reconciler (react package) is framework-agnostic: it builds and diffs the virtual element tree, manages state and effects, and schedules work. It knows nothing about how to actually display anything.

A renderer is the bridge between the reconciler and a specific host environment. It receives instructions (create node, update prop, delete node) and executes them:

Renderer Host environment
react-dom Browser DOM
react-native iOS / Android views
@react-three/fiber WebGL / Three.js
react-test-renderer Plain JS objects (testing)
react-pdf PDF document
// react (reconciler) figures out *what* changed
// react-dom (renderer) figures out *how* to apply it to the DOM
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')).render(<App />)

Rule of thumb: React's portability comes from this separation — you can write the same component logic and target any host environment by swapping the renderer.

React skips re-rendering a subtree under two conditions:

  1. Same element reference — if renderChildren() returns the same object (by reference, not value) as last time, React skips diffing that subtree entirely. This is the basis of the children prop optimisation: elements created in the parent and passed as props are created before the parent re-renders, so their references remain stable.

  2. React.memo / PureComponent / shouldComponentUpdate — if the component is wrapped and its props are shallowly equal, React skips the render. React.memo with a custom comparator lets you define equality yourself.

// Pattern 1: stable children reference
function Parent() {
  const [count, setCount] = useState(0)
  return <Layout>{/* children created here don't re-create on Parent re-render */}</Layout>
}

// Pattern 2: React.memo
const Child = React.memo(function Child({ value }) {
  return <div>{value}</div>
})
// Child re-renders only when `value` changes (shallow equal check)

Note: the setter from useState and values from refs never change reference across renders, so they never cause unnecessary re-renders in memoised children.

Rule of thumb: Bail-out only skips the render call — effects and context subscriptions are still checked, and the fibre is still visited briefly in the reconciler's tree walk.

Hydration is the process by which React attaches event listeners and takes over management of server-rendered HTML without re-creating the DOM from scratch.

During hydration React walks the existing DOM and the virtual tree in parallel. If they match, React reuses the nodes (fast). If they don't match — a hydration mismatch — React either logs a warning and patches the DOM, or (in development) tears it down and re-renders from scratch.

// Common cause: rendering different content on server vs client
<p>{typeof window !== 'undefined' ? 'Client' : 'Server'}</p>
//           ^--- different on server vs browser → mismatch warning

Mismatches are problematic because:

  • Patches are extra work (doubles DOM writes on affected nodes).
  • Content flickers when the DOM is replaced.
  • Accessibility tools may read the pre-patch content.

Fixes: use suppressHydrationWarning for intentionally dynamic values (timestamps), defer client-only content with useEffect + client-only useState, or use <ClientOnly> wrapper patterns.

Rule of thumb: Render the exact same tree on server and client; treat any hydration warning as a correctness bug, not a cosmetic issue.

In development, React StrictMode intentionally calls your component function twice (then discards the first result) to help you discover side effects hidden in the render phase.

// React calls this TWICE in dev/StrictMode:
function Counter() {
  console.log('render') // you'll see this twice in the console
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

If your render function is truly pure (no side effects), double-invocation is invisible — you get the same result both times. But if render contains a side effect (incrementing a ref, logging an analytics event, mutating external state), you'll see it doubled, revealing the bug.

This matches the Concurrent Mode contract: the render phase may be discarded and restarted at any time, so it must always be side-effect free.

Rule of thumb: Strict Mode double-invocation only happens in development and is one of the easiest ways to catch render-phase side effects before they cause production bugs.

These three terms are often confused because they're all part of the same pipeline.

React element — a plain JS object describing what to render. Created by JSX (<Button />React.createElement(Button, null)). Cheap to create, immutable, thrown away after reconciliation.

// An element is just data:
{ type: Button, props: { color: 'blue' }, key: null, ref: null }

Component — a function (or class) that accepts props and returns elements. It's the template; React calls it to produce elements.

Fiber — a live node in React's internal work tree, one per element in the rendered tree. Fibers are long-lived; they persist across renders and carry state, effects, and work-scheduling metadata. They are not exposed to user code.

Flow:
JSX → createElement → element (data)
                           ↓ reconciler
                        fiber (live node in Fiber tree, carries state)
                           ↓ renderer
                      real DOM node

Rule of thumb: You write components, you receive elements from JSX, and React manages fibers internally — mixing these terms up in an interview usually signals a shallow understanding of React internals.

Concurrent rendering (React 18+) lets React prepare multiple versions of the UI at the same time and interrupt, pause, or abandon in-progress renders when higher-priority work arrives.

The problem it solves: in synchronous React, any state update — even a low-priority background one — could block the main thread, causing the UI to feel janky during large re-renders (typing into a filtered list, animating while data loads).

import { startTransition } from 'react'

// Mark filter update as non-urgent — React can pause it
// if the user types again before it finishes
startTransition(() => {
  setFilter(e.target.value)
})

Key tools:

  • startTransition / useTransition — mark an update as low priority.
  • useDeferredValue — defer a derived value to avoid blocking.
  • Suspense — declaratively show a fallback while async work is pending.

Rule of thumb: Concurrent rendering is opt-in per update, not a mode you flip globally. Most updates are still synchronous; you use startTransition only for genuinely expensive, deferrable work.

A pure function always produces the same output for the same inputs and has no side effects. React requires render functions to be pure because:

  1. The render phase can be interrupted and restarted (Concurrent Mode) — side effects in render would fire multiple times or be abandoned mid-execution.
  2. Strict Mode double-invokes renders — to detect purity violations in development.
  3. Server-side rendering — the render phase runs in Node.js where no DOM, window, or browser APIs exist; impure renders that touch these break SSR.
// Impure render — BAD
let calls = 0
function Counter() {
  calls++          // side effect: mutates external state on every render
  return <p>{calls}</p>
}

// Pure render — GOOD
function Counter({ count }) {
  return <p>{count}</p>  // same props → same output, every time
}

Rule of thumb: If you need to produce a side effect (fetch, log, update the DOM), put it inside useEffect or an event handler — never directly in the function body.

Both hooks run after React commits changes to the DOM, but at different points in the browser's rendering pipeline.

useLayoutEffect fires synchronously after DOM mutations but before the browser paints. Use it when you need to read layout information (element sizes, scroll position) or make DOM mutations that must be invisible to the user.

useEffect fires asynchronously after the browser has painted. It doesn't block the visual update, making it the right place for network requests, subscriptions, and analytics.

React commits DOM mutations
      ↓
useLayoutEffect runs (synchronous, before paint)
      ↓
Browser paints
      ↓
useEffect runs (asynchronous, after paint)
useLayoutEffect(() => {
  // Safe to read DOM measurements here — layout is complete but not painted
  const height = ref.current.getBoundingClientRect().height
  setHeight(height) // won't cause visible flash
})

Rule of thumb: Start with useEffect; switch to useLayoutEffect only if you see a visible flash caused by a DOM measurement + state update cycle.

The primary tool is the React DevTools Profiler tab, which records a flame chart of which components rendered, how long each took, and why each rendered (changed props, state, context, or parent).

Steps:

  1. Open DevTools → Profiler → click Record.
  2. Perform the interaction that feels slow.
  3. Stop recording and inspect the flame chart.
  4. Look for components with long render times or high render counts.
  5. Check "Why did this render?" for specific cause.
// Instrument a component boundary for programmatic profiling
<React.Profiler id="FilterPanel" onRender={(id, phase, actualDuration) => {
  console.log(id, phase, actualDuration)
}}>
  <FilterPanel />
</React.Profiler>

Also useful:

  • Browser Performance tab for the full main-thread timeline.
  • console.time / console.timeEnd around suspected slow code.
  • why-did-you-render library to log unexpected re-renders.

Rule of thumb: Profile first, then optimise. Adding React.memo and memoisation hooks everywhere without profiling often makes performance worse by increasing the cost of the equality checks themselves.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel