How Does React Handle Re-renders and How Do You Optimize Them?
Advertisement
🧩 Scenario
Architecture Walkthrough
The Four Re-render Triggers
React re-renders a component under four conditions. First, its own state changes via a setState call or dispatch. Second, the props it receives change (new values or new references). Third, its parent re-renders — even if the child received no different props. This third trigger is the most commonly misunderstood: by default, a parent re-render cascades to every child in its subtree unconditionally. React does this because checking whether props changed has a cost, and for cheap components the re-render is faster than the check. Fourth, a context value the component reads changes — any component calling useContext re-renders when the context value changes, regardless of whether it uses the part of the context that changed.
The Difference Between Re-rendering and DOM Updates
A re-render means React calls the component function and produces new JSX. It does not immediately update the DOM. After producing the new JSX, React runs reconciliation: it compares the new output against the previous output and identifies what changed. Only the actual differences are applied to the real DOM. A component can re-render dozens of times without any DOM mutations if its output is identical. This distinction matters for how you think about optimization: reducing unnecessary re-renders addresses JavaScript execution overhead; reconciliation already handles DOM mutation overhead.
The Three Memoization Tools
The three memoization tools serve distinct roles. React.memo memoizes a component's rendered output, skipping the component function call when props are shallowly equal. useMemo memoizes a computed value, recomputing it only when its dependencies change. useCallback memoizes a function reference, returning the same function instance across renders unless its dependencies change. They form a system: React.memo on the child, useCallback and useMemo on the props crossing into the memoized child to keep their references stable.
Key Code Explained
import { memo, useState, useCallback, useMemo } from 'react';
// Default behavior: parent re-render cascades to all children
function Parent({ count }: { count: number }) {
return (
<>
<ExpensiveChild /> {/* re-renders every time Parent does */}
<CheapChild /> {/* re-renders every time Parent does */}
<span>{count}</span>
</>
);
}
// Fix 1: React.memo — skip re-render when props are unchanged
const ExpensiveChild = memo(function ExpensiveChild() {
// Takes 40ms to render. Without memo, every parent keystroke costs 40ms.
console.log('ExpensiveChild rendered');
return <div className="heavy">{/* complex render */}</div>;
});
// ExpensiveChild now only re-renders when its own props change
// (it has none here, so it never re-renders after mount)
// Fix 2: state co-location — move state down to prevent cascade
function SearchContainer() {
// Before: SearchBox and ResultList shared a parent that held query state
// Every keystroke re-rendered ResultList unnecessarily
// After: query state lives in SearchBox itself
return (
<div>
<SearchBox /> {/* manages its own state — no cascade upward */}
<ResultList /> {/* never re-renders due to SearchBox typing */}
</div>
);
}
// Fix 3: memoizing function and object props to keep React.memo working
function ProductGrid({ products }: { products: Product[] }) {
const [cartCount, setCartCount] = useState(0);
// Without useCallback: new function reference per render
// React.memo on ProductCard sees onAddToCart as changed — bypassed
const handleAddToCart = useCallback((id: string) => {
setCartCount((c) => c + 1);
}, []); // no deps — never changes
// Without useMemo: new object reference per render
const gridStyle = useMemo(() => ({ columns: 3, gap: '16px' }), []);
return (
<div style={gridStyle}>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
);
}
// Fix 4: avoid storing derived values in state
// Bad: storing filtered list in state — causes extra render on every filter change
function BadFilteredList({ items, filter }: Props) {
const [filtered, setFiltered] = useState(items); // double render on filter change
useEffect(() => {
setFiltered(items.filter((item) => item.category === filter));
}, [items, filter]);
return <ul>{filtered.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}
// Good: derive during render — no extra state, no extra render
function GoodFilteredList({ items, filter }: Props) {
// useMemo only if the filtering is genuinely expensive (large dataset)
const filtered = items.filter((item) => item.category === filter);
return <ul>{filtered.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}
State co-location (Fix 2) is often the most impactful fix with the least code: moving state down to the component that actually needs it prevents re-renders from propagating upward and cascading to siblings. React.memo solves the problem from the consumer side; co-location solves it from the producer side by making the state change invisible to components that do not depend on it.
Tradeoffs
| Optimization | What it prevents | What it does not prevent | When to use |
|---|---|---|---|
React.memo | Child re-renders when parent re-renders | Context-triggered re-renders; unstable prop references | Expensive components with stable props |
useCallback | New function references per render | Component re-renders if other props change | Function props passed to memoized children |
useMemo | Re-computing expensive values | Component re-renders if other props change | Expensive derived values; object props for memoized children |
| State co-location | Cascade to unrelated siblings | Nothing within the component | Query/input state shared only among a subtree |
| Structuring contexts | Context change triggering all consumers | Nothing if context is broad | Splitting large contexts by update frequency |
What Interviewers Actually Check
- Whether you know all four re-render triggers, especially the parent cascade
- Whether you can distinguish a component re-rendering from the DOM being updated
- Whether you know what each memoization tool actually memoizes
- Whether you know the correct workflow (profile, identify, apply surgically)
- Whether you can describe state co-location as an alternative to wrapping children in memo
Follow-Up Questions
- A component reads from a context but only uses one field. The context updates many fields frequently. How do you prevent the component from re-rendering on unrelated updates?
- How do you use the React DevTools Profiler to identify which components are rendering unnecessarily?
- At what tree depth or render frequency does memoization actually pay off in measurable milliseconds?
- Your team adds
React.memoto every component as a default style. What is the argument against this and what would you propose instead? - How does React 18's automatic batching reduce the number of re-renders compared to React 17?
Common Candidate Mistakes
- Listing only state and prop changes as re-render triggers, missing parent re-renders and context changes
- Thinking a component re-render always produces DOM mutations — reconciliation may find no changes
- Applying
React.memoeverywhere without profiling — adds comparison overhead where it does not help - Storing derived values in state with
useEffect— creates extra renders that are entirely avoidable - Using
React.memowithout also stabilizing object and function props withuseCallback/useMemo
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you list all four conditions that cause a React component to re-render?
- Can you explain the difference between a component re-rendering and the DOM being updated?
- Can you explain why parent re-renders cascade to all children by default?
- Can you apply
React.memo,useMemo, anduseCallbackcorrectly and explain what each memoizes? - Can you describe the profiling-first workflow using React DevTools?
Summary
React re-renders a component when its state changes, its props change, its parent re-renders (cascades unconditionally by default), or a context it consumes changes. Re-rendering means the component function runs and produces new JSX. Reconciliation then diffs the new output against the previous and applies only actual DOM differences, so unnecessary re-renders waste JavaScript execution time, not DOM time. React.memo prevents child function calls when props are shallowly equal; useCallback stabilizes function prop references across renders; useMemo stabilizes computed value and object references. Derived values should be computed during render, not stored in state with useEffect, which causes extra render cycles. State co-location reduces cascade by limiting the propagation radius of state changes. The correct engineering workflow is to profile with React DevTools first to identify the specific components causing slowness, then apply targeted memoization rather than wrapping everything speculatively.
If a component re-renders, does it always update the DOM?
No. Re-rendering means React calls the component function and produces new JSX. React then compares the new JSX output against the previous output via reconciliation. Only the parts that actually differ are applied to the real DOM. A component can re-render many times without causing any DOM mutations if the output is identical.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement