Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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+.
// ---- 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:
useMemo caches the filter+sort result: clicking "Add to cart" no longer recomputes 10,000 items (saves ~45ms per click)useCallback stabilizes handleAddToCart so MemoizedProductRow can skip re-rendersmemo on ProductRow prevents 10,000 list items from re-rendering when only the cart count changesuseMemo(fn, deps) calls fn on mount and caches the result. On subsequent renders, it compares each dependency (using Object.is) against the previous values. If all match, it returns the cached value without calling fn. If any changed, it calls fn again and caches the new result.useCallback(fn, deps) is syntactic sugar for useMemo(() => 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 optional areEqual function can customize the comparison.{ a: 1 } and { a: 1 } are not shallowly equal because they are different references, even though their contents match.useMemo and useCallback are performance hints, not semantic guarantees. React may discard cached values under memory pressure or during future concurrent features.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;useMemo<T> infers T from the factory function return type. Explicit annotation is rarely needed.useCallback infers the function signature. TypeScript enforces that the callback type matches what children expect.memo(GenericList) as typeof GenericList.areEqual comparator in memo receives (prevProps: Readonly<Props>, nextProps: Readonly<Props>).Memoizing without stabilizing dependencies - useMemo recalculates 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 useCallback creates a stale closure that captures old state values. Fix: Always include all referenced values. Enable the react-hooks/exhaustive-deps ESLint rule.
memo on components receiving children - children is a new React element tree on every render, so memo is ineffective for components that receive children. Fix: Either memoize the children at the parent level or restructure to avoid passing children to memoized components.
Over-memoizing - Wrapping every component in memo and every value in useMemo adds 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, and memo are 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 useMemo is a hint, not a guarantee. Future React versions may discard caches more aggressively. Fix: Never rely on useMemo for correctness, only for performance. Your code must work correctly even if every useMemo recomputes on every render.
| 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 |
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// 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;
}
);// 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:
SavePointCheckbox was necessary because the parent recreates the onSavedChange callback on every render. Without the custom comparator, every checkbox in a list of 50+ items would re-render whenever any single checkbox changed.onSavedChange. This is safe because the callback always has the same behavior (it calls the same store action) even though its reference changes.useQuiz, the useMemo on the return object is critical. Without it, every component consuming this hook would receive a new object reference on every render, defeating any memo wrappers downstream.submitAnswer uses useCallback with an empty dependency array because it only calls setAnswers with 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 with prev.set(...) would not trigger a re-render.useMemo(fn, deps) caches the return value of fn.useCallback(fn, deps) caches the function reference itself.useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).const).children is a new React element tree on every render, creating a new reference each time.memo sees a different reference and re-renders anyway.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;
}
);useMemo sees a different dependency each time and recalculates, defeating the cache.useMemo/useCallback).useMemo recomputes on every render.useMemo for correctness -- only for performance optimization.useMemo, useCallback, and memo are applied automatically at build time.useCallback captures old state values in the closure.react-hooks/exhaustive-deps ESLint rule.// 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]} />(prevProps: Readonly<Props>, nextProps: Readonly<Props>) => booleantrue to skip re-render, false to allow it.Readonly -- you cannot mutate props inside the comparator.useMemo on the return value, every consumer receives a new object reference each render.memo wrappers downstream.return useMemo(() => ({
currentQuestion,
submitAnswer,
progress,
}), [currentQuestion, submitAnswer, progress]);Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥