Rendimiento en React: patrones que marcan la diferencia
Técnicas prácticas de rendimiento en React: memoización, virtualización, división de código y perfilado, con comparativas reales y sus compromisos.

El rendimiento es una característica
Las interfaces lentas cuestan dinero. Un retraso de 100ms en el tiempo de carga puede reducir las tasas de conversión en un 1%. Una experiencia de scroll con tirones aleja a los usuarios. El rendimiento no es un extra opcional: es un requisito del producto.
Pero optimizar mal desperdicia tiempo de ingeniería y añade complejidad sin ninguna mejora medible. Esta guía se centra en los cambios que realmente marcan la diferencia.
Paso 0: perfila antes de optimizar
Toda mejora de rendimiento debe empezar con una medición. El Profiler de React DevTools y la pestaña Performance de Chrome son tus mejores aliados.
// Enable profiling in production builds
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true, // React 19+
},
productionBrowserSourceMaps: true,
};Graba una traza de rendimiento, identifica los componentes con los tiempos de renderizado más largos y corrige esos primero. Optimizar un componente que se renderiza en 2ms es esfuerzo desperdiciado.
Patrón 1: memoización — úsala con precisión quirúrgica
React.memo, useMemo y useCallback tienen un costo: la función de comparación se ejecuta en cada renderizado. La memoización solo gana cuando el componente es costoso Y las props son estables.
// ❌ Over-memoizing cheap components — adds overhead, no benefit
const SimpleLabel = React.memo(({ text }: { text: string }) => (
<span>{text}</span>
));
// ✅ Memoize expensive computations
const DataGrid = React.memo(({ rows, columns }: DataGridProps) => {
return <VirtualizedTable rows={rows} columns={columns} />;
}, (prev, next) => {
// Custom comparison — shallow equality isn't enough for arrays
return prev.rows === next.rows && prev.columns === next.columns;
});
// ✅ Stabilize callbacks passed to memoized children
function Dashboard({ userId }: { userId: string }) {
const handleRowClick = useCallback((rowId: string) => {
router.push(`/items/${rowId}`);
}, []); // No dependencies — stable across renders
return <DataGrid rows={rows} onRowClick={handleRowClick} />;
}Regla general: memoiza solo cuando puedas medir la mejora.
Patrón 2: virtualización para listas largas
Renderizar 10,000 nodos del DOM es la forma más rápida de destruir el rendimiento. La virtualización renderiza solo los elementos visibles.
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64, // estimated row height in px
overscan: 5, // render 5 extra items above/below viewport
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
transform: `translateY(${virtualItem.start}px)`,
width: "100%",
height: `${virtualItem.size}px`,
}}
>
<ListRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}Con 10,000 elementos, el renderizado pasa de ~8 segundos a ~50ms. Esto no es una mejora marginal: es transformador.
Patrón 3: división de código y carga diferida
Cada kilobyte de JavaScript bloquea el renderizado. Divide tu bundle por rutas y por límites de componentes.
import { lazy, Suspense } from "react";
// Route-level splitting — Next.js handles this automatically
// Component-level splitting for heavy, conditionally rendered components
const RichTextEditor = lazy(() => import("@/components/RichTextEditor"));
const DataVisualization = lazy(() => import("@/components/DataVisualization"));
function ArticleEditor({ article }: { article: Article }) {
const [showPreview, setShowPreview] = useState(false);
return (
<div>
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor content={article.content} />
</Suspense>
{showPreview && (
<Suspense fallback={<div>Loading preview...</div>}>
<DataVisualization data={article.metrics} />
</Suspense>
)}
</div>
);
}Combinado con next/dynamic, esto te da un control muy preciso sobre lo que se incluye en el bundle inicial.
Patrón 4: colocación del estado
El estado global provoca re-renderizados globales. La solución suele ser arquitectónica, no una simple llamada a la API de React.
// ❌ Storing UI state in a global store — every subscriber re-renders
const useStore = create((set) => ({
isDropdownOpen: false,
toggleDropdown: () => set((s) => ({ isDropdownOpen: !s.isDropdownOpen })),
}));
// ✅ Keep UI state local to the component that owns it
function SearchBar() {
const [isOpen, setIsOpen] = useState(false); // Only SearchBar re-renders
return (
<div>
<input onFocus={() => setIsOpen(true)} onBlur={() => setIsOpen(false)} />
{isOpen && <SearchSuggestions />}
</div>
);
}Pregúntate: "¿Quién necesita realmente conocer este estado?". Si la respuesta es un solo componente, mantenlo local.
Patrón 5: transiciones para actualizaciones no urgentes
El hook useTransition de React 18 marca actualizaciones como no urgentes, manteniendo la interfaz receptiva durante renderizados costosos.
import { useTransition, useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
function handleSearch(value: string) {
setQuery(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: can be interrupted if user types again
setResults(filterLargeDataset(value));
});
}
return (
<div>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultList results={results} />
</div>
);
}El resultado: el campo de entrada se mantiene fluido incluso mientras se calcula la lista de resultados.
Cómo medir el éxito
Antes de publicar cualquier optimización:
| Métrica | Antes | Después | Herramienta |
|---|---|---|---|
| Time to Interactive | 4.2s | 1.8s | Lighthouse |
| Largest Contentful Paint | 3.1s | 1.2s | Chrome DevTools |
| Tiempo de renderizado del componente | 320ms | 18ms | React Profiler |
| Tamaño del bundle (inicial) | 840KB | 280KB | next build |
Si no puedes completar esta tabla, es que aún no has terminado la optimización.
La regla de oro
El trabajo de optimización que llega a producción vale más que el trabajo perfecto que nunca se publica. Empieza por las mejoras más grandes: la virtualización y la división de código suelen ofrecer mejoras de 10x. La memoización es el último 10%.


