Memoization fixes real slowdowns, but misapplied it adds complexity; the Compiler changes when you need it by hand.
Because re-rendering cascades, you sometimes want to skip work. React offers three tools:
- `memo(Component)` skips re-rendering a component when its props are shallowly equal (
Object.isper prop) to last time. - `useMemo(() => compute(a, b), [a, b])` caches a computed value between renders until a dependency changes.
- `useCallback(fn, [deps])` caches a function identity; it's
useMemo(() => fn, deps).
They work together. memo is defeated if a parent passes a new object or arrow function each render, since {} !== {}. So you stabilise those props with useMemo/useCallback. The same applies to values in effect dependencies and context values.
Use them when you've measured a problem: an expensive calculation (filtering thousands of rows), a large subtree that re-renders on every keystroke, or a value that must be stable for an effect. Memoizing everything costs memory and readability, and one unstable prop silently breaks the chain.
Often a structural fix is better: move state down into the component that uses it, or lift expensive content up and pass it as children so it isn't re-created when the wrapper's state changes.
The React Compiler (stable since v1.0) is a build-time plugin (babel-plugin-react-compiler, enabled through the Vite React plugin or Next.js config) that analyses your components and Hooks and inserts fine-grained memoization automatically, including for JSX and callbacks, and even after early returns. With it, most manual memo/useMemo/useCallback becomes unnecessary. It relies on your code following the Rules of React (pure render, no mutation of props/state, Hooks at top level) and skips components it can't prove safe; the eslint-plugin-react-hooks rules flag those. Manual memoization remains as an escape hatch for precise control, such as effect dependencies.
Example
import { memo, useCallback, useMemo, useState } from "react";
const List = memo(function List({ items, onSelect }) {
console.log("List rendered");
return <ul>{items.map(i => <li key={i.id} onClick={() => onSelect(i.id)}>{i.name}</li>)}</ul>;
});
export default function Products({ products }) {
const [query, setQuery] = useState("");
const [theme, setTheme] = useState("light");
// Recomputed only when products or query change, not when theme changes
const visible = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query]
);
// Stable identity so memo(List) can skip re-rendering
const handleSelect = useCallback(id => console.log("selected", id), []);
return (
<div className={theme}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button onClick={() => setTheme(t => (t === "light" ? "dark" : "light"))}>Theme</button>
<List items={visible} onSelect={handleSelect} />
</div>
);
}
// With the React Compiler enabled, the useMemo/useCallback/memo above are unnecessary.Remember
memoskips renders on shallow-equal props;useMemocaches values;useCallbackcaches functions- A single new object or function prop defeats
memo; measure before memoizing - The React Compiler memoizes automatically for code that follows the Rules of React
Watch out for
- Wrapping a component in
memobut passing an inline object or arrow function, so it re-renders anyway.