useMemo y useCallback en React: cuándo realmente necesitas
1. Descripción general
useMemo y useCallback suelen usarse por intuición: o envuelves todo “por si acaso”, o nunca los usas. Ambas formas están mal. Este artículo explica el mecanismo detrás de estos dos hooks, señala tres situaciones donde realmente son útiles y casos donde deberías evitarlos. También escribirás un test para demostrar que la memoización tiene un efecto real.
2. El problema base: cada render crea todo de nuevo
Cada vez que un component se renderiza, todo el cuerpo de la función se ejecuta desde el principio. Cada objeto, array y función declarados adentro se crean nuevamente.
function Checkout({ customer }) {
const shipping = { city: customer.city, express: true };
const handlePay = () => console.log('Pago para', customer.name);
return <PaymentForm shipping={shipping} onPay={handlePay} />;
}
Después de cada render de Checkout, shipping y handlePay son referencias nuevas, aunque su contenido no cambie. JavaScript compara objetos y funciones por referencia:
{ city: 'Huế' } === { city: 'Huế' } // false
(() => 1) === (() => 1) // false
Normalmente esto es inofensivo, porque crear objetos es barato. Solo se convierte en un problema cuando algo compara referencias. Eso podría ser React.memo, el array de dependencias de useEffect, u otro hook. useMemo y useCallback fueron creados para mantener referencias estables entre renders.
3. useMemo: recordar un valor
useMemo(calculate, deps) llama a calculate en el primer render y guarda el resultado. En los siguientes renders, React solo recalcula cuando un elemento en deps cambia, comparando con Object.is.
3.1. Evitar cálculos costosos en cada render
import { useMemo, useState } from 'react';
function OrderReport({ orders }) {
const [keyword, setKeyword] = useState('');
const [darkMode, setDarkMode] = useState(false);
const visibleOrders = useMemo(() => {
const kw = keyword.toLowerCase();
return orders
.filter((o) => o.customer.toLowerCase().includes(kw))
.sort((a, b) => b.total - a.total);
}, [orders, keyword]);
return (
<div className={darkMode ? 'dark' : ''}>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={() => setDarkMode((d) => !d)}>Cambiar tema</button>
<ul>
{visibleOrders.map((o) => (
<li key={o.id}>{o.customer}: {o.total}</li>
))}
</ul>
</div>
);
}
Explicación:
- La operación
filterysortsolo se ejecutan nuevamente cuandoordersokeywordcambian. - Cuando haces clic en “Cambiar tema”,
darkModecambia y el component se renderiza nuevamente. En este momentouseMemodevuelve el array guardado, sin filtrar de nuevo. sortes seguro aquí porquefilterya devuelve un array nuevo. No hay ninguna operación que modifique directamente el proporders.
¿Cómo saber si un cálculo es “costoso”? Mide en lugar de adivinar:
console.time('filter orders');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('filter orders');
filter orders: 0.08ms
Un punto práctico de referencia: si un cálculo tarda menos de alrededor de 1ms en tu máquina de desarrollo, casi nunca necesita memoización. Recuerda medir en la build de producción, y mejor aún, activa el throttling de CPU en DevTools, porque las máquinas de los usuarios suelen ser más lentas que la tuya.
3.2. Mantener un objeto estable para pasar adelante
El segundo caso no está relacionado con la velocidad del cálculo. Aquí necesitas un objeto con una referencia estable para usarlo como dependencia:
function CustomerOrders({ customerId, status }) {
const [orders, setOrders] = useState([]);
const query = useMemo(() => ({ customerId, status }), [customerId, status]);
useEffect(() => {
fetchOrders(query).then(setOrders);
}, [query]);
return <OrderTable orders={orders} />;
}
Si omites useMemo, query será un objeto nuevo después de cada render. Entonces el effect se ejecutaría continuamente, y como setOrders causa otro render, podrías caer en un ciclo infinito de llamadas a la API.
Nota: En este ejemplo hay una forma más simple. Creas el objeto dentro del effect y pones los valores primitivos en las dependencias:
useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);. Prefiere ese enfoque. Solo usauseMemocuando el objeto debe vivir fuera del effect, por ejemplo cuando se pasa a un component hijo.
4. useCallback: recordar una función
useCallback(fn, deps) es equivalente a useMemo(() => fn, deps). Devuelve la función que pasaste y mantiene la misma referencia hasta que deps cambie. useCallback no hace que la función se ejecute más rápido. Este hook solo tiene sentido cuando la función se compara en algún lugar.
4.1. Combinado con React.memo
React.memo omite el render de un component hijo si los props son los mismos que la vez anterior (comparación superficial). Si uno de los props es una función creada nuevamente cada vez, memo pierde su utilidad.
import { memo, useCallback, useState } from 'react';
const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
onRender?.(order.id);
return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});
export function OrderList({ orders, onRender }) {
const [selectedId, setSelectedId] = useState(null);
const [note, setNote] = useState('');
const handleSelect = useCallback((id) => setSelectedId(id), []);
return (
<>
<input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
<ul>
{orders.map((o) => (
<OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
))}
</ul>
<p>Seleccionado: {selectedId ?? 'ninguno'}</p>
</>
);
}
Explicación:
handleSelecttiene dependencias vacías porquesetSelectedIdsiempre es estable (React garantiza esto para los setters deuseState).- Cuando escribes en el campo de notas,
OrderListse renderiza de nuevo. Sin embargo, cadaOrderRowrecibe el mismoorder, el mismohandleSelect, el mismoonRender, así quememolos omite. - Si cambiaras
handleSelecta una arrow function normal, cada fila se renderizaría nuevamente cada vez que escribas.
Punto importante: useCallback sin memo en el receptor es prácticamente inútil. Pasar un callback memoizado a un elemento <button> del DOM no ahorra nada.
4.2. Función usada como dependencia
function useOrderPolling(customerId) {
const [orders, setOrders] = useState([]);
const refresh = useCallback(async () => {
const data = await fetchOrders({ customerId });
setOrders(data);
}, [customerId]);
useEffect(() => {
refresh();
const id = setInterval(refresh, 10_000);
return () => clearInterval(id);
}, [refresh]);
return { orders, refresh };
}
refresh se usa tanto dentro del effect como se devuelve al exterior. Gracias a useCallback, el intervalo solo se crea nuevamente cuando customerId cambia. Cualquier component que use refresh como dependencia tampoco se ejecutará innecesariamente.
Esta es una regla a guardar cuando escribes custom hooks: las funciones que devuelves hacia afuera deben envolverse con useCallback. No sabes de antemano si quien usa el hook la pondrá en una dependencia o la pasará a memo.
5. Verificación mediante tests
En lugar de asumir que memo “probablemente funciona”, puedes escribir un test para ello. Instala Vitest y React Testing Library:
npm install -D vitest jsdom @testing-library/react
Configura el entorno jsdom en vite.config.js:
export default {
test: { environment: 'jsdom' },
};
Test para OrderList de la sección 4.1:
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';
const orders = [
{ id: 1, customer: 'Lan' },
{ id: 2, customer: 'Minh' },
];
describe('OrderList', () => {
it('no renderiza de nuevo OrderRow al solo escribir en notas', () => {
const onRender = vi.fn();
render(<OrderList orders={orders} onRender={onRender} />);
expect(onRender).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('note'), { target: { value: 'entrega rápida' } });
expect(onRender).toHaveBeenCalledTimes(2);
});
it('aún actualiza al seleccionar un pedido', () => {
render(<OrderList orders={orders} />);
fireEvent.click(screen.getByText('Minh'));
expect(screen.getByText('Seleccionado: 2')).toBeTruthy();
});
});
✓ OrderList > no renderiza de nuevo OrderRow al solo escribir en notas
✓ OrderList > aún actualiza al seleccionar un pedido
Intenta eliminar useCallback y ejecuta de nuevo. El primer test fallará con expected 4 calls, received 2… mejor dicho, recibirá 4 llamadas en lugar de 2. Esa es una prueba clara de que el hook está haciendo su trabajo. Declara onRender fuera y pasa la misma referencia, de lo contrario este prop mismo destruirá memo.
Además de los tests, deberías usar la pestaña Profiler de React DevTools con la opción “Highlight updates” para ver visualmente qué components se renderizaron nuevamente.
6. Cuándo NO deberías usarlos
La memoización no es gratis. React debe guardar el valor anterior, guardar el array de dependencias y compararlas cada vez que se renderiza. El código también se vuelve más difícil de leer. Deberías evitarlos en estos casos:
- Cálculos baratos: sumar unos números, concatenar strings,
mapsobre pocas docenas de elementos. - Valores primitivos:
useMemo(() => a + b, [a, b])devuelve un número. Los números ya se comparan por valor, así que memo no ayuda. - Callback pasado a un elemento del DOM o a un component hijo sin
memo. - El component hijo aún recibe props que cambian cada vez. Por ejemplo
<OrderRow order={{ ...o }} />o pasarchildrencomo JSX. JSX también es un objeto nuevo cada vez que se renderiza, así quememosiempre verá que es diferente. - Podrías cambiar la estructura en lugar de hacer memo. Mover el estado más cerca de donde se usa, o pasar un component pesado a través de
children, generalmente elimina renders innecesarios más efectivamente.
Nota: No confíes en
useMemopara garantizar la lógica correcta. React se reserva el derecho de borrar el cache, por ejemplo en algunas situaciones con Suspense o en el futuro. Tu código debe funcionar correctamente incluso sin memo. Este hook es solo una optimización de rendimiento. De la misma manera, no pongas efectos secundarios como llamadas a API o escribir enlocalStoragedentro deuseMemo. Su lugar es enuseEffecto en manejadores de eventos.
El error más común es olvidar dependencias, lo que lleva a closures antiguas:
const handleSave = useCallback(() => {
saveOrder({ ...order, note });
}, [order]); // falta `note`
handleSave siempre usará el valor note de la primera vez que se creó. Activa la regla react-hooks/exhaustive-deps de eslint-plugin-react-hooks y no suprimas sus advertencias.
7. ¿Qué cambia React Compiler?
React Compiler, que se estabilizó a finales de 2025, analiza los components en tiempo de build e inyecta memoización automáticamente de forma más granular que lo que normalmente escribirías a mano. Para probar en un proyecto Vite o Babel:
npm install -D babel-plugin-react-compiler
Cuando el compiler está activado, prácticamente no necesitarás escribir useMemo, useCallback o memo. Los hooks antiguos aún funcionan. Son útiles cuando necesitas controlar precisamente una dependencia de un effect. El compiler solo puede optimizar código que sigue las reglas de React: components puros, sin modificar props ni state directamente. Por eso seguir entendiendo estos dos hooks es importante para leer código antiguo y depurar cuando el compiler omite un component.
8. Conclusión
useMemo y useCallback resuelven el mismo problema: mantener referencias estables entre renders. Realmente vale la pena usarlos en tres situaciones:
- El cálculo se mide y es costoso.
- El valor o función se pasa a un component envuelto en
React.memo. - El valor o función está en la dependencia de
useEffectu otro hook, incluyendo funciones que devuelve un custom hook.
Fuera de estos casos, mantén el código simple. Mide con Profiler, verifica con tests, y considera activar React Compiler para que la herramienta haga este trabajo por ti.