What actually happens when state changes — re-renders, virtual DOM, diffing, keys, and the two-phase render model explained precisely.
React's job is to keep the UI in sync with state. Every time state changes, React needs to figure out what the screen should look like now — and what's the minimal set of DOM operations to get it there. Understanding React well means understanding exactly how it does that. When state changes, React calls your component function again. That function returns a new tree of React elements — plain JavaScript objects describing what the UI should look like. React hasn't touched the DOM yet. It's just building a description. This is the render phase. React then compares that new description against the previous one. This comparison is called reconciliation. React walks both trees simultaneously, finds the differences, and builds a list of DOM mutations to apply. Only then — in the commit phase — does React actually touch the DOM. The critical insight that trips up most developers: re-rendering a component does NOT mean its DOM nodes are updated. A component can re-render dozens of times while React touches zero DOM nodes — because the output was identical each time. Re-render = function called again. DOM update = something actually changed. This is also why "virtual DOM is faster" is a misleading statement. The virtual DOM adds a comparison step. It's only worthwhile because it minimizes expensive DOM mutations. The speed story is: batch many state changes → reconcile once → apply minimal DOM mutations. Four things trigger a re-render: state change (setState), context change (value changed in a Provider above), parent re-render (any parent renders → child renders by default), and a forced re-render (forceUpdate, rarely used). What does NOT trigger a re-render: prop changes that don't come with a parent re-render (impossible — changing props requires the parent to re-render), re-rendering a component whose props are the same (unless it's wrapped in React.memo), and directly mutating state (React never sees the change because you didn't call setState).
// Trigger 1: setState / useState setter
function Counter() {
const [count, setCount] = useState(0)
// Clicking the button triggers a re-render of Counter
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
// Trigger 2: Context value change
const ThemeContext = createContext('light')
function App() {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={theme}> {/* change here... */}
<ThemedButton /> {/* ...re-renders this */}
</ThemeContext.Provider>
)
}
function ThemedButton() {
const theme = useContext(ThemeContext) // subscribed → re-renders on value change
return <button className={theme}>Click</button>
}
// Trigger 3: Parent re-renders (the most common source of surprise re-renders)
function Parent() {
const [x, setX] = useState(0)
return <><Child /><button onClick={() => setX(x + 1)}>Update Parent</button></>
}
function Child() {
// Child re-renders every time Parent re-renders — even though it uses no props
console.log('Child rendered')
return <div>Static child</div>
}
// What does NOT trigger a re-render:
const obj = { value: 1 }
obj.value = 2 // Direct mutation — React never knows. Don't do this.
props.name = 'new' // Mutating props — also invisible to React, and breaks things.
// When you write JSX:
const element = <h1 className="title">Hello</h1>
// Babel compiles it to:
const element = React.createElement('h1', { className: 'title' }, 'Hello')
// Which produces a plain JavaScript object (the "virtual DOM node"):
const element = {
type: 'h1',
props: {
className: 'title',
children: 'Hello'
},
key: null,
ref: null,
// ...a few other internal fields
}
// The "virtual DOM" is just: a tree of these plain JS objects.
// React keeps TWO of these trees at all times:
// 1. The current tree (what's on screen now)
// 2. The work-in-progress tree (what should be on screen after this render)
//
// After reconciliation, work-in-progress becomes current.
// This is double buffering — more on this in the Fiber topic.
Naive tree diffing is O(n³). React achieves O(n) using two deliberate heuristics:
// Heuristic 1: Different element types → tear down, rebuild from scratch
// Before:
<div><Counter /></div>
// After:
<span><Counter /></span>
// React sees div → span. It destroys the div subtree entirely (unmounts Counter)
// and builds the span subtree fresh. Counter's state is LOST.
// This means: changing a wrapper element type loses all child state.
// Same type → update props, recurse into children
// Before: <div className="old">content</div>
// After: <div className="new">content</div>
// React keeps the DOM node, just updates className. No unmount. State preserved.
// Heuristic 2: Children are reconciled using keys
// Without keys — React diffs by position:
// Before: [<A/>, <B/>, <C/>]
// After: [<X/>, <A/>, <B/>, <C/>]
// React compares A→X (different), B→A (different), C→B (different), new→C
// Result: 3 updates + 1 insert. Expensive and wrong.
// With keys — React matches by identity:
// Before: [<A key="a"/>, <B key="b"/>, <C key="c"/>]
// After: [<X key="x"/>, <A key="a"/>, <B key="b"/>, <C key="c"/>]
// React sees key "x" is new (insert), "a","b","c" moved (reorder).
// Result: 1 insert + reorder. State on A, B, C is preserved.
// A todo list where items can be deleted
function TodoList() {
const [todos, setTodos] = useState(['Buy milk', 'Read book', 'Exercise'])
// ✗ WRONG: index as key
return (
<ul>
{todos.map((todo, index) => (
// When "Buy milk" (index 0) is deleted:
// "Read book" becomes index 0, "Exercise" becomes index 1
// React sees key 0 still exists — it REUSES the component instance
// Result: wrong state, wrong focus, wrong animations
<TodoItem key={index} text={todo} />
))}
</ul>
)
}
// ✓ CORRECT: stable unique ID as key
function TodoList() {
const [todos, setTodos] = useState([
{ id: 'a1', text: 'Buy milk' },
{ id: 'b2', text: 'Read book' },
{ id: 'c3', text: 'Exercise' }
])
return (
<ul>
{todos.map(todo => (
// When "Buy milk" (id: a1) is deleted:
// React sees key "a1" is gone → unmounts that instance
// "b2" and "c3" still exist → reuses those instances with correct state
<TodoItem key={todo.id} text={todo.text} />
))}
</ul>
)
}
// When IS index-as-key acceptable?
// Only when ALL three conditions are true:
// 1. The list is static — never reordered or filtered
// 2. Items are never inserted or deleted from the middle
// 3. Items have no internal state (they're purely display components)
// RENDER PHASE — pure, no side effects, may be interrupted (in Concurrent Mode)
// React calls your component functions
// Reconciles old tree vs new tree
// Builds a list of "effects" (DOM mutations needed)
// This phase can be paused, restarted, or thrown away entirely.
function MyComponent() {
// This runs in the RENDER phase:
const value = expensiveCalculation() // runs on every render
const [state] = useState(0) // reads fiber state
// No DOM access here. No network calls. Just return elements.
return <div>{value}</div>
}
// COMMIT PHASE — synchronous, cannot be interrupted
// Phase 1 (Before Mutation): getSnapshotBeforeUpdate
// Phase 2 (Mutation): React applies DOM mutations
// Phase 3 (Layout): useLayoutEffect fires synchronously
// Then browser paints the screen
// Phase 4 (Passive Effects): useEffect fires asynchronously after paint
// useEffect vs useLayoutEffect — the key distinction:
function Modal() {
const ref = useRef()
// useLayoutEffect: fires BEFORE browser paint
// Use when you need to read/mutate DOM before user sees it
useLayoutEffect(() => {
// Safe: DOM mutations here are invisible to the user
const height = ref.current.getBoundingClientRect().height
ref.current.style.marginTop = `-${height / 2}px` // center vertically
})
// useEffect: fires AFTER browser paint
// Use for data fetching, subscriptions, logging — most things
useEffect(() => {
// DOM mutations here cause a visible flicker (layout → paint → update)
const height = ref.current.getBoundingClientRect().height
ref.current.style.marginTop = `-${height / 2}px` // user sees shift!
})
return <div ref={ref}>Modal content</div>
}
// Rule of thumb:
// Need to read/write DOM that affects layout → useLayoutEffect
// Everything else → useEffect
// Imagine a parent with 5 children:
function Dashboard() {
const [activeTab, setActiveTab] = useState('home')
// When setActiveTab is called:
// ALL 5 children re-render (their functions run)
// React compares old output vs new output for each
// DOM updates: maybe 1-2 className changes, nothing else
return (
<div>
<Nav activeTab={activeTab} /> {/* function called — output changes */}
<TabContent tab={activeTab} /> {/* function called — output changes */}
<Sidebar /> {/* function called — same output */}
<Footer /> {/* function called — same output */}
<NotificationBell /> {/* function called — same output */}
</div>
)
}
// Re-renders: 5 (all children ran)
// DOM mutations: 2 (Nav className, TabContent innerHTML)
// This is the trade-off React makes:
// Cheap JS function calls (re-renders) → minimal expensive DOM operations
// To prevent unnecessary re-renders on Sidebar, Footer, NotificationBell:
const Sidebar = React.memo(function Sidebar() { ... })
const Footer = React.memo(function Footer() { ... })
const NotificationBell = React.memo(function NotificationBell() { ... })
// Now only Nav and TabContent re-render when activeTab changesMany developers believe "re-render = DOM update." This is the most important misconception to correct. Re-rendering means React called your component function again and ran the reconciliation algorithm. DOM updates are a separate step that only happens in the commit phase when reconciliation finds actual differences. A component can re-render hundreds of times without changing a single DOM node if the output is identical each time.
Many developers believe "the virtual DOM is faster than the real DOM." The virtual DOM adds overhead — it requires building a JS object tree, diffing it against the previous tree, and then applying changes. It's only faster than naive DOM manipulation because naive DOM manipulation doesn't batch changes. The virtual DOM wins against "update everything on every state change." It doesn't win against "update exactly the right DOM nodes." The real benefit is the programming model, not raw speed.
Many developers believe changing a prop causes a re-render. This is backwards — a prop change requires the parent to re-render and pass new values, which is what causes the child to re-render. The prop change is a consequence of the parent's re-render, not an independent trigger. If the parent doesn't re-render, the child's props can never change.
Many developers believe React.memo prevents all unnecessary re-renders. React.memo only prevents re-renders caused by a parent's re-render when the props are shallowly equal. It does nothing when the component's own state changes, when a context it subscribes to changes, or when the props contain new object/array/function references on every render.
Many developers think useEffect and useLayoutEffect are interchangeable with different timing. The critical difference is that useLayoutEffect fires synchronously after DOM mutation but before the browser paints. This means DOM reads inside useLayoutEffect see the post-update DOM. DOM writes inside it are invisible to the user. If you use useEffect for DOM measurements that affect layout, users will see a flash — the component renders with incorrect measurements, browser paints, then effect runs and corrects the measurements.
Many developers think keys are just for avoiding the "key warning." Keys are React's identity system. A key is how React determines whether a component instance should be reused or torn down and rebuilt. Giving the same component a different key forces a full remount — useful for resetting state. This "key trick" is a legitimate pattern: to reset a child's state when a prop changes, just change its key.
Form field state loss is a direct consequence of the type-change heuristic. If a component conditionally renders either a TextInput or a SelectInput at the same tree position, switching between them causes the internal state (value, focus, cursor position) to be destroyed completely — React sees a different element type at that position and unmounts. The fix is to always render both and toggle visibility with CSS, or give each a different key to manage the remount explicitly.
Animated list reordering bugs trace directly to index-as-key mistakes. In a sortable kanban board, if list items use index as key, dragging a card from position 0 to position 3 causes React to see "key 0 changed its text" rather than "key 'abc123' moved to a new position." All CSS transitions fire on the wrong elements, inputs lose their values, and focused elements lose focus. Libraries like Framer Motion's AnimatePresence require stable keys to correctly animate enter/exit of list items.
The "stale props in event handlers" bug in real-time applications comes from the render/commit phase distinction. A chat app that subscribes to a WebSocket in useEffect and reads the latest messages in a callback has a stale closure — the callback captures the messages array from the render when the effect ran, not the latest value. The fix is useRef to always point to the latest value, or including the dependency in the effect's array to re-subscribe when messages change.
Performance profiling reveals that most "slow" React apps are caused by excessive re-renders, not slow render functions. A dashboard at a fintech startup had a 200ms update lag because a top-level state update (user notification count) was triggering re-renders in 40+ components. The fix was colocating state closer to where it was needed and wrapping stable subtrees with React.memo — reducing re-renders from 40 to 3 on each notification, bringing update lag to under 16ms.
Server components in Next.js App Router rely directly on the render/commit model. Server components run their render phase on the server — they produce React element trees (plain JSON) that get sent to the client. The client merges this into the existing fiber tree without discarding existing client component state. This is only possible because React separates "what should the UI look like" (render) from "apply changes to the DOM" (commit).
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.