Optimización de Rendimiento de React — Perfila, mide e impide re-renderizados innecesarios
Receta
import { memo, useMemo, useCallback, useTransition } from "react";
// Memoiza componente caro
const ExpensiveList = memo(function ExpensiveList({
items,
onSelect,
}: {
items: Item[];
onSelect: (id: string) => void;
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// Padre: estabiliza props pasados a hijos memoizados
function Parent() {
const [items] = useState<Item[]>(initialItems);
const [selected, setSelected] = useState<string | null>(null);
// Referencia de callback estable
const handleSelect = useCallback((id: string) => {
setSelected(id);
}, []);
return <ExpensiveList items={items} onSelect={handleSelect} />;
}Cuándo usarlo: Solo después de que el perfilado revele un problema de rendimiento. La optimización prematura añade complejidad sin ganancia. Usa el Profiler de React DevTools para medir primero, luego aplica memo, useMemo o useCallback donde realmente ayudan.
Ejemplo Práctico
import {
memo,
useMemo,
useCallback,
useState,
useTransition,
useDeferredValue,
} from "react";
// --- Lista virtualizada con filas memoizadas ---
interface Contact {
id: string;
name: string;
email: string;
avatar: string;
}
const ContactRow = memo(function ContactRow({
contact,
isSelected,
onSelect,
}: {
contact: Contact;
isSelected: boolean;
onSelect: (id: string) => void;
}) {
return (
<div
onClick={() => onSelect(contact.id)}
className={`flex items-center gap-3 p-3 cursor-pointer ${
isSelected ? "bg-blue-50 border-l-4 border-blue-500" : "hover:bg-gray-50"
}`}
>
<img
src={contact.avatar}
alt=""
className="w-10 h-10 rounded-full"
loading="lazy"
/>
<div>
<p className="font-medium">{contact.name}</p>
<p className="text-sm text-gray-500">{contact.email}</p>
</div>
</div>
);
});
// --- Búsqueda con valor diferido ---
function ContactList({ contacts }: { contacts: Contact[] }) {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// El filtrado caro solo se vuelve a ejecutar cuando deferredQuery o contacts cambian
const filtered = useMemo(
() =>
contacts.filter(
(c) =>
c.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
c.email.toLowerCase().includes(deferredQuery.toLowerCase())
),
[contacts, deferredQuery]
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
return (
<div className="max-w-md border rounded-lg overflow-hidden">
<div className="p-3 border-b">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar contactos..."
className="w-full px-3 py-2 border rounded"
/>
</div>
<div
className={`max-h-96 overflow-y-auto ${isStale ? "opacity-60" : ""}`}
>
{filtered.length === 0 ? (
<p className="p-4 text-gray-500 text-center">No se encontraron contactos</p>
) : (
filtered.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
isSelected={contact.id === selectedId}
onSelect={handleSelect}
/>
))
)}
</div>
<div className="p-2 border-t text-sm text-gray-400 text-center">
{filtered.length} de {contacts.length} contactos
</div>
</div>
);
}
// --- Transición para cambio de pestaña ---
function TabPanel({ tabs }: { tabs: { id: string; label: string; content: () => JSX.Element }[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const [isPending, startTransition] = useTransition();
const handleTabChange = (tabId: string) => {
startTransition(() => {
setActiveTab(tabId);
});
};
const ActiveContent = tabs.find((t) => t.id === activeTab)?.content;
return (
<div>
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`px-4 py-2 -mb-px ${
activeTab === tab.id
? "border-b-2 border-blue-500 font-medium"
: "text-gray-500"
}`}
>
{tab.label}
</button>
))}
</div>
<div className={`p-4 ${isPending ? "opacity-50 transition-opacity" : ""}`}>
{ActiveContent && <ActiveContent />}
</div>
</div>
);
}Lo que esto demuestra:
memoenContactRowevita re-renderizados cuando los props no han cambiadouseCallbackestabilizahandleSelectpara que no rompamemouseDeferredValuemantiene el input de búsqueda receptivo mientras el filtrado se difiereuseMemocachea la lista filtrada para evitar recomputar en cada pulsación de teclauseTransitionmarca el cambio de pestaña como no urgente, manteniendo las interacciones fluidas
Inmersión Profunda
Cómo Funciona
- React re-renderiza un componente cuando su estado cambia, su padre se re-renderiza, o un contexto que consume cambia.
React.memo(Component)envuelve un componente para que solo se re-renderice cuando sus props cambian (comparación superficial por defecto).useMemo(fn, deps)cachea un valor computado y solo recomputa cuando las dependencias cambian.useCallback(fn, deps)cachea una referencia de función — equivalente auseMemo(() => fn, deps).useDeferredValue(value)devuelve una copia diferida que se atrasa durante actualizaciones urgentes, permitiendo que el input permanezca receptivo.useTransitionenvuelve actualizaciones de estado como no urgentes, permitiendo que React interrumpa el renderizado si llega una actualización más urgente.- El compilador de React (experimental) puede memoizar automáticamente componentes y hooks, potencialmente haciendo innecesario el
memo/useMemo/useCallbackmanual.
Parámetros y Valores de Retorno
| API | Input | Output | Comportamiento de Re-renderizado |
|---|---|---|---|
memo(Component, areEqual?) | Componente, comparador opcional | Componente memoizado | Evita renderizado si props son superficialmente iguales |
useMemo(fn, deps) | Función factory, array de dependencias | Valor cacheado de tipo T | Recomputa solo cuando las deps cambian |
useCallback(fn, deps) | Función, array de dependencias | Referencia de función estable | Nueva referencia solo cuando las deps cambian |
useDeferredValue(value) | Cualquier valor | Copia diferida del valor | Se actualiza con menor prioridad |
useTransition() | Ninguno | [isPending, startTransition] | Marca actualizaciones como interrumpibles |
Variaciones
Comparación personalizada para memo:
const Chart = memo(
function Chart({ data, config }: ChartProps) {
// Renderizado caro
return <canvas />;
},
(prev, next) => {
// Solo re-renderiza si la longitud de data o config cambian
return (
prev.data.length === next.data.length &&
prev.config.type === next.config.type
);
}
);Perfilado con React DevTools:
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.table({ id, phase, actualDuration, baseDuration });
};
function App() {
return (
<Profiler id="ContactList" onRender={onRender}>
<ContactList contacts={contacts} />
</Profiler>
);
}Evitar re-renderizados con el patrón children:
// Esto hace que SearchResults se re-renderice cuando count cambia:
function BadParent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<SearchResults /> {/* Se re-renderiza innecesariamente */}
</div>
);
}
// Solución: eleva el estado a un componente más pequeño
function GoodParent() {
return (
<div>
<Counter /> {/* count state delimitado aquí */}
<SearchResults /> {/* Ya no se re-renderiza */}
</div>
);
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}Notas TypeScript
memopreserva los tipos de prop del componente. Los componentes genéricos requieren un wrapper:memo(Component) as typeof Component.useMemo<T>yuseCallbackinfieren tipos de la función factory. Las anotaciones de tipo explícito raramente son necesarias.- La función
areEqualenmemorecibe(prevProps, nextProps)y debe devolvertruepara evitar re-renderizado.
Trampas
-
Optimización prematura — Añadir
memo,useMemoyuseCallbackpor todas partes añade complejidad y puede ralentizar componentes rápidos debido a sobrecarga de comparación. Solución: Perfila primero con React DevTools. Solo optimiza componentes que muestren costo de re-renderizado medible. -
Referencias de prop inestables que rompen memo — Pasar
onClick={() => {}}ostyle={{ color: "red" }}inline crea nuevas referencias cada renderizado, anulandomemo. Solución: UsauseCallbackpara funciones yuseMemopara objetos/arrays pasados a hijos memoizados. -
Falta de dependencia en useMemo/useCallback — Omitir una dependencia lleva a cierres obsoletos. Solución: Incluye todos los valores referenciados en el array de dependencias. Usa la regla ESLint
react-hooks/exhaustive-deps. -
Memoizar computaciones baratas —
useMemopara concatenación de cadenas simple cuesta más que simplemente recomputarla. Solución: Solo memoiza computaciones que sean realmente caras (filtrado de arrays grandes, cálculos complejos, crear objetos para contexto). -
Prop key reseteando memo — Cambiar la
keyde un componente hace que React lo desmonte y remonte, sin importarmemo. Solución: Usa claves estables (IDs, no índices de array) a menos que quieras intencionalmente un reseteo. -
Árboles de componentes grandes sin virtualización — Memoizar 10,000 elementos de lista es más lento que virtualizar para renderizar solo elementos visibles. Solución: Usa
@tanstack/react-virtual,react-window, o CSScontent-visibility: autopara listas grandes.
Alternativas
| Enfoque | Compensación |
|---|---|
React.memo + useCallback | Manual pero preciso; boilerplate |
| React Compiler (experimental) | Memoización automática; experimental, no listo para producción |
| Virtualización | Renderiza solo elementos visibles; comportamiento de scrolling más complejo |
| State colocation | Mueve estado más cerca de donde se usa; solución más simple |
useDeferredValue | Mantiene la UI receptiva; muestra datos anticuados brevemente |
CSS content-visibility | Renderizado lazy nativo del navegador; no se necesita API de React |
| Web Workers | Descarga computación; transferencia de datos compleja |
FAQs
¿Cuándo deberías añadir React.memo, useMemo o useCallback a un componente?
- Solo después de que el perfilado con React DevTools Profiler revele un costo de re-renderizado medible.
- La optimización prematura añade complejidad y sobrecarga de comparación que puede ralentizar componentes rápidos.
- Enfócate en componentes que rendericen listas grandes, computaciones caras, o árboles profundos.
¿Qué provoca que un componente de React se re-renderice?
- Su propio estado cambia vía
useStateouseReducer. - Su padre se re-renderiza (incluso si no hay cambios en props, a menos que esté envuelto en
memo). - Un contexto que consume cambia.
¿Cómo mantiene useDeferredValue la UI receptiva durante filtrado caro?
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const filtered = useMemo(
() => contacts.filter((c) => c.name.includes(deferredQuery)),
[contacts, deferredQuery]
);- El input se actualiza inmediatamente con
query, perodeferredQueryse atrasa. - El filtrado caro se ejecuta en el valor diferido, por lo que escribir permanece receptivo.
isStalepuede usarse para mostrar un indicador visual (p. ej., opacidad reducida) mientras el filtrado se pone al día.
¿Cuál es la diferencia entre useTransition y useDeferredValue?
useTransitionenvuelve una actualización de estado para marcarla como no urgente, manteniendo la UI actual visible.useDeferredValuecrea una copia diferida de un valor que se actualiza con menor prioridad.- Usa
useTransitioncuando controlas la actualización de estado. UsauseDeferredValuecuando recibes un valor de props.
Trampa: ¿Por qué pasar objetos o funciones inline como props rompe React.memo?
- Inline
onClick={() => {}}ostyle={{ color: "red" }}crea una nueva referencia cada renderizado. React.memocompara props por referencia, por lo que ve un "cambio" y se re-renderiza.- Solución: usa
useCallbackpara funciones yuseMemopara objetos/arrays pasados a hijos memoizados.
Trampa: ¿Por qué memoizar computaciones baratas es realmente más lento?
useMemotiene sobrecarga: almacena el valor anterior, compara dependencias y gestiona memoria.- Para concatenación de cadenas simple o aritmética básica, recomputar es más rápido que la sobrecarga de memoización.
- Solo memoiza computaciones que sean realmente caras (filtrado de arrays grandes, cálculos complejos).
¿Cómo evita el patrón children-as-props re-renderizados innecesarios del padre?
// Malo: SearchResults se re-renderiza cuando count cambia
function BadParent() {
const [count, setCount] = useState(0);
return <div><button onClick={() => setCount(c => c + 1)}>{count}</button><SearchResults /></div>;
}
// Bueno: mueve estado a un componente más pequeño
function GoodParent() {
return <div><Counter /><SearchResults /></div>;
}- Mover estado a un componente más pequeño delimita re-renderizados solo a ese componente.
SearchResultsya no se re-renderiza porque su padre (GoodParent) no posee el estado que está cambiando.
¿Cómo interactúa memo con componentes genéricos en TypeScript?
memo(Component)puede perder parámetros de tipo genérico.- Solución: convierte el resultado:
memo(Component) as typeof Component. - Esto preserva la firma genérica para los consumidores.
¿Qué hace la función areEqual en React.memo en TypeScript?
const Chart = memo(
function Chart({ data, config }: ChartProps) { return <canvas />; },
(prev, next) => prev.data.length === next.data.length && prev.config.type === next.config.type
);- Recibe
(prevProps, nextProps)y devuelvetruepara evitar re-renderizado,falsepara re-renderizar. - Úsalo para lógica de comparación personalizada cuando la igualdad superficial no es suficiente.
¿Cuándo deberías usar virtualización en lugar de memoización para listas grandes?
- Cuando tienes miles de elementos, memoizar cada uno es más lento que renderizar solo elementos visibles.
- Bibliotecas como
@tanstack/react-virtualyreact-windowrenderizan solo elementos en el viewport. - CSS
content-visibility: autoproporciona renderizado lazy nativo del navegador sin biblioteca de React.
¿Qué es el Compilador de React y cómo se relaciona con la memoización manual?
- El Compilador de React (experimental) puede memoizar automáticamente componentes y hooks en tiempo de compilación.
- Podría hacer que el
memo,useMemoyuseCallbackmanual sean innecesarios. - Aún no está listo para producción, por lo que la memoización manual sigue siendo el enfoque estándar.
Relacionado
- Context Patterns — División de contexto para reducir re-renderizados
- Suspense —
useTransitionyuseDeferredValuepara renderizado asincrónico - Composition — Patrón children-as-props evita re-renderizados del padre