Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
// ---- 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:
NewTaskInput prevents 500 items from re-rendering on every keystrokeReact.memo on MemoizedTaskItem skips re-renders when task and onToggle props are unchangeduseCallback on toggleTask stabilizes the function reference so memo worksuseMemo on filtered avoids recomputing the filter on unrelated state changessetState is called, React re-renders that component and all its descendants. This is the most common re-render trigger.useContext(SomeContext) re-renders whenever the context value changes, regardless of whether the specific field it uses changed.onClick={() => handleClick(id)} creates a new function on every render. If the child is wrapped in memo, this new reference defeats the optimization.style={{ color: "red" }} or data={{ items }} creates a new object reference each render, breaking shallow comparison in memo.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>
);
}React.memo preserves prop types. For generic components, cast after wrapping: memo(MyComponent) as typeof MyComponent.useCallback dependency array is type-checked by TypeScript and the react-hooks/exhaustive-deps ESLint rule.React.ReactNode for maximum flexibility.Adding memo everywhere without profiling - memo has 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 key or generating keys with Math.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 - memo on the child is useless if the parent passes a new function reference on every render. Fix: Wrap handler functions in useCallback or 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 StateContext and DispatchContext, or use Zustand with selectors.
Spreading props defeating memo - <Child {...props} /> where props is rebuilt each render creates new object references. Fix: Pass individual props or memoize the props object.
| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
Always profile first to confirm the component is both expensive and re-rendering unnecessarily.
useDeferredValue still re-renders the slow component but at a lower priority, keeping the input responsive while showing stale content brieflyState colocation is simpler when possible; useDeferredValue is useful when the slow component genuinely depends on the changing state.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥