Concept
The beginner framing: React re-renders a component whenever its state or props change, sometimes this triggers real, necessary work (an expensive calculation, a child component re-rendering), and sometimes it triggers work that produces the EXACT same result as last time, purely wasted. Memoization is caching a computed result so identical inputs don't repeat identical work.
The three manual memoization tools
// useMemo, cache an EXPENSIVE CALCULATION's result between renders
const sortedItems = useMemo(() => expensiveSort(items), [items]);
// useCallback, cache a FUNCTION REFERENCE itself (not its result)
const handleClick = useCallback(() => doSomething(id), [id]);
// React.memo, skip a CHILD COMPONENT's re-render entirely if its props are unchanged
const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
return <div>{/* expensive render */}</div>;
});Each targets a genuinely different problem. useMemo avoids recomputing a VALUE. useCallback avoids creating a NEW function reference on every render, which matters specifically because a new function reference is itself a prop CHANGE that can defeat React.memo on a child expecting that function as a prop (a new reference !== the old one, even if the function does the exact same thing). React.memo avoids re-rendering a CHILD component entirely when its props are referentially unchanged.
Confirmed, current: React Compiler is stable but NOT the default reality
// next.config.ts, this app's ACTUAL current configuration:
const nextConfig: NextConfig = {
/* reactCompiler is NOT set, confirmed, this app does not have it enabled */
};Confirmed directly against this app's own bundled Next.js 16 documentation this session: the React Compiler reached its 1.0 release and is now STABLE, the reactCompiler config option was promoted from experimental to stable in Next.js 16. The Compiler automatically inserts the equivalent of useMemo/useCallback/React.memo at build time, aiming to make manual memoization largely unnecessary. But it is explicitly NOT enabled by default, it requires installing babel-plugin-react-compiler and setting reactCompiler: true, an opt-in this app itself does not have configured. This is a genuinely important, current fact for how this topic should be framed: manual memoization remains the PRACTICAL DEFAULT most real apps are actually shipping with today, even though a credible, stable path to automating it now exists, don't overclaim compiler ubiquity, and don't dismiss manual memoization as obsolete.
The real cost side of memoization, it is not free
// EVERY render still runs the useMemo hook itself, it just SKIPS the expensive callback
// if dependencies haven't changed. The comparison (and hook bookkeeping) has a real,
// small cost, and the cached value itself consumes memory for as long as it's retained.
const result = useMemo(() => cheapCalculation(x), [x]); // ❌ likely NOT worth memoizingMemoization trades a GUARANTEED small cost (the dependency comparison, plus retained memory for the cached value) for an AVOIDED cost that only materializes when the computation would have actually been re-run with unchanged inputs. For a genuinely cheap calculation, the guaranteed overhead of memoizing can exceed the avoided cost of just recomputing it, memoizing everything reflexively is a real anti-pattern, not a universal safety margin.
Try It
Predict the outcome before checking the solution.
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => console.log("clicked"); // NEW function reference every render
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment: {count}</button>
<ExpensiveChild onClick={handleClick} />
</div>
);
Does wrapping ExpensiveChild in React.memo actually prevent it from re-rendering when count changes?
Solution
No, ExpensiveChild still re-renders on every count change, despite the React.memo wrapper. handleClick is redefined as a brand-new function on every single render of Parent (a plain arrow function with no memoization), React.memo's shallow prop comparison sees a genuinely different onClick reference each time (functions are compared by reference, not by behavior), so it correctly concludes the props DID change and re-renders the child. React.memo alone is not sufficient here, the FIX requires wrapping handleClick in useCallback too: const handleClick = useCallback(() => console.log("clicked"), []);, only then does the SAME function reference persist across renders where its dependencies haven't changed, letting React.memo's comparison actually find no prop change and skip the re-render. This is exactly why useCallback and are usually used TOGETHER, not independently, one without the other frequently accomplishes nothing.
Implement It Yourself
Build a minimal memoize function, the actual caching mechanism useMemo conceptually wraps:
function memoize(fn) {
let lastArgs = null;
let lastResult = undefined;
let hasRun = false;
return function (...args) {
const argsChanged = !hasRun || args.length !== lastArgs.length || args.some((arg, i) => arg !== lastArgs[i]);
if (argsChanged) {
lastResult = fn(...args);
lastArgs = args;
This is exactly the mechanism (and exactly the gotcha) useMemo has: it compares dependencies by REFERENCE (===), not deep equality, newList failing the === check despite having identical contents to list is the same reason a freshly-created object or array literal passed as a dependency defeats memoization every single render, even when its actual values never change.
Under the Hood
The reference-equality mechanics demonstrated in Try It and Implement It Yourself are the same underlying comparison behavior covered mechanically in the already-shipped React hooks material, this topic applies it specifically to the "why didn't my memoization actually work" diagnostic problem. And this topic's React Compiler framing carries forward directly into React Performance Patterns, the SAME confirmed stable-but-opt-in reality applies there too, since it's about the identical underlying mechanism (automatic vs. manual re-render prevention), just at the whole-component-tree level rather than individual value/function/component memoization.
Common Mistakes
1. Memoizing a cheap calculation reflexively
const doubled = useMemo(() => x * 2, [x]); // ❌ the multiplication is cheaper than the memoization overhead itselfAs covered in Concept, memoization has a real, guaranteed cost (dependency comparison, retained memory); for trivially cheap computations, this overhead can exceed whatever it's supposedly saving.
2. Using React.memo without memoizing the function/object props being passed to it
<MemoizedChild onClick={() => doThing()} data={{ id: 1 }} /> // ❌ NEW references every render, React.memo does nothing hereAs shown in Try It, React.memo's shallow comparison is defeated by any prop that's a freshly-created object, array, or function on every render, regardless of whether its actual behavior/contents changed.
3. Assuming the React Compiler is already handling this automatically
"We don't need manual memoization anymore, React Compiler does it" // ❌ only true if EXPLICITLY enabledConfirmed: the Compiler is opt-in even in a framework version where it's stable, assuming it's active without checking next.config/build setup for reactCompiler: true (and the plugin actually installed) is a real, checkable mistake, not a safe assumption.
Best Practices
- Memoize expensive computations and stable function references passed to memoized children, not everything reflexively; measure/reason about actual cost first.
- Pair
useCallbackwithReact.memowhen passing a function as a prop to a memoized child, one without the other frequently accomplishes nothing, per Try It. - Remember dependency comparison is by REFERENCE (
===), not deep equality, a fresh object/array/function literal in a dependency array defeats memoization every render regardless of its actual contents. - Check whether React Compiler is actually enabled in your specific project before assuming manual memoization is unnecessary, verify
next.config'sreactCompilersetting (or the equivalent for your build setup) rather than assuming. - Treat manual memoization as the practical default for most apps today, while tracking React Compiler adoption as a credible near-term path to reducing how much of it you need to hand-write.
Performance Tips
- The
memoizemechanism (anduseMemo/useCallback) trades CPU time for memory, the cached value/function persists as long as its owning component instance does, which is a real (if usually small) memory cost, not a pure win. - Profiling (via React DevTools' Profiler, or the runtime-profiling techniques covered elsewhere) BEFORE reaching for memoization is the actual recommended workflow, confirm a specific re-render or recalculation is genuinely expensive and genuinely frequent before adding memoization overhead to address it.
