When React feels slow, unnecessary re-renders are usually the culprit. Components re-execute more often than needed, and the accumulated cost shows up as jank.
I once had a page with a list of several hundred items. Scrolling felt sluggish. I opened React DevTools and found every component re-rendering on every interaction — including ones that had nothing to do with the change.
This article explains how re-renders work, then shows you exactly how to use memo, useMemo, and useCallback to stop the unnecessary ones. Not "sprinkle memo everywhere" — but understanding why each tool is needed in each scenario.
Why Do Re-renders Happen?
React re-renders a component in three situations:
- Its own state changed (
setStatewas called) - Its parent re-rendered
- A context it consumes changed
The second point is the most surprising: when a parent re-renders, all its children re-render by default — regardless of whether their props changed.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
{/* ChildA and ChildB re-render every time count changes */}
<ChildA />
<ChildB />
</div>
);
}
ChildA and ChildB don't receive count as a prop, yet they re-render whenever Parent state changes. At scale, this accumulates into real performance problems.
Re-renders Aren't Inherently Bad
Important caveat: re-renders themselves aren't evil. React is designed to make them fast, and actual DOM updates are minimized to the diff. Dozens of simple components re-rendering is imperceptible to users.
Optimization becomes necessary when:
- Components contain expensive calculations
- Lists have hundreds of items
- You need smooth 60fps animations
Measure first, then optimize. This is non-negotiable.
Visualize Re-renders with React DevTools
Before optimizing, identify what's actually re-rendering unnecessarily.
Use the Profiler tab in React DevTools (browser extension):
- Install React DevTools
- Open the Profiler tab
- Click the gear icon → enable "Highlight updates when components render"
- Interact with your app — re-rendered components will flash
Color guide:
- Blue — rare renders (fine)
- Green — moderate
- Yellow — frequent
- Red — very frequent (investigate)
React.memo: Skip Re-renders When Props Haven't Changed
React.memo wraps a component and skips re-rendering when props are unchanged (compared with Object.is).
Without Optimization
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Alice");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
<input value={name} onChange={e => setName(e.target.value)} />
{/* NameDisplay re-renders every time count changes */}
<NameDisplay name={name} />
</div>
);
}
function NameDisplay({ name }: { name: string }) {
console.log("NameDisplay rendered"); // Called every time
return <p>Hello, {name}</p>;
}
With React.memo
// Only re-renders when name actually changes
const NameDisplay = memo(function NameDisplay({ name }: { name: string }) {
console.log("NameDisplay rendered"); // Only called when name changes
return <p>Hello, {name}</p>;
});
When memo Doesn't Work
function Parent() {
const [count, setCount] = useState(0);
// ❌ New object reference on every render
const user = { name: "Alice", age: 20 };
// ❌ New function reference on every render
const handleClick = () => console.log("clicked");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
{/* user and handleClick have new references every render — memo can't help */}
<UserCard user={user} onClick={handleClick} />
</div>
);
}
const UserCard = memo(function UserCard({
user,
onClick,
}: {
user: { name: string; age: number };
onClick: () => void;
}) {
console.log("UserCard rendered"); // Still called every time
return (
<div onClick={onClick}>
{user.name} ({user.age})
</div>
);
});
Objects and functions create new references on every render. memo sees "props changed" and re-renders anyway. This is where useMemo and useCallback come in.
useMemo: Stabilize Object References and Cache Expensive Calculations
useMemo memoizes a computed value, returning the same reference unless dependencies change.
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Alice");
const [age, setAge] = useState(20);
// ✅ New object only when name or age changes
const user = useMemo(() => ({ name, age }), [name, age]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
{/* When only count changes, user reference stays stable — UserCard skips re-render */}
<UserCard user={user} />
</div>
);
}
const UserCard = memo(function UserCard({
user,
}: {
user: { name: string; age: number };
}) {
console.log("UserCard rendered");
return <div>{user.name} ({user.age})</div>;
});
Caching Expensive Calculations
function SearchResults({ items, query }: { items: Item[]; query: string }) {
// ❌ Filter runs on every render
// const filtered = items.filter(item => item.name.includes(query));
// ✅ Filter only runs when items or query changes
const filtered = useMemo(
() => items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
),
[items, query]
);
return (
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
For 1,000+ items with complex filtering or sorting, useMemo pays off. For 10 simple items, the useMemo overhead likely exceeds the savings.
useCallback: Stabilize Function References
useCallback memoizes a function, returning the same reference unless dependencies change.
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Alice");
// ✅ New function only when name changes
const handleUserClick = useCallback(() => {
console.log(`${name} was clicked`);
}, [name]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>count: {count}</button>
{/* When only count changes, handleUserClick reference stays stable */}
<UserCard onClick={handleUserClick} />
</div>
);
}
const UserCard = memo(function UserCard({ onClick }: { onClick: () => void }) {
console.log("UserCard rendered");
return <button onClick={onClick}>Click me</button>;
});
useMemo vs useCallback
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Think of useCallback as shorthand for memoizing functions:
// These two are identical
const fn1 = useCallback(() => doSomething(a, b), [a, b]);
const fn2 = useMemo(() => () => doSomething(a, b), [a, b]);
Real-World Example: Optimizing a Heavy List
Let's walk through a complete optimization of a large todo list.
Before
function TodoList() {
const [todos, setTodos] = useState(initialTodos); // 500 items
const [filter, setFilter] = useState("all");
// Filtering runs on every render
const filtered = todos.filter(todo => {
if (filter === "done") return todo.done;
if (filter === "pending") return !todo.done;
return true;
});
// New function references on every render
const handleToggle = (id: string) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
};
const handleDelete = (id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
<ul>
{filtered.map(todo => (
// Every TodoItem re-renders when filter changes
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
</div>
);
}
After
function TodoList() {
const [todos, setTodos] = useState(initialTodos); // 500 items
const [filter, setFilter] = useState("all");
// ✅ Filter only recalculates when todos or filter changes
const filtered = useMemo(
() => todos.filter(todo => {
if (filter === "done") return todo.done;
if (filter === "pending") return !todo.done;
return true;
}),
[todos, filter]
);
// ✅ Stable references — functional updates mean no need for todos in deps
const handleToggle = useCallback((id: string) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
}, []);
const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []);
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
<ul>
{filtered.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle} // Stable reference
onDelete={handleDelete} // Stable reference
/>
))}
</ul>
</div>
);
}
// ✅ memo wrap — skips re-render when todo, onToggle, onDelete haven't changed
const TodoItem = memo(function TodoItem({
todo,
onToggle,
onDelete,
}: {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<li>
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => onToggle(todo.id)}>Toggle</button>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
What changed:
useMemofor filtering — 500-item filter no longer runs on unrelated rendersuseCallbackfor handlers — functional updates (prev =>) let us omittodosfrom depsmemoon TodoItem — individual items skip re-renders when nothing changed for them
Prevent Context-Caused Re-renders
When a context value changes, every component consuming that context re-renders. Overloading a single context causes cascading re-renders.
// ❌ Everything in one context
const AppContext = createContext({
user: null,
theme: "dark",
notifications: [],
});
// A notification update causes components only using user to re-render
// ✅ Split by update frequency
const UserContext = createContext(null);
const ThemeContext = createContext("dark");
const NotificationContext = createContext([]);
// Components using UserContext only re-render when user changes
Group context values by how often they change together.
Wrapping Up
Re-render optimization follows: measure → identify → fix.
| Tool | Purpose | Use When |
|---|---|---|
React.memo | Skip re-renders when props unchanged | Heavy components with confirmed unnecessary re-renders |
useMemo | Cache values and stabilize object references | Expensive calculations, objects passed to memoized components |
useCallback | Stabilize function references | Functions passed to memoized components, functions in useEffect deps |
"Sprinkle memo everywhere" often makes things worse. Open React DevTools Profiler, confirm which re-renders are unnecessary, trace the cause, then apply the right fix.
Optimization priority:
- Measure and confirm the problem exists
- Consider structural solutions (component colocation) first
- Then reach for
memo→useCallback→useMemo