React course menu
React Performance: memo, useMemo and useCallback Explained
Understand React re-renders and optimise them: when components re-render, skipping re-renders with memo, caching calculations with useMemo, stable functions with useCallback, measuring with the Profiler, the React Compiler, and avoiding premature optimisation.
On this page
React is fast by default, and most apps need no optimisation. When something does feel slow, it helps to understand when components re-render and the three tools for skipping unnecessary work: memo, useMemo and useCallback.
When does a component re-render?#
A component re-renders when:
- its own state changes;
- its parent re-renders (by default, all children re-render too, whether or not their props changed);
- a context it reads changes.
Re-rendering means calling the component function again and comparing the result; React only touches the real page where something actually changed. Re-renders are usually cheap. They only matter when a component does expensive work or renders a very large tree.
memo: skip re-rendering when props are the same#
memo(Component) makes React skip re-rendering a component if its props are unchanged since the last render (compared with Object.is for each prop).
The counters in this demo are stored outside React purely to make renders visible; never do this in real components, which must stay pure.
import { memo, useState } from "react";
const renders = { plain: 0, memoized: 0 };
function PlainList({ items }) {
renders.plain += 1;
return <p>Plain list ({items.length} items) · renders so far: {renders.plain}</p>;
}
const MemoList = memo(function MemoList({ items }) {
renders.memoized += 1;
return <p>Memo list ({items.length} items) · renders so far: {renders.memoized}</p>;
});
const ITEMS = ["pen", "pencil", "eraser"];
export default function App() {
const [text, setText] = useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type to re-render App" />
<PlainList items={ITEMS} />
<MemoList items={ITEMS} />
</div>
);
}Type a few letters. Each keystroke re-renders App, so PlainList renders again every time. MemoList receives the same ITEMS array each time, so it rendered once and is skipped. (Its text keeps showing the first render's count, because it never re-renders.)
The catch: new objects and functions every render#
memo compares props by identity. An object, array or function created inside the parent's render is a brand-new value each time, so memo sees "changed" and re-renders anyway:
function App() {
const filters = { inStock: true }; // new object every render
const handleSelect = (id) => select(id); // new function every render
return <ProductList filters={filters} onSelect={handleSelect} />; // memo cannot help
}useMemo and useCallback keep such values stable between renders.
useMemo: cache a calculation#
useMemo(() => compute(), [deps]) re-runs the calculation only when a dependency changes; otherwise it returns the cached result:
import { useMemo, useState } from "react";
const NUMBERS = Array.from({ length: 200000 }, (_, i) => i + 1);
let calculations = 0;
function countPrimes(limit) {
calculations += 1;
let count = 0;
for (const n of NUMBERS) {
if (n > limit || n < 2) continue;
let prime = true;
for (let d = 2; d * d <= n; d++) if (n % d === 0) { prime = false; break; }
if (prime) count += 1;
}
return count;
}
export default function App() {
const [limit, setLimit] = useState(100000);
const [dark, setDark] = useState(false);
const primes = useMemo(() => countPrimes(limit), [limit]);
return (
<div style={{ background: dark ? "#222" : "#fff", color: dark ? "#eee" : "#222", padding: 10 }}>
<label>
Limit: <select value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
<option value={50000}>50,000</option>
<option value={100000}>1,00,000</option>
<option value={200000}>2,00,000</option>
</select>
</label>{" "}
<button onClick={() => setDark(!dark)}>Toggle theme</button>
<p>{primes} primes up to {limit.toLocaleString("en-IN")}</p>
<p>Calculations so far: {calculations}</p>
</div>
);
}Toggling the theme re-renders App, but the prime count is not recalculated because limit did not change. Changing the limit recalculates it, and the counter goes up.
useCallback: a stable function#
useCallback(fn, [deps]) returns the same function between renders until a dependency changes. It is useMemo for functions, and it matters when passing callbacks to memo components or using them as effect dependencies:
import { memo, useCallback, useState } from "react";
const renders = { stable: 0, unstable: 0 };
const AddButton = memo(function AddButton({ label, onAdd, counterKey }) {
renders[counterKey] += 1;
return <button onClick={onAdd} style={{ marginRight: 8 }}>{label} · renders: {renders[counterKey]}</button>;
});
export default function App() {
const [count, setCount] = useState(0);
const stableAdd = useCallback(() => setCount((c) => c + 1), []);
const unstableAdd = () => setCount((c) => c + 1);
return (
<div>
<AddButton label="useCallback" onAdd={stableAdd} counterKey="stable" />
<AddButton label="plain function" onAdd={unstableAdd} counterKey="unstable" />
<p>Count: {count}</p>
</div>
);
}Click either button: the count goes up, App re-renders, the button with the plain function re-renders (its onAdd is new each time), and the useCallback button is skipped. Because stableAdd uses the updater form, it needs no dependencies.
Measure first#
Optimising everything makes code harder to read and can even be slower (memoisation has its own cost). Before optimising:
- Use the React Developer Tools Profiler to record an interaction and see which components rendered and how long they took.
- Look for real problems: typing that lags, long lists, heavy calculations.
- Prefer structural fixes first: move state down closer to where it is used, pass components as
children, and virtualise very long lists (render only the visible rows, with a library such as TanStack Virtual).
The React Compiler#
The React Compiler is a build-time tool from the React team that adds this memoisation automatically, so you can write plain components without memo, useMemo and useCallback. Projects that enable it rarely need to write these hooks by hand; you still need to understand them to read existing code and to know what the compiler does.
Common mistakes#
Practice#
Practice exercise
A product list is filtered by a search box, and the page also has an unrelated "favourites only" style toggle. Use useMemo so that filtering 20,000 products only reruns when the search text changes, not when the toggle changes.
Show hint
const visible = useMemo(() => products.filter((p) => p.name.includes(query)), [query]);
Show solution
import { useMemo, useState } from "react";
const PRODUCTS = Array.from({ length: 20000 }, (_, i) => ({ id: i, name: `Product ${i}` }));
export default function App() {
const [query, setQuery] = useState("");
const [compact, setCompact] = useState(false);
const visible = useMemo(() => PRODUCTS.filter((p) => p.name.includes(query)), [query]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search, e.g. 1234" />
<label style={{ marginLeft: 8 }}>
<input type="checkbox" checked={compact} onChange={(e) => setCompact(e.target.checked)} /> Compact
</label>
<p>{visible.length.toLocaleString("en-IN")} matches</p>
<ul style={{ lineHeight: compact ? 1.1 : 1.8 }}>
{visible.slice(0, 5).map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
</div>
);
}Key takeaway#
Next: Error Boundaries.
Finished this lesson?