useMemo, useCallback & React.memo — Cachea valores, estabiliza referencias y omite re-renderizados
Receta
import { memo, useMemo, useCallback, useState } from "react";
interface Product {
id: string;
name: string;
price: number;
category: string;
}
// 1. useMemo — cachea un cálculo costoso
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 — estabiliza la referencia de función para hijos memoizados
function ProductPage() {
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setSelected(id);
}, []);
return <MemoizedProductGrid onSelect={handleSelect} />;
}
// 3. React.memo — omite re-renderizado cuando los props no cambian
const MemoizedProductGrid = memo(function ProductGrid({
onSelect,
}: {
onSelect: (id: string) => void;
}) {
return <div>{/* renderizado costoso de grid */}</div>;
});Cuándo usarlo: Después de hacer perfiles y descubrir que un cálculo específico o un re-renderizado de componente está causando lentitud medible. Nunca por defecto — React Compiler maneja la mayoría de la memoización automáticamente en React 19+.
Ejemplo de trabajo
// ---- ANTES: Sin memoización — filtrado de 10,000 productos en cada pulsación de tecla ----
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);
// Recompilado en CADA renderizado, incluyendo actualizaciones del carrito (cuesta ~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>Carrito: {cartCount}</header>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => setSortBy(sortBy === "price" ? "name" : "price")}>
Ordenar por {sortBy === "price" ? "nombre" : "precio"}
</button>
<button onClick={() => setCartCount((c) => c + 1)}>Agregar al carrito</button>
<ul>
{results.map((p) => (
<ProductRow
key={p.id}
product={p}
onAddToCart={() => setCartCount((c) => c + 1)} // Nueva función en cada renderizado
/>
))}
</ul>
</div>
);
}
function ProductRow({
product,
onAddToCart,
}: {
product: Product;
onAddToCart: () => void;
}) {
return (
<li>
{product.name} — ${product.price}
<button onClick={onAddToCart}>Agregar</button>
</li>
);
}
// ---- DESPUÉS: Memoizado — el filtrado solo se ejecuta cuando search o sortBy cambian ----
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);
// Solo se recomputa cuando search, sortBy o products cambian (~45ms ahorrados en clics de carrito)
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>Carrito: {cartCount}</header>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => setSortBy(sortBy === "price" ? "name" : "price")}>
Ordenar por {sortBy === "price" ? "nombre" : "precio"}
</button>
<button onClick={handleAddToCart}>Agregar al carrito</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}>Agregar</button>
</li>
);
});Lo que esto demuestra:
useMemocachea el resultado de filtro+ordenamiento: hacer clic en "Agregar al carrito" ya no recomputa 10,000 elementos (ahorra ~45ms por clic)useCallbackestabilizahandleAddToCartpara queMemoizedProductRowpueda omitir re-renderizadosmemoenProductRowpreviene que 10,000 elementos de lista se re-rendericen cuando solo cambia el conteo del carrito- Efecto combinado: la latencia de interacción cae de ~200ms a menos de 5ms en actualizaciones del carrito
Análisis profundo
Cómo funciona
useMemo(fn, deps)llamafnen el montaje y cachea el resultado. En renderizados posteriores, compara cada dependencia (usandoObject.is) contra los valores anteriores. Si todas coinciden, devuelve el valor cacheado sin llamarfn. Si alguna cambió, llamafnde nuevo y cachea el nuevo resultado.useCallback(fn, deps)es azúcar sintáctico parauseMemo(() => fn, deps). Cachea la referencia de la función misma en lugar del valor de retorno de la función. Esto mantiene la referencia estable entre renderizados mientras las dependencias no cambien.React.memo(Component, areEqual?)envuelve un componente para que React realice una comparación superficial de props antes de renderizar. Si todos los props son superficialmente iguales al renderizado anterior, React omite el componente y reutiliza la última salida renderizada. Una funciónareEqualopcional puede personalizar la comparación.- Comparación superficial verifica igualdad de referencia para objetos y arrays. Dos objetos
{ a: 1 }y{ a: 1 }no son superficialmente iguales porque son referencias diferentes, aunque su contenido coincida. - React nunca garantiza retención de caché —
useMemoyuseCallbackson pistas de rendimiento, no garantías semánticas. React puede descartar valores cacheados bajo presión de memoria o durante futuras características concurrentes.
Variaciones
useMemo para igualdad referencial (no solo cálculos costosos):
// Sin useMemo: nueva referencia de array dispara useEffect en hijo
function Parent() {
const config = { theme: "dark", locale: "en" }; // Nuevo objeto en cada renderizado
return <Dashboard config={config} />;
}
// Con useMemo: referencia estable previene ejecuciones de efecto innecesarias
function Parent() {
const config = useMemo(() => ({ theme: "dark", locale: "en" }), []);
return <Dashboard config={config} />;
}Función de comparación personalizada con memo:
const ChartWidget = memo(
function ChartWidget({ data, title }: { data: number[]; title: string }) {
return <canvas>{/* renderizado costoso de gráfico */}</canvas>;
},
(prev, next) => {
// Omite re-renderizado si la longitud del array y el título son los mismos
return prev.data.length === next.data.length && prev.title === next.title;
}
);Cuándo NO memoizar:
// MAL: memoizar un cálculo trivial
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
// BIEN: solo calcúlalo — la concatenación de strings es casi instantánea
const greeting = `Hello, ${name}!`;
// MAL: memoizar una primitiva que nunca cambia de referencia
const TAX_RATE = useMemo(() => 0.08, []);
// BIEN: solo usa una constante
const TAX_RATE = 0.08;Notas de TypeScript
useMemo<T>infiereTdel tipo de retorno de la función factory. La anotación explícita raramente es necesaria.useCallbackinfiere la firma de la función. TypeScript refuerza que el tipo de callback coincida con lo que los hijos esperan.- Para componentes memoizados genéricos, usa una aserción de tipo:
memo(GenericList) as typeof GenericList. - El comparador
areEqualenmemorecibe(prevProps: Readonly<Props>, nextProps: Readonly<Props>).
Trampas
-
Memoizar sin estabilizar dependencias —
useMemorecalcula si alguna dependencia es una nueva referencia. Pasar un objeto inline como dependencia anula el caché. Solución: Asegúrate de que todas las dependencias sean primitivas o referencias estables. -
useCallback con dependencias faltantes — Omitir una dependencia de
useCallbackcrea un cierre obsoleto que captura valores de estado antiguos. Solución: Siempre incluye todos los valores referenciados. Habilita la regla ESLintreact-hooks/exhaustive-deps. -
memo en componentes que reciben children —
childrenes un nuevo árbol de elementos React en cada renderizado, por lo quememoes inefectivo para componentes que recibenchildren. Solución: Ya sea memoiza los children en el nivel padre o reestructura para evitar pasar children a componentes memoizados. -
Sobre-memoizar — Envolver cada componente en
memoy cada valor enuseMemoagrega sobrecarga de comparación que ralentiza componentes simples. Solución: Haz perfiles primero. Solo memoiza componentes que el Profiler muestra que son costosos (más de 5ms de tiempo de renderizado) y se están re-renderizando innecesariamente. -
React Compiler hace obsoleta la mayoría de la memoización manual — En React 19+ con el compiler habilitado,
useMemo,useCallbackymemose aplican automáticamente en tiempo de compilación. Solución: Si usas React Compiler, elimina la memoización manual y solo agrega de vuelta si los perfiles muestran que el compiler perdió un caso. -
useMemo no garantiza que el caché persista — React documenta que
useMemoes una pista, no una garantía. Las futuras versiones de React pueden descartar cachés más agresivamente. Solución: Nunca confíes enuseMemopara la corrección, solo para el rendimiento. Tu código debe funcionar correctamente incluso si cadauseMemose recomputa en cada renderizado.
Alternativas
| Enfoque | Compensación |
|---|---|
| React Compiler | Memoización automática; sin hooks manuales necesarios; requiere React 19+ |
| Colocación de estado | Mueve el estado más cerca del uso; evita la necesidad de memoizar en primer lugar |
useDeferredValue | Difiere actualizaciones en lugar de cachear; muestra datos obsoletos brevemente |
| Virtualización | Renderiza solo elementos visibles; más efectivo que memo para listas grandes |
| Web Workers | Descarga computación pesada fuera del hilo principal; transferencia de datos compleja |
| Selectores de Zustand | Suscripciones de estado de grano fino sin memo o context |
Ejemplo del mundo real
De una aplicación SaaS de producción Next.js 15 / React 19 (SystemsArchitect.io).
React.memo con comparación personalizada
// Ejemplo de producción: SavePointCheckbox con comparación memo personalizada
// Archivo: 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="Guardar este punto"
/>
);
},
(prev, next) => {
// Comparación personalizada: ignora la identidad del callback, compara solo props de datos
return prev.pointId === next.pointId && prev.isSaved === next.isSaved;
}
);useMemo y useCallback en un hook personalizado
// Ejemplo de producción: Hook de Quiz con valor de retorno memoizado
// Archivo: 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]);
}Lo que esto demuestra en producción:
- La comparación personalizada en
SavePointCheckboxfue necesaria porque el padre recrea el callbackonSavedChangeen cada renderizado. Sin el comparador personalizado, cada checkbox en una lista de 50+ elementos se re-rendería siempre que cualquier checkbox cambiara. - El comparador personalizado intencionalmente omite comparar
onSavedChange. Esto es seguro porque el callback siempre tiene el mismo comportamiento (llama a la misma acción de store) aunque su referencia cambie. - En
useQuiz, eluseMemoen el objeto de retorno es crítico. Sin él, cada componente que consume este hook recibiría una nueva referencia de objeto en cada renderizado, anulando cualquier envoltoriomemodownstream. submitAnswerusauseCallbackcon un array de dependencias vacío porque solo llamasetAnswerscon un updater funcional. La función updater siempre recibe el estado más reciente, por lo que no se necesitan dependencias externas.new Map(prev).set(questionId, answer)crea una nueva referencia de Map para que React detecte el cambio de estado. Mutar el Map existente conprev.set(...)no dispararía un re-renderizado.
FAQs
¿Cuál es la diferencia entre useMemo y useCallback?
useMemo(fn, deps)cachea el valor de retorno defn.useCallback(fn, deps)cachea la referencia de función misma.useCallback(fn, deps)es equivalente auseMemo(() => fn, deps).
¿Cuándo NO deberías usar useMemo?
- Para cálculos triviales como concatenación de strings o aritmética.
- Para constantes que nunca cambian (solo usa una
const). - Cuando React Compiler (React 19+) está habilitado -- maneja la memoización automáticamente.
¿Por qué React.memo no funciona cuando un componente recibe children?
childrenes un nuevo árbol de elementos React en cada renderizado, creando una nueva referencia cada vez.- La comparación superficial en
memove una referencia diferente y se re-renderiza de todas formas. - Solución: Memoiza los children en el nivel padre o reestructura para evitar pasar children.
¿Cómo funciona la función de comparación personalizada en React.memo?
const MyComponent = memo(
function MyComponent({ data, title }: Props) {
return <div>{title}: {data.length} elementos</div>;
},
(prev, next) => {
// Devuelve true para OMITIR re-renderizado, false para re-renderizar
return prev.data.length === next.data.length
&& prev.title === next.title;
}
);Trampa: ¿Qué sucede si pasas un objeto inline como dependencia de useMemo?
- Los objetos inline crean una nueva referencia en cada renderizado.
useMemove una dependencia diferente cada vez y recalcula, anulando el caché.- Solución: Asegúrate de que todas las dependencias sean primitivas o referencias estables (vía
useMemo/useCallback).
¿Por qué es importante que useMemo sea una pista, no una garantía?
- React puede descartar valores cacheados bajo presión de memoria o durante características concurrentes.
- Tu código debe funcionar correctamente incluso si cada
useMemose recomputa en cada renderizado. - Nunca uses
useMemopara la corrección -- solo para optimización de rendimiento.
¿Cómo afecta React Compiler a la memoización manual?
- En React 19+ con el compiler habilitado,
useMemo,useCallbackymemose aplican automáticamente en tiempo de compilación. - La memoización manual se vuelve redundante y agrega código innecesario.
- Solución: Elimina la memoización manual cuando uses el compiler; agrégala de vuelta solo si los perfiles muestran una brecha.
Trampa: ¿Qué es un bug de cierre obsoleto con useCallback?
- Omitir una dependencia de
useCallbackcaptura valores de estado antiguos en el cierre. - El callback lee datos obsoletos en lugar del estado actual.
- Solución: Incluye todos los valores referenciados en el array de dependencias; habilita la regla ESLint
react-hooks/exhaustive-deps.
¿Cómo tipas un componente memoizado genérico en TypeScript?
// React.memo pierde genéricos, así que usa una aserción de tipo
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;
// Uso: <MemoizedList<number> items={[1, 2, 3]} />¿Cuál es la firma de tipo del comparador areEqual en React.memo?
(prevProps: Readonly<Props>, nextProps: Readonly<Props>) => boolean- Devuelve
truepara omitir re-renderizado,falsepara permitirlo. - Ambos argumentos son
Readonly-- no puedes mutar props dentro del comparador.
¿Por qué deberías devolver un objeto memoizado desde un hook personalizado?
- Sin
useMemoen el valor de retorno, cada consumidor recibe una nueva referencia de objeto en cada renderizado. - Esto anula cualquier envoltorio
memodownstream.
return useMemo(() => ({
currentQuestion,
submitAnswer,
progress,
}), [currentQuestion, submitAnswer, progress]);¿Cómo decides entre useMemo, colocación de estado y virtualización para una lista lenta?
- useMemo: Cachea filtro/ordenamiento costoso; bueno cuando la lista tiene menos de ~500 elementos.
- Colocación de estado: Mueve el estado más cerca del componente que lo usa para evitar re-renderizar hermanos.
- Virtualización: Renderiza solo elementos visibles; más efectivo para listas con más de 100 elementos.
Relacionado
- Prevenir re-renderizados — Causas raíz y correcciones estructurales para re-renderizados innecesarios
- React Compiler — Memoización automática que reemplaza hooks manuales
- Profiling — Cómo probar que se necesita memoización con React DevTools
- Rendimiento de gestión de estado — Selectores de Zustand como alternativa a memo