useMemo, useCallback & React.memo — Cache values, stabilize references, and skip re-renders
Recipe
import { memo, useMemo, useCallback, useState } from "react";
interface Product {
id: string;
name: string;
price: number;
category: string;
}
// 1. useMemo — cache an expensive computation
function ProductFilter({ products, category }: { products: Product[]; category: string }) {
const filtered = useMemo(
() => products.filter((p) => p.category === category).sort((a, b) => a.price - b.price),
[products, category]
);
return <ProductList items={filtered} />;
}
// 2. useCallback — stabilize function reference for memoized children
function ProductPage() {
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setSelected(id);
}, []);
return <MemoizedProductGrid onSelect={handleSelect} />;
}
// 3. React.memo — skip re-render when props are unchanged
const MemoizedProductGrid = memo(function ProductGrid({
onSelect,
}: {
onSelect: (id: string) => void;
}) {
return <div>{/* expensive grid rendering */}</div>;
});When to reach for this: After profiling reveals that a specific computation or component re-render is causing measurable slowness. Never as a default — the React Compiler handles most memoization automatically in React 19+.
Working Example
// ---- BEFORE: No memoization — filtering 10,000 products on every keystroke ----
function StorePage() {
const [products] = useState<Product[]>(generateProducts(10_000));
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"price" | "name">("name");
const [cartCount, setCartCount] = useState(0);
// Recomputed on EVERY render, including cart updates (costs ~45ms)
const results = products
.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
sortBy === "price" ? a.price - b.price : a.name.localeCompare(b.name)
);
return (
<div>
<header>Cart: {cartCount}</header>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => setSortBy(sortBy === "price" ? "name" : "price")}>
Sort by {sortBy === "price" ? "name" : "price"}
</button>
<button onClick={() => setCartCount((c) => c + 1)}>Add to cart</button>
<ul>
{results.map((p) => (
<ProductRow
key={p.id}
product={p}
onAddToCart={() => setCartCount((c) => c + 1)} // New fn every render
/>
))}
</ul>
</div>
);
}
function ProductRow({
product,
onAddToCart,
}: {
product: Product;
onAddToCart: () => void;
}) {
return (
<li>
{product.name} — ${product.price}
<button onClick={onAddToCart}>Add</button>
</li>
);
}
// ---- AFTER: Memoized — filtering only runs when search or sortBy changes ----
function StorePageOptimized() {
const [products] = useState<Product[]>(generateProducts(10_000));
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"price" | "name">("name");
const [cartCount, setCartCount] = useState(0);
// Only recomputes when search, sortBy, or products change (~45ms saved on cart clicks)
const results = useMemo(
() =>
products
.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
sortBy === "price" ? a.price - b.price : a.name.localeCompare(b.name)
),
[products, search, sortBy]
);
const handleAddToCart = useCallback(() => {
setCartCount((c) => c + 1);
}, []);
return (
<div>
<header>Cart: {cartCount}</header>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => setSortBy(sortBy === "price" ? "name" : "price")}>
Sort by {sortBy === "price" ? "name" : "price"}
</button>
<button onClick={handleAddToCart}>Add to cart</button>
<ul>
{results.map((p) => (
<MemoizedProductRow
key={p.id}
product={p}
onAddToCart={handleAddToCart}
/>
))}
</ul>
</div>
);
}
const MemoizedProductRow = memo(function ProductRow({
product,
onAddToCart,
}: {
product: Product;
onAddToCart: () => void;
}) {
return (
<li>
{product.name} — ${product.price}
<button onClick={onAddToCart}>Add</button>
</li>
);
});What this demonstrates:
useMemocaches the filter+sort result: clicking "Add to cart" no longer recomputes 10,000 items (saves ~45ms per click)useCallbackstabilizeshandleAddToCartsoMemoizedProductRowcan skip re-rendersmemoonProductRowprevents 10,000 list items from re-rendering when only the cart count changes- Combined effect: interaction latency drops from ~200ms to under 5ms on cart updates
Deep Dive
How It Works
useMemo(fn, deps)callsfnon mount and caches the result. On subsequent renders, it compares each dependency (usingObject.is) against the previous values. If all match, it returns the cached value without callingfn. If any changed, it callsfnagain and caches the new result.useCallback(fn, deps)is syntactic sugar foruseMemo(() => fn, deps). It caches the function reference itself rather than the function's return value. This keeps the reference stable across renders as long as dependencies do not change.React.memo(Component, areEqual?)wraps a component so React performs a shallow prop comparison before rendering. If all props are shallowly equal to the previous render, React skips the component and reuses the last rendered output. An optionalareEqualfunction can customize the comparison.- Shallow comparison checks reference equality for objects and arrays. Two objects
{ a: 1 }and{ a: 1 }are not shallowly equal because they are different references, even though their contents match. - React never guarantees cache retention —
useMemoanduseCallbackare performance hints, not semantic guarantees. React may discard cached values under memory pressure or during future concurrent features.
Variations
useMemo for referential equality (not just expensive computations):
// Without useMemo: new array reference triggers useEffect in child
function Parent() {
const config = { theme: "dark", locale: "en" }; // New object every render
return <Dashboard config={config} />;
}
// With useMemo: stable reference prevents unnecessary effect runs
function Parent() {
const config = useMemo(() => ({ theme: "dark", locale: "en" }), []);
return <Dashboard config={config} />;
}Custom comparison function with memo:
const ChartWidget = memo(
function ChartWidget({ data, title }: { data: number[]; title: string }) {
return <canvas>{/* expensive chart render */}</canvas>;
},
(prev, next) => {
// Skip re-render if array length and title are the same
return prev.data.length === next.data.length && prev.title === next.title;
}
);When NOT to memoize:
// BAD: memoizing a trivial computation
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
// GOOD: just compute it — string concatenation is near-instant
const greeting = `Hello, ${name}!`;
// BAD: memoizing a primitive that never changes reference
const TAX_RATE = useMemo(() => 0.08, []);
// GOOD: just use a constant
const TAX_RATE = 0.08;TypeScript Notes
useMemo<T>infersTfrom the factory function return type. Explicit annotation is rarely needed.useCallbackinfers the function signature. TypeScript enforces that the callback type matches what children expect.- For generic memoized components, use a type assertion:
memo(GenericList) as typeof GenericList. - The
areEqualcomparator inmemoreceives(prevProps: Readonly<Props>, nextProps: Readonly<Props>).
Gotchas
-
Memoizing without stabilizing dependencies —
useMemorecalculates if any dependency is a new reference. Passing an inline object as a dependency defeats the cache. Fix: Ensure all dependencies are primitives or stable references. -
useCallback with missing dependencies — Omitting a dependency from
useCallbackcreates a stale closure that captures old state values. Fix: Always include all referenced values. Enable thereact-hooks/exhaustive-depsESLint rule. -
memo on components receiving children —
childrenis a new React element tree on every render, somemois ineffective for components that receivechildren. Fix: Either memoize the children at the parent level or restructure to avoid passing children to memoized components. -
Over-memoizing — Wrapping every component in
memoand every value inuseMemoadds comparison overhead that slows down simple components. Fix: Profile first. Only memoize components that the Profiler shows are expensive (over 5ms render time) and re-rendering unnecessarily. -
React Compiler makes most manual memoization obsolete — In React 19+ with the compiler enabled,
useMemo,useCallback, andmemoare applied automatically at build time. Fix: If using React Compiler, remove manual memoization and only add it back if profiling shows the compiler missed a case. -
useMemo does not guarantee the cache persists — React documents that
useMemois a hint, not a guarantee. Future React versions may discard caches more aggressively. Fix: Never rely onuseMemofor correctness, only for performance. Your code must work correctly even if everyuseMemorecomputes on every render.
Alternatives
| Approach | Trade-off |
|---|---|
| React Compiler | Automatic memoization; no manual hooks needed; requires React 19+ |
| State colocation | Move state closer to usage; avoids the need to memoize in the first place |
useDeferredValue | Defers updates instead of caching; shows stale data briefly |
| Virtualization | Renders only visible items; more effective than memo for large lists |
| Web Workers | Offloads heavy computation off the main thread; complex data transfer |
| Zustand selectors | Fine-grained state subscriptions without memo or context |
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
React.memo with Custom Comparison
// Production example: SavePointCheckbox with custom memo comparison
// File: src/components/study/save-point-checkbox.tsx
'use client';
import React, { memo } from 'react';
interface SavePointCheckboxProps {
pointId: string;
isSaved: boolean;
onSavedChange: (pointId: string, isSaved: boolean) => void;
}
const SavePointCheckbox = memo(
function SavePointCheckbox({ pointId, isSaved, onSavedChange }: SavePointCheckboxProps) {
return (
<input
type="checkbox"
checked={isSaved}
onChange={(e) => onSavedChange(pointId, e.target.checked)}
aria-label="Save this point"
/>
);
},
(prev, next) => {
// Custom comparison: ignore callback identity, compare only data props
return prev.pointId === next.pointId && prev.isSaved === next.isSaved;
}
);useMemo and useCallback in a Custom Hook
// Production example: Quiz hook with memoized return value
// File: src/hooks/use-quiz.ts
import { useState, useMemo, useCallback } from 'react';
export function useQuiz(questions: Question[]) {
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Map<string, string>>(new Map());
const currentQuestion = useMemo(
() => questions[currentIndex],
[questions, currentIndex]
);
const submitAnswer = useCallback(
(questionId: string, answer: string) => {
setAnswers((prev) => new Map(prev).set(questionId, answer));
},
[]
);
const goToNext = useCallback(() => {
setCurrentIndex((prev) => Math.min(prev + 1, questions.length - 1));
}, [questions.length]);
return useMemo(() => ({
currentQuestion,
submitAnswer,
goToNext,
progress: currentIndex / questions.length,
}), [currentQuestion, submitAnswer, goToNext, currentIndex, questions.length]);
}What this demonstrates in production:
- The custom comparison in
SavePointCheckboxwas necessary because the parent recreates theonSavedChangecallback on every render. Without the custom comparator, every checkbox in a list of 50+ items would re-render whenever any single checkbox changed. - The custom comparator intentionally skips comparing
onSavedChange. This is safe because the callback always has the same behavior (it calls the same store action) even though its reference changes. - In
useQuiz, theuseMemoon the return object is critical. Without it, every component consuming this hook would receive a new object reference on every render, defeating anymemowrappers downstream. submitAnswerusesuseCallbackwith an empty dependency array because it only callssetAnswerswith a functional updater. The updater function always receives the latest state, so no external dependencies are needed.new Map(prev).set(questionId, answer)creates a new Map reference so React detects the state change. Mutating the existing Map withprev.set(...)would not trigger a re-render.
FAQs
What is the difference between useMemo and useCallback?
useMemo(fn, deps)caches the return value offn.useCallback(fn, deps)caches the function reference itself.useCallback(fn, deps)is equivalent touseMemo(() => fn, deps).
When should you NOT use useMemo?
- For trivial computations like string concatenation or arithmetic.
- For constants that never change (just use a
const). - When the React Compiler (React 19+) is enabled -- it handles memoization automatically.
Why does React.memo not work when a component receives children?
childrenis a new React element tree on every render, creating a new reference each time.- The shallow comparison in
memosees a different reference and re-renders anyway. - Fix: Memoize the children at the parent level or restructure to avoid passing children.
How does the custom comparison function in React.memo work?
const MyComponent = memo(
function MyComponent({ data, title }: Props) {
return <div>{title}: {data.length} items</div>;
},
(prev, next) => {
// Return true to SKIP re-render, false to re-render
return prev.data.length === next.data.length
&& prev.title === next.title;
}
);Gotcha: What happens if you pass an inline object as a useMemo dependency?
- Inline objects create a new reference on every render.
useMemosees a different dependency each time and recalculates, defeating the cache.- Fix: Ensure all dependencies are primitives or stable references (via
useMemo/useCallback).
Why is it important that useMemo is a hint, not a guarantee?
- React may discard cached values under memory pressure or during concurrent features.
- Your code must work correctly even if every
useMemorecomputes on every render. - Never use
useMemofor correctness -- only for performance optimization.
How does the React Compiler affect manual memoization?
- In React 19+ with the compiler enabled,
useMemo,useCallback, andmemoare applied automatically at build time. - Manual memoization becomes redundant and adds unnecessary code.
- Fix: Remove manual memoization when using the compiler; add it back only if profiling shows a gap.
Gotcha: What is a stale closure bug with useCallback?
- Omitting a dependency from
useCallbackcaptures old state values in the closure. - The callback reads stale data instead of the current state.
- Fix: Include all referenced values in the dependency array; enable
react-hooks/exhaustive-depsESLint rule.
How do you type a generic memoized component in TypeScript?
// React.memo loses generics, so use a type assertion
function GenericList<T>({ items }: { items: T[] }) {
return <ul>{items.map((item, i) => <li key={i}>{String(item)}</li>)}</ul>;
}
const MemoizedList = memo(GenericList) as typeof GenericList;
// Usage: <MemoizedList<number> items={[1, 2, 3]} />What is the type signature of the areEqual comparator in React.memo?
(prevProps: Readonly<Props>, nextProps: Readonly<Props>) => boolean- Return
trueto skip re-render,falseto allow it. - Both arguments are
Readonly-- you cannot mutate props inside the comparator.
Why should you return a memoized object from a custom hook?
- Without
useMemoon the return value, every consumer receives a new object reference each render. - This defeats any
memowrappers downstream.
return useMemo(() => ({
currentQuestion,
submitAnswer,
progress,
}), [currentQuestion, submitAnswer, progress]);How do you decide between useMemo, state colocation, and virtualization for a slow list?
- useMemo: Caches expensive filter/sort; good when the list is under ~500 items.
- State colocation: Move state closer to the component that uses it to avoid re-rendering siblings.
- Virtualization: Renders only visible items; most effective for lists over 100 items.
Related
- Preventing Re-renders — Root causes and structural fixes for unnecessary re-renders
- React Compiler — Automatic memoization that replaces manual hooks
- Profiling — How to prove memoization is needed with React DevTools
- State Management Performance — Zustand selectors as an alternative to memo