Rendering and Performance
7 pages · ~5 min✓ Reviewed
Builds on Effects and Data Fetching.
What Triggers a Re-render
1. Render vs DOM update
- A render = React calls your component to compute JSX
- A render can end with zero DOM changes
- The DOM is touched only at commit
2. What triggers one
| Trigger | Re-render? |
|---|---|
| Own state setter | yes |
| Parent re-renders | yes, all children |
| Consumed context changes | yes |
| Props change alone | no, not a trigger |
- Child re-renders even with identical props
- Skip it by wrapping the child in React.memo
3. Mistake: mutating state
jsx
items.push(4) // mutated in place setItems(items) // Object.is same: skipped setItems([...items, 4]) // new array: updates
Same reference = bail-out
React compares with Object.is. Always pass a new array or object.
Reconciliation and the Virtual DOM
1. Virtual DOM
- Virtual DOM = light tree of JS objects (React elements)
- Reconciliation diffs the new tree against the previous one
- Only the minimal DOM changes get committed
- Rendernew tree
- Diffvs previous
- Commitminimal DOM changes
2. Diff Rules
| Same position | Result | State |
|---|---|---|
| Different type | Tear down, remount | Lost |
| Same type | Update in place | Kept |
| Key: stable id | Item matched | Kept |
| Key: array index | Breaks on reorder, insert, remove | Wrong |
- Changing a
keyon purpose resets that component's state
3. Mistake
Component defined inside a component
Each render makes a new function type, so React remounts it: state and focus are lost. Define it at module level.
4. Interview Point
- Why O(n), not O(n^3)? Two heuristics: type check and keys, no full tree comparison
- Type changes at same position? Old subtree torn down, state lost
- Why is
Math.random()a bad key? New key each render, so every item remounts - Virtual DOM faster than real DOM? No: it is overhead that avoids needless DOM writes
React.memo in Practice
1. What memo checks
- React.memo(C) skips a render if each prop is
Object.isequal - Guards parent-driven renders only
areEqualcomparator: rarely needed
| Cause | Memo child |
|---|---|
| Parent renders, props equal | skipped |
| New object, array or function prop | renders |
| Own state change | renders |
| Consumed context change | renders |
2. Stable props
- Inline objects, arrays, functions are new each render
- Fix with
useCallbackanduseMemo; parent{children}JSX is new too
jsx
const Row = React.memo(function Row({ item, onSelect }) { return <li onClick={() => onSelect(item.id)}>{item.name}</li>; }); const onSelect = useCallback(id => setSel(id), []); <Row item={item} onSelect={onSelect} />
stable onSelect lets Row skip renders
3. Interview Point
- How are props compared? Shallow,
Object.isper prop - Why does an inline callback fail? New function identity each render
- Does memo block state or context renders? No
- Custom comparator? Rarely; only for a cheap, correct check
One bad prop
A single unstable prop defeats React.memo.
Memoization Trade-offs
1. What They Cache
| useMemo | useCallback | |
|---|---|---|
| Caches | a computed value | a function identity |
| Keyed by | dependency array | dependency array |
| Costs | memory + a compare every render | same |
2. Worth It?
- Yes: genuinely expensive calculation
- Yes: feeds a memoized child or an effect dependency
- Yes: list with many items
- No: cheap calculations or primitives
3. Cheaper First
- Move state down into the component that needs it
- Lift static JSX up, pass it as
children: not re-created on state change - Measure with the React DevTools Profiler first
4. Mistakes
Don't wrap everything
Wrong or missing dependencies cause stale closures. The cache is no guarantee: React may discard it.
Code Splitting with lazy and Suspense
1. What lazy Does
- Code splitting = bundle cut into chunks loaded on demand
- Chunk boundary: dynamic
import() lazyloads the module's default export on first render
jsx
const Page = lazy(() => import('./Page')); <Suspense fallback={<Spinner />}> <Page /> </Suspense>
lazy() at module top level
2. Suspense and Split Points
- Lazy component needs a Suspense ancestor; it shows the
fallbackwhile loading - Nested boundaries: inner fallback covers only its part
| Split | Verdict |
|---|---|
| Routes | first |
| Modal, chart, editor | yes, heavy |
| Tiny component | no, extra request |
3. Mistakes
- No Suspense above: whole root suspends, nothing new shows until the chunk loads
- Failed chunk rejects: wrap in an Error Boundary with retry
lazy()inside a component: remounts every render
4. Interview Point
- What must the module export? A
defaultexport of a component - No Suspense boundary? Root suspends, no fallback, no error
- Where to split? Routes first, then heavy modals, charts, editors
- Chunk fails? Error Boundary shows a retry
Re-render and Performance Cheat Sheet
1. Triggers and Reconciliation
| Case | Result |
|---|---|
| Own state, parent render, context | re-render |
| New reference in state | update |
Same reference (Object.is) | bail-out |
| Same type | update, keep state |
| Different type | remount, state lost |
- Component defined inside a component: remounts every render
- Index or
Math.random()keys break state; use stable ids
2. Performance Order
- Profilermeasure first
- Move state down
- React.memostable props
- useMemo / useCallbackonly where needed
3. Split with lazy
jsx
const Page = lazy(() => import('./Page')) <ErrorBoundary> <Suspense fallback={<Spinner />}> <Page /> </Suspense> </ErrorBoundary>
call lazy() at module top level
- Error Boundary catches chunk load failures
- No Suspense above it: the root suspends, nothing shows until the chunk loads
4. Interview Point
- Render vs commit? Render calls components and diffs; commit applies the DOM changes
- Stop a re-render?
React.memo, move state down, pass static JSX as children - Skip memoizing? Cheap values, primitives, or no measured slowdown
- Split a route bundle?
lazyper route, insideSuspense
Check yourself
items.push(x); setItems(items); and the list never updates. Why?
- Same reference, so
Object.issays unchanged and React bails out - Pass a new array:
setItems([...items, x])
Row is wrapped in React.memo, but the parent passes onSelect={() => pick(id)}. Does Row skip renders?
- No: the inline function is a new reference every render, so the shallow compare fails
- Wrap it in
useCallbackso the reference stays stable
A text field defined inside another component's body loses focus on every keystroke. Why?
- Each render creates a new function type, so React unmounts and remounts it
- Define the component at module level