Preventing Unnecessary Re-renders — Identify and eliminate wasted renders for a faster UI
Recipe
// BEFORE: Every keystroke re-renders the entire product list (47 renders)
function ProductPage() {
const [search, setSearch] = useState("");
const [products] = useState<Product[]>(initialProducts);
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<ProductList products={products} /> {/* Re-renders on every keystroke */}
<Footer /> {/* Also re-renders unnecessarily */}
</div>
);
}
// AFTER: Only SearchBar re-renders on keystroke (3 renders)
function ProductPage() {
const [products] = useState<Product[]>(initialProducts);
return (
<div>
<SearchBar /> {/* State colocated here */}
<ProductList products={products} /> {/* No longer re-renders */}
<Footer /> {/* No longer re-renders */}
</div>
);
}
function SearchBar() {
const [search, setSearch] = useState("");
return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}When to reach for this: When React DevTools Profiler highlights show components flashing on every interaction, or when the UI feels sluggish during typing, scrolling, or tab switching. Always profile first to confirm the problem before applying fixes.
Working Example
// ---- BEFORE: Slow list with unnecessary re-renders ----
interface Task {
id: string;
title: string;
completed: boolean;
}
// Problem: TaskItem re-renders for ALL items when any state changes
function TaskApp() {
const [tasks, setTasks] = useState<Task[]>(generateTasks(500));
const [filter, setFilter] = useState("all");
const [newTitle, setNewTitle] = useState("");
const toggleTask = (id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const filtered = tasks.filter((t) => {
if (filter === "done") return t.completed;
if (filter === "todo") return !t.completed;
return true;
});
return (
<div>
{/* Typing here re-renders all 500 TaskItems */}
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="New task..."
/>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="done">Done</option>
<option value="todo">Todo</option>
</select>
<ul>
{filtered.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={() => toggleTask(task.id)} // New function every render!
/>
))}
</ul>
</div>
);
}
function TaskItem({ task, onToggle }: { task: Task; onToggle: () => void }) {
// Simulated expensive render
const start = performance.now();
while (performance.now() - start < 1) {} // 1ms per item = 500ms total
return (
<li onClick={onToggle} style={{ textDecoration: task.completed ? "line-through" : "none" }}>
{task.title}
</li>
);
}
// ---- AFTER: Optimized — reduces re-renders from 500 to ~1-3 per interaction ----
function TaskAppOptimized() {
const [tasks, setTasks] = useState<Task[]>(generateTasks(500));
const [filter, setFilter] = useState("all");
const toggleTask = useCallback((id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}, []);
const filtered = useMemo(
() =>
tasks.filter((t) => {
if (filter === "done") return t.completed;
if (filter === "todo") return !t.completed;
return true;
}),
[tasks, filter]
);
return (
<div>
{/* State colocated — typing no longer re-renders the list */}
<NewTaskInput
onAdd={(title) =>
setTasks((prev) => [...prev, { id: crypto.randomUUID(), title, completed: false }])
}
/>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="done">Done</option>
<option value="todo">Todo</option>
</select>
<ul>
{filtered.map((task) => (
<MemoizedTaskItem key={task.id} task={task} onToggle={toggleTask} />
))}
</ul>
</div>
);
}
// Colocated input state — isolated from the list
function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) {
const [title, setTitle] = useState("");
return (
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && title.trim()) {
onAdd(title.trim());
setTitle("");
}
}}
placeholder="New task..."
/>
);
}
// Memoized item — only re-renders when its own task changes
const MemoizedTaskItem = memo(function TaskItem({
task,
onToggle,
}: {
task: Task;
onToggle: (id: string) => void;
}) {
const start = performance.now();
while (performance.now() - start < 1) {}
return (
<li
onClick={() => onToggle(task.id)}
style={{ textDecoration: task.completed ? "line-through" : "none" }}
>
{task.title}
</li>
);
});What this demonstrates:
- State colocation: moving input state to
NewTaskInputprevents 500 items from re-rendering on every keystroke React.memoonMemoizedTaskItemskips re-renders whentaskandonToggleprops are unchangeduseCallbackontoggleTaskstabilizes the function reference so memo worksuseMemoonfilteredavoids recomputing the filter on unrelated state changes- Total render time drops from ~500ms to under 5ms on each keystroke
Deep Dive
How It Works
- State change triggers re-render — When
setStateis called, React re-renders that component and all its descendants. This is the most common re-render trigger. - Parent re-render cascades — A parent re-rendering causes all children to re-render, even if their props have not changed. This is React's default behavior and the primary source of wasted renders.
- Context change triggers re-render — Any component that calls
useContext(SomeContext)re-renders whenever the context value changes, regardless of whether the specific field it uses changed. - Inline functions create new references —
onClick={() => handleClick(id)}creates a new function on every render. If the child is wrapped inmemo, this new reference defeats the optimization. - Inline objects create new references —
style={{ color: "red" }}ordata={{ items }}creates a new object reference each render, breaking shallow comparison inmemo. - React DevTools Profiler highlights — Enable "Highlight updates when components render" in React DevTools settings. Components flash with a colored border on each render. Frequent flashing during idle or typing indicates wasted renders.
Variations
Children pattern to prevent re-renders:
// BEFORE: SlowChild re-renders when count changes
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<SlowChild />
</div>
);
}
// AFTER: SlowChild passed as children, won't re-render
function CounterWrapper({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
</div>
);
}
function Parent() {
return (
<CounterWrapper>
<SlowChild /> {/* Created in Parent scope, not CounterWrapper */}
</CounterWrapper>
);
}Splitting components to isolate state:
// BEFORE: Hover state re-renders the entire card
function ProductCard({ product }: { product: Product }) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ExpensiveChart data={product.data} />
<HoverOverlay visible={isHovered} />
</div>
);
}
// AFTER: Hover state isolated in its own component
function ProductCard({ product }: { product: Product }) {
return (
<div>
<ExpensiveChart data={product.data} />
<HoverArea />
</div>
);
}
function HoverArea() {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<HoverOverlay visible={isHovered} />
</div>
);
}TypeScript Notes
React.memopreserves prop types. For generic components, cast after wrapping:memo(MyComponent) as typeof MyComponent.- The
useCallbackdependency array is type-checked by TypeScript and thereact-hooks/exhaustive-depsESLint rule. - When using the children pattern, type children as
React.ReactNodefor maximum flexibility.
Gotchas
-
Adding memo everywhere without profiling —
memohas overhead (shallow comparison on every render). For simple components that render quickly, the comparison cost exceeds the render cost. Fix: Profile first. Only wrap components that the Profiler shows are expensive and re-rendering unnecessarily. -
Unstable keys causing remounts — Using array index as
keyor generating keys withMath.random()forces React to unmount and remount components, which is more expensive than a re-render. Fix: Use stable, unique IDs from your data. -
Forgetting useCallback when passing handlers to memo children —
memoon the child is useless if the parent passes a new function reference on every render. Fix: Wrap handler functions inuseCallbackor restructure so the child owns the handler. -
Context causing global re-renders — A single context with both state and dispatch causes all consumers to re-render on any state change. Fix: Split into separate
StateContextandDispatchContext, or use Zustand with selectors. -
Spreading props defeating memo —
<Child {...props} />wherepropsis rebuilt each render creates new object references. Fix: Pass individual props or memoize the props object.
Alternatives
| Approach | Trade-off |
|---|---|
| State colocation | Simplest fix; may require restructuring component tree |
React.memo + useCallback | Precise control; adds boilerplate |
| Children pattern | Zero overhead; only works when state-owning component wraps content |
| React Compiler | Automatic; experimental, not yet stable |
| Zustand selectors | Replaces context; adds dependency |
| Virtualization | Renders only visible items; changes scrolling behavior |
useDeferredValue | Keeps input responsive; shows stale content briefly |
FAQs
What is the most common cause of unnecessary re-renders in React?
A parent component re-rendering causes all its children to re-render, even if their props have not changed. This is React's default behavior and the primary source of wasted renders.
How does state colocation prevent re-renders?
By moving state into the component that actually uses it, parent components no longer re-render when that state changes. For example, moving search input state into a SearchBar component prevents the sibling ProductList from re-rendering on every keystroke.
Why does wrapping a child in React.memo not help if the parent passes an inline function?
Inline functions like onClick={() => handleClick(id)} create a new function reference on every render. memo uses shallow comparison, so it sees a new prop and re-renders the child anyway.
Fix: Wrap the handler in useCallback or restructure so the child owns the handler.
What is the children pattern and when should you use it?
function CounterWrapper({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
</div>
);
}Children are created in the parent scope, not inside CounterWrapper, so they do not re-render when count changes. Use it when a state-owning component wraps content it does not need to control.
How do you identify unnecessary re-renders using React DevTools?
- Enable "Highlight updates when components render" in React DevTools settings
- Components flash with a colored border on each render
- Frequent flashing during idle or typing indicates wasted renders
- Use the Profiler tab to record interactions and inspect render durations
Gotcha: Can using array index as a key cause performance problems?
Yes. Using array index as key or generating keys with Math.random() forces React to unmount and remount components instead of updating them. Remounting is more expensive than a re-render. Always use stable, unique IDs from your data.
Why does a single context with both state and dispatch cause global re-renders?
Any component that calls useContext(SomeContext) re-renders whenever the context value changes, regardless of which field changed. If state and dispatch share one context, every state change re-renders all consumers, including those that only dispatch.
Fix: Split into StateContext and DispatchContext, or use Zustand with selectors.
Does spreading props defeat React.memo?
Yes. <Child {...props} /> where props is rebuilt each render creates new object references every time, causing memo to always see "changed" props. Pass individual props or memoize the props object instead.
How do you type the children prop in the children pattern in TypeScript?
function Wrapper({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}Use React.ReactNode for maximum flexibility. It accepts elements, strings, numbers, fragments, portals, and null.
How do you type a generic component wrapped in React.memo in TypeScript?
React.memo preserves prop types, but for generic components, you need a type assertion after wrapping:
const MemoizedList = memo(MyList) as typeof MyList;Without the assertion, the generic type parameter is lost.
When should you NOT use React.memo?
- When the component renders quickly (under 1ms) and the shallow comparison cost exceeds the render cost
- When props change on almost every render anyway
- When using the React Compiler, which handles memoization automatically
Always profile first to confirm the component is both expensive and re-rendering unnecessarily.
What is the difference between useDeferredValue and state colocation for handling slow lists?
- State colocation prevents the slow component from re-rendering at all
useDeferredValuestill re-renders the slow component but at a lower priority, keeping the input responsive while showing stale content briefly
State colocation is simpler when possible; useDeferredValue is useful when the slow component genuinely depends on the changing state.
Related
- Memoization — Deep dive into useMemo, useCallback, and React.memo
- React Compiler — Automatic memoization without manual hooks
- Profiling — How to use React DevTools Profiler to find re-render bottlenecks
- State Management Performance — Zustand selectors and context splitting