Avoiding Unnecessary Re-renders
Every time a React component’s state or props change, React calls that component’s function again to figure out what the UI should look like. This is called a re-render. Re-renders are normal and cheap in small amounts, but by default React also re-renders every child inside a re-rendering parent, even if that child’s own props never changed. In large trees, expensive computations, or long lists, that cascading behavior can add up to real, visible slowness. This lesson explains exactly when and why re-renders happen, and the tools React gives you — React.memo, useMemo, and useCallback — to avoid the ones that don’t need to happen.
Overview: How Re-renders Actually Work
A React re-render can be triggered by exactly three things: a state update in the component itself (via a useState or useReducer setter), a re-render of its parent component, or a change in a context value the component consumes. When any of these happen, React calls the component function again, producing a brand-new tree of React elements (plain JavaScript objects describing what should be on screen).
It’s important to separate two phases: the render phase, where React calls your component functions and builds this new element tree, and the commit phase, where React compares (“reconciles”) the new tree against the previous one and applies only the minimal set of real DOM changes. Reconciliation is why re-rendering a component doesn’t necessarily mean the DOM changes — if the output is identical, React skips the DOM update. But the render phase itself still costs something: your component function runs again, new objects and arrays are allocated, and every non-memoized child component’s function runs too, purely because its parent rendered, regardless of whether that child’s props actually changed.
This is the key fact to internalize: React does not skip a child’s render just because its props look the same — unless you explicitly tell it to with memo. Left unchecked, a single keystroke in a top-level input can cascade re-renders through dozens of components that have nothing to do with that input.
This also explains why hooks must always run in the same order on every render: React tracks each component’s hooks as a linked list tied to that component’s internal “fiber.” On every re-render, React walks that same list and matches each hook call by position. If a hook is called conditionally, the order shifts, and React attaches the wrong state to the wrong hook — which is why hooks can never live inside if statements, loops, or nested functions.
One caution before reaching for optimizations: not all re-renders are worth preventing. Adding memo, useMemo, or useCallback everywhere has its own cost (extra comparisons, extra memory) and adds noise to your code. Use these tools where profiling (or obvious symptoms like typing lag) shows they matter — typically large lists, expensive calculations, or components deep in a frequently-updating tree.
Syntax
| Tool | Form | Purpose |
|---|---|---|
React.memo |
const Comp = memo(function Comp(props) { ... }); |
Skips re-rendering a component if its props are shallowly equal to the previous render’s props. |
useMemo |
const value = useMemo(() => compute(a, b), [a, b]); |
Caches the result of an expensive calculation between renders, recomputing only when a dependency changes. |
useCallback |
const fn = useCallback((x) => doThing(x, a), [a]); |
Caches a function reference between renders, so it doesn’t count as a “new prop” for memoized children. |
- Dependency array — the array passed as the last argument to
useMemo/useCallback; the cached value/function is only recreated when one of these values changes between renders. - Shallow comparison —
memocompares each prop withObject.is(like===), one level deep. A new object or array literal is never equal to the previous one, even with identical contents. - Custom comparison —
memo(Component, areEqual)accepts an optional second argument for custom prop comparison, used rarely for special cases.
Examples
Example 1: A child re-renders even though its props never change
import { useState } from "react";
function Child({ label }) {
console.log("Child rendered");
return <p>{label}</p>;
}
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child label="I don't depend on count or text" />
</div>
);
}
export default Parent;
Renders: a button showing the current count, a text input, and a paragraph rendered by Child. Output: the console logs Child rendered once on mount, and then again every single time you click the button or type a character — even though Child‘s label prop never changes. This happens because Child is a plain function component: whenever Parent re-renders, React re-renders every child it returns, unconditionally.
Example 2: Skipping the re-render with React.memo
import { useState, memo } from "react";
const Child = memo(function Child({ label }) {
console.log("Child rendered");
return <p>{label}</p>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child label="I don't depend on count or text" />
</div>
);
}
export default Parent;
Renders: the same UI as Example 1. Output: Child rendered is logged exactly once, on mount. Clicking the button or typing no longer re-renders Child, because memo compares the new label prop against the previous one, finds them equal, and reuses the previous render output entirely, skipping the function call.
Example 3: Stabilizing a function prop with useCallback
import { useState, useCallback, memo } from "react";
const TodoItem = memo(function TodoItem({ todo, onToggle }) {
console.log("Rendering:", todo.text);
return (
<li>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</label>
</li>
);
});
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React", done: false },
{ id: 2, text: "Learn hooks", done: false },
]);
const [filter, setFilter] = useState("");
const handleToggle = useCallback((id) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}, []);
return (
<div>
<input
placeholder="Filter (doesn't affect todos)"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
</div>
);
}
export default TodoList;
Renders: a filter input and a checklist of todos. Output: typing in the filter input logs nothing further from TodoItem after the initial mount. handleToggle is created with useCallback and an empty dependency array (it only uses the updater form of setTodos, so it never needs to read todos directly), so its reference stays identical across renders. Combined with memo on TodoItem, and the fact that unrelated todo objects aren’t recreated by typing in the filter box, each TodoItem is skipped unless its own todo actually changes.
Example 4: Caching an expensive calculation with useMemo
import { useState, useMemo } from "react";
function expensiveFilter(items, query) {
console.log("Filtering...");
return items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
}
function SearchableList({ items }) {
const [query, setQuery] = useState("");
const [theme, setTheme] = useState("light");
const filtered = useMemo(
() => expensiveFilter(items, query),
[items, query]
);
return (
<div>
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Toggle theme ({theme})
</button>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{filtered.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
export default SearchableList;
Renders: a theme toggle button, a search input, and a filtered list of items. Output: Filtering... is logged only when items or query changes. Clicking the theme toggle re-renders SearchableList (because theme is state), but useMemo sees that its dependencies [items, query] are unchanged, so it returns the cached filtered array instead of calling expensiveFilter again.
Under the Hood: Mount, Update, and Unmount
On mount: React calls the component function for the first time, builds an element tree, allocates fresh hook state (a fiber with an empty hook list), and commits every resulting DOM node.
On a state update: React schedules a re-render of the component whose state changed. During the render phase, that component’s function runs again, and by default so does every function component it renders as a child — recursively, all the way down — regardless of whether their props changed. If a child is wrapped in memo, React instead performs a shallow comparison of its new props against the previous ones; if they’re all equal, React reuses the previous output and skips calling that child’s function (and everything below it) entirely. useMemo and useCallback matter here because they control whether the values/functions being passed as props even count as “changed” in that comparison.
On unmount: React removes the component’s fiber and hook state, running any cleanup functions returned from useEffect along the way.
Common Mistakes
Mistake 1: An inline object literal defeats memo
Wrapping a component in memo does nothing if you keep passing it a brand-new object or array on every render — object literals are never === equal to a previous render’s literal, even with identical contents.
const Child = memo(function Child({ style }) {
return <div style={style}>Hi</div>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child style={{ color: "blue" }} />
</div>
);
}
Every click creates a new { color: "blue" } object, so memo‘s shallow comparison always sees a “changed” prop and re-renders Child anyway. Fix it by hoisting the constant object outside the component (or wrapping it in useMemo if it depends on props or state):
const childStyle = { color: "blue" };
const Child = memo(function Child({ style }) {
return <div style={style}>Hi</div>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child style={childStyle} />
</div>
);
}
Mistake 2: An inline function prop breaks memoization
The same problem happens with inline arrow functions passed as event handler props — a new function is created every render, so a memoized child always sees a “new” prop.
const ItemList = memo(function ItemList({ items, onSelect }) {
console.log("ItemList rendered");
return (
<ul>
{items.map((item) => (
<li key={item} onClick={() => onSelect(item)}>
{item}
</li>
))}
</ul>
);
});
function Parent() {
const [count, setCount] = useState(0);
const [items] = useState(["a", "b", "c"]);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ItemList items={items} onSelect={(item) => console.log("selected", item)} />
</div>
);
}
Clicking the counter button re-renders Parent, which recreates the onSelect arrow function, which makes memo think ItemList‘s props changed — so it re-renders every time, defeating the whole point. Wrap the handler in useCallback so its reference stays stable:
const ItemList = memo(function ItemList({ items, onSelect }) {
console.log("ItemList rendered");
return (
<ul>
{items.map((item) => (
<li key={item} onClick={() => onSelect(item)}>
{item}
</li>
))}
</ul>
);
});
function Parent() {
const [count, setCount] = useState(0);
const [items] = useState(["a", "b", "c"]);
const handleSelect = useCallback((item) => {
console.log("selected", item);
}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ItemList items={items} onSelect={handleSelect} />
</div>
);
}
Best Practices
- Profile before optimizing — use the React DevTools Profiler to confirm a component is actually re-rendering too often before adding
memo,useMemo, oruseCallback. - Only wrap components in
memowhen they render often with unchanged props and their own render work is non-trivial (large lists, complex JSX, expensive child trees). - Pair
memowithuseCallback/useMemofor any function, object, or array props — otherwise the memoization is silently defeated. - Prefer moving state down into the smallest component that needs it, instead of lifting it up unnecessarily and forcing a large parent (and all its children) to re-render.
- Split large components into smaller ones so React can skip re-rendering the parts that didn’t change.
- Split contexts by concern (e.g. a separate
ThemeContextandUserContext) so updating one doesn’t re-render consumers of the other. - Always give list items a stable, unique
key(like a database id), not the array index, so React can correctly match items across re-renders instead of re-rendering everything. - Don’t reach for
useMemo/useCallbackfor trivial values — the memoization bookkeeping itself has a cost, and overusing it hurts readability with no real benefit.
Practice Exercises
- Build a
Parentcomponent with a counter and aStatsPanelchild that just displays a static heading. Add aconsole.loginsideStatsPanel, confirm it logs on every counter click, then fix it withmemoso it only logs once. - Take the
TodoListexample from this lesson and add a “clear completed” button that calls a new handler. Make sure that handler is wrapped inuseCallbackso it doesn’t cause everyTodoItemto re-render when it’s added. - Create a component that computes the sum of a large array (e.g. 100,000 numbers) on every render. Add an unrelated piece of state (like a toggle button) that re-renders the component, and use
useMemoto stop the sum from being recalculated when only the toggle changes. Log inside the sum function to verify it only runs when the array itself changes.
Summary
- A re-render happens when a component’s own state changes, its parent re-renders, or a context value it consumes changes.
- By default, every child of a re-rendering parent re-renders too, regardless of whether its props changed.
React.memoskips a component’s re-render when its props are shallowly equal to the previous render.useMemocaches an expensive computed value between renders based on a dependency array.useCallbackcaches a function reference so it doesn’t look “new” to a memoized child on every render.- Inline object, array, and function literals passed as props defeat
memobecause they’re never equal to the previous render’s literal. - Always profile with React DevTools before optimizing — unnecessary
memo/useMemo/useCallbackadds cost and complexity without benefit.
