Rendimiento en React 19: una nueva era
React 19 representa un salto significativo en rendimiento. Con la introducción del React Compiler, mejoras en Suspense, Server Components maduros y nuevos hooks como useTransition y useOptimistic, muchas de las optimizaciones que antes requerían intervención manual ahora ocurren de forma automática. Pero entender estas herramientas y saber cuándo aplicarlas sigue siendo fundamental para construir aplicaciones rápidas.
Esta guía cubre las técnicas de optimización de rendimiento más importantes en React 19, desde las características del framework hasta las métricas Web Vitals que necesitas monitorear en producción.
React Compiler: memoización automática
El React Compiler (anteriormente conocido como React Forget) es la mejora de rendimiento más revolucionaria de React 19. Es un compilador que analiza tu código en build time y aplica automáticamente memoización donde sea necesario, eliminando la necesidad de React.memo, useMemo y useCallback manuales.
Cómo funciona el compilador
El compilador analiza tu código y aplica las reglas de React para determinar qué valores pueden cambiar y cuáles son estables entre renders. Luego inserta automáticamente las instrucciones de memoización equivalentes:
// Lo que TÚ escribes (React 19 con el compilador)
function ProductList({ products, category }: {
products: Product[];
category: string;
}) {
const filtered = products.filter((p) => p.category === category);
const sorted = filtered.sort((a, b) => a.price - b.price);
return (
<ul>
{sorted.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={() => addToCart(product.id)}
/>
))}
</ul>
);
}
// Lo que el COMPILADOR genera internamente (equivalente a):
function ProductList({ products, category }: {
products: Product[];
category: string;
}) {
const filtered = useMemo(
() => products.filter((p) => p.category === category),
[products, category]
);
const sorted = useMemo(
() => [...filtered].sort((a, b) => a.price - b.price),
[filtered]
);
const onAddToCart = useCallback(
(id: string) => addToCart(id),
[]
);
return (
<ul>
{sorted.map((product) => (
<MemoizedProductCard
key={product.id}
product={product}
onAddToCart={() => onAddToCart(product.id)}
/>
))}
</ul>
);
}Habilitar el compilador en Next.js
# Instalar el plugin del compilador
npm install -D babel-plugin-react-compiler// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
reactCompiler: true,
},
};
export default nextConfig;
El compilador funciona mejor cuando tu código sigue las reglas de React: componentes puros, sin mutaciones durante el render y sin efectos secundarios fuera de useEffect. Si el compilador detecta código que viola estas reglas, simplemente lo omite sin causar errores.
Validación con ESLint
Instala el plugin de ESLint para verificar que tu código es compatible con el compilador:
npm install -D eslint-plugin-react-compiler// eslint.config.mjs
import reactCompiler from "eslint-plugin-react-compiler";
export default [
{
plugins: {
"react-compiler": reactCompiler,
},
rules: {
"react-compiler/react-compiler": "error",
},
},
];useTransition: actualizaciones no bloqueantes
useTransition permite marcar actualizaciones de estado como transiciones de baja prioridad. Esto significa que la UI sigue respondiendo a las interacciones del usuario mientras React procesa la actualización en segundo plano.
// components/SearchWithTransition.tsx
"use client";
import { useState, useTransition } from "react";
interface SearchResult {
id: string;
title: string;
description: string;
}
export function SearchWithTransition() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [isPending, startTransition] = useTransition();
function handleSearch(value: string) {
// Actualización urgente: actualizar el input inmediatamente
setQuery(value);
// Transición: la búsqueda se procesa sin bloquear el input
startTransition(async () => {
const response = await fetch(`/api/search?q=${value}`);
const data = await response.json();
setResults(data.results);
});
}
return (
<div>
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Buscar proyectos..."
className="w-full rounded-lg border px-4 py-2"
aria-label="Buscar proyectos"
/>
{isPending && (
<div className="mt-2 text-sm text-gray-500" role="status">
Buscando...
</div>
)}
<ul className={isPending ? "opacity-60" : ""} role="list">
{results.map((result) => (
<li key={result.id} className="border-b py-3">
<h3 className="font-semibold">{result.title}</h3>
<p className="text-gray-600">{result.description}</p>
</li>
))}
</ul>
</div>
);
}
La diferencia clave es que sin useTransition, escribir en el input podría sentirse lento si la búsqueda es costosa, porque React intentaría procesar ambas actualizaciones con la misma prioridad. Con la transición, el input responde instantáneamente mientras los resultados se actualizan cuando están listos.
useTransition para navegación
// components/TabNavigation.tsx
"use client";
import { useState, useTransition } from "react";
const tabs = ["overview", "analytics", "settings"] as const;
type Tab = (typeof tabs)[number];
export function TabNavigation() {
const [activeTab, setActiveTab] = useState<Tab>("overview");
const [isPending, startTransition] = useTransition();
function handleTabChange(tab: Tab) {
startTransition(() => {
setActiveTab(tab);
});
}
return (
<div>
<nav role="tablist" className="flex gap-2 border-b">
{tabs.map((tab) => (
<button
key={tab}
role="tab"
aria-selected={activeTab === tab}
onClick={() => handleTabChange(tab)}
className={
activeTab === tab
? "border-b-2 border-primary px-4 py-2 font-semibold"
: "px-4 py-2 text-gray-500 hover:text-gray-700"
}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</nav>
<div className={isPending ? "opacity-50 transition-opacity" : ""}>
{activeTab === "overview" && <OverviewPanel />}
{activeTab === "analytics" && <AnalyticsPanel />}
{activeTab === "settings" && <SettingsPanel />}
</div>
</div>
);
}Suspense para data fetching y code splitting
Suspense en React 19 es mucho más potente que en versiones anteriores. Ahora soporta nativamente data fetching en Server Components, streaming SSR y code splitting declarativo.
Suspense con Server Components
// app/dashboard/page.tsx
import { Suspense } from "react";
import { DashboardSkeleton } from "@/components/skeletons/DashboardSkeleton";
export default function DashboardPage() {
return (
<main>
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Cada sección carga independientemente */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<Suspense fallback={<StatsSkeleton />}>
<StatsCards />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</main>
);
}
// Cada componente es un Server Component async que hace su propio fetch
async function StatsCards() {
const stats = await fetchStats(); // Se suspende automáticamente
return (
<div className="grid grid-cols-3 gap-4">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border p-4">
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
))}
</div>
);
}
async function RevenueChart() {
const revenue = await fetchRevenue(); // Datos independientes
return <Chart data={revenue} />;
}
async function RecentOrders() {
const orders = await fetchRecentOrders();
return <OrdersTable orders={orders} />;
}Con este patrón, cada sección del dashboard se carga de forma independiente. La sección más rápida se muestra primero mientras las demás muestran su skeleton. Esto mejora drásticamente el LCP (Largest Contentful Paint) porque el usuario ve contenido útil mucho antes.
Streaming SSR
Suspense habilita streaming SSR automáticamente en Next.js. El servidor envía el HTML del shell inmediatamente y luego transmite cada sección Suspense conforme se resuelve:
// app/[locale]/layout.tsx
// El layout se envía inmediatamente como HTML
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<header>{/* Se renderiza inmediatamente */}</header>
{children} {/* Suspense boundaries se resuelven incrementalmente */}
<footer>{/* Se renderiza inmediatamente */}</footer>
</body>
</html>
);
}React.lazy y dynamic imports
El code splitting permite cargar componentes solo cuando se necesitan, reduciendo el tamaño del bundle inicial. En Next.js, usamos next/dynamic que extiende React.lazy con soporte para SSR:
// Componente pesado que solo se necesita condicionalmente
import dynamic from "next/dynamic";
// Cargar solo en el cliente (sin SSR)
const HeavyEditor = dynamic(
() => import("@/components/Editor"),
{
ssr: false,
loading: () => (
<div className="h-64 animate-pulse rounded-lg bg-gray-100" />
),
}
);
// Cargar con SSR pero de forma lazy
const Chart = dynamic(() => import("@/components/Chart"), {
loading: () => <ChartSkeleton />,
});
// Cargar componentes de una librería pesada solo cuando se necesitan
const CodeEditor = dynamic(
() => import("@monaco-editor/react").then((mod) => mod.default),
{
ssr: false,
loading: () => (
<div className="flex h-96 items-center justify-center rounded-lg border">
<p className="text-gray-500">Cargando editor...</p>
</div>
),
}
);Carga condicional basada en interacción
// components/ContactSection.tsx
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
// El modal de contacto solo se carga cuando el usuario lo necesita
const ContactModal = dynamic(
() => import("@/components/common/ContactModal"),
{
ssr: false,
loading: () => null,
}
);
export function ContactSection() {
const [showModal, setShowModal] = useState(false);
return (
<section>
<button
onClick={() => setShowModal(true)}
className="rounded-lg bg-primary px-6 py-3 text-white"
>
Contactar
</button>
{showModal && (
<ContactModal onClose={() => setShowModal(false)} />
)}
</section>
);
}Cuándo usar useMemo y useCallback manualmente
Con el React Compiler, la necesidad de memoización manual se reduce drásticamente. Sin embargo, si tu proyecto aún no usa el compilador, o si necesitas control explícito, estos hooks siguen siendo útiles:
// SIN compilador: memoización manual necesaria
"use client";
import { useMemo, useCallback, useState } from "react";
interface DataTableProps {
data: Record<string, unknown>[];
columns: string[];
}
export function DataTable({ data, columns }: DataTableProps) {
const [sortKey, setSortKey] = useState("");
const [filterText, setFilterText] = useState("");
// useMemo: evitar recalcular en cada render
const filteredData = useMemo(() => {
return data.filter((row) =>
columns.some((col) =>
String(row[col]).toLowerCase().includes(filterText.toLowerCase())
)
);
}, [data, columns, filterText]);
const sortedData = useMemo(() => {
if (!sortKey) return filteredData;
return [...filteredData].sort((a, b) =>
String(a[sortKey]).localeCompare(String(b[sortKey]))
);
}, [filteredData, sortKey]);
// useCallback: estabilizar referencia para componentes hijo
const handleSort = useCallback((key: string) => {
setSortKey(key);
}, []);
const handleFilter = useCallback((text: string) => {
setFilterText(text);
}, []);
return (
<div>
<SearchInput onFilter={handleFilter} />
<Table
data={sortedData}
columns={columns}
onSort={handleSort}
/>
</div>
);
}Regla práctica: Si usas el React Compiler, no necesitas
useMemoniuseCallback. El compilador los inserta automáticamente donde sea beneficioso. Si no usas el compilador, aplícalos en cálculos costosos y callbacks pasados a componentes memoizados.
Virtualización de listas largas
Cuando necesitas renderizar miles de elementos (tablas de datos, feeds, logs), la virtualización es esencial. Solo renderiza los elementos que están visibles en el viewport, manteniendo el DOM liviano.
# TanStack Virtual es la opción más moderna y flexible
npm install @tanstack/react-virtual// components/VirtualList.tsx
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface VirtualListProps {
items: { id: string; title: string; description: string }[];
}
export function VirtualList({ items }: VirtualListProps) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // altura estimada de cada elemento
overscan: 5, // elementos extra arriba y abajo del viewport
});
return (
<div
ref={parentRef}
className="h-[600px] overflow-auto rounded-lg border"
role="list"
aria-label="Lista de elementos"
>
<div
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={item.id}
role="listitem"
className="absolute left-0 right-0 border-b px-4 py-3"
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
);
})}
</div>
</div>
);
}Con 10.000 elementos, sin virtualización React renderizaría 10.000 nodos DOM. Con virtualización, solo renderiza los ~15-20 elementos visibles más el overscan. La diferencia en rendimiento es enorme: de segundos a milisegundos.
Optimización de imágenes con next/image
Las imágenes suelen ser el recurso más pesado de una página web. El componente next/image de Next.js optimiza automáticamente las imágenes con lazy loading, formatos modernos (WebP/AVIF), y responsive sizing:
import Image from "next/image";
// Imagen above-the-fold: prioridad alta, sin lazy loading
export function HeroBanner() {
return (
<section className="relative h-screen">
<Image
src="/images/hero-banner.jpg"
alt="Descripción del banner principal"
fill
priority
fetchPriority="high"
sizes="100vw"
className="object-cover"
quality={85}
/>
</section>
);
}
// Imagen below-the-fold: lazy loading automático
export function ProjectCard({ project }: { project: Project }) {
return (
<div className="overflow-hidden rounded-xl">
<Image
src={project.image}
alt={project.title}
width={640}
height={360}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="transition-transform hover:scale-105"
placeholder="blur"
blurDataURL={project.blurHash}
/>
</div>
);
}Buenas prácticas para imágenes
- Usa
prioritysolo en imágenes above-the-fold (hero, logo, LCP image). Nunca en imágenes que requieren scroll. - Define
sizescorrectamente para que el navegador cargue la imagen del tamaño adecuado según el viewport. - Usa
placeholder="blur"conblurDataURLpara evitar layout shift (CLS) mientras la imagen carga. - Prefiere formatos modernos: Next.js sirve WebP o AVIF automáticamente si el navegador los soporta.
- Comprime antes de subir: Aunque Next.js optimiza, empezar con imágenes más pequeñas mejora los tiempos de build.
Análisis de bundle y tree-shaking
Entender qué código termina en tu bundle del cliente es fundamental para optimizar tiempos de carga. Next.js ofrece herramientas integradas y hay paquetes adicionales para un análisis más profundo:
# Instalar el analizador de bundle
npm install -D @next/bundle-analyzer// next.config.ts
import type { NextConfig } from "next";
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig: NextConfig = {
// ...tu configuración
};
const analyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
export default analyzer(nextConfig);# Ejecutar el análisis
ANALYZE=true npm run buildEsto genera un reporte visual interactivo que muestra cada módulo en tu bundle y su tamaño. Busca:
- Dependencias grandes: Librerías como
moment.js,lodashcompleto, o editores de texto pesados. Considera alternativas más ligeras comodate-fnso importaciones selectivas. - Código duplicado: Módulos que aparecen en múltiples chunks. Configura
splitChunkspara deduplicar. - Imports innecesarios: Componentes o utilidades que se importan pero no se usan.
Consejos para tree-shaking efectivo
// MAL: importa toda la librería (100KB+)
import _ from "lodash";
const sorted = _.sortBy(items, "name");
// BIEN: importa solo la función necesaria (4KB)
import sortBy from "lodash/sortBy";
const sorted = sortBy(items, "name");
// MAL: importa todos los iconos (200KB+)
import * as Icons from "@tabler/icons-react";
// BIEN: importa solo los iconos que necesitas
import { IconHome, IconUser, IconSettings } from "@tabler/icons-react";
// BIEN: usar barrel exports con re-exports nombrados
// utils/index.ts
export { cn } from "./classNames";
export { formatDate } from "./dates";
// No re-exportar módulos que no todos los consumidores necesitanReact DevTools Profiler
El Profiler de React DevTools es la herramienta principal para identificar cuellos de botella de rendimiento. Te muestra qué componentes se re-renderizan, cuánto tiempo toma cada render y por qué ocurrió.
Cómo usar el Profiler
- Instala React DevTools: Extensión de Chrome o Firefox.
- Abre la pestaña Profiler: En las DevTools del navegador, busca la pestaña "Profiler" de React.
- Graba una interacción: Haz clic en "Record", interactúa con tu app, y detén la grabación.
- Analiza el flamegraph: Los componentes que tardan más aparecen más anchos. Los colores indican la duración del render.
Profiler programático
// Usar el componente Profiler para medir renders en producción
import { Profiler, ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
// Enviar métricas a tu servicio de analytics
if (actualDuration > 16) {
// Más de un frame (16ms)
console.warn(
`Render lento en "${id}": ${actualDuration.toFixed(2)}ms (fase: ${phase})`
);
}
};
export function MonitoredDashboard() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard />
</Profiler>
);
}Web Vitals: medir y mejorar
Las Core Web Vitals son las métricas que Google usa para evaluar la experiencia de usuario de tu sitio. Afectan directamente al SEO y a la percepción de rendimiento del usuario.
Métricas clave
- LCP (Largest Contentful Paint): Tiempo hasta que el contenido principal es visible. Objetivo: < 2.5 segundos. Mejora con
priorityen imágenes hero, font preloading, y Server Components. - INP (Interaction to Next Paint): Tiempo de respuesta a interacciones del usuario (reemplaza FID). Objetivo: < 200ms. Mejora con
useTransition, code splitting y reduciendo JavaScript en el cliente. - CLS (Cumulative Layout Shift): Estabilidad visual del layout. Objetivo: < 0.1. Mejora con dimensiones explícitas en imágenes, font fallbacks y skeletons con tamaño fijo.
Medir Web Vitals en Next.js
// app/components/WebVitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
// Enviar a Google Analytics
window.gtag?.("event", metric.name, {
value: Math.round(
metric.name === "CLS" ? metric.value * 1000 : metric.value
),
event_label: metric.id,
non_interaction: true,
});
// Log en desarrollo
if (process.env.NODE_ENV === "development") {
console.log(`${metric.name}: ${metric.value.toFixed(2)}`);
}
});
return null;
}Mejorar LCP
// Precargar fuentes críticas en el layout
// app/[locale]/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // Mostrar texto inmediatamente con fuente fallback
preload: true,
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html className={inter.className}>
<head>
{/* Preconnect al CDN de imágenes */}
<link rel="preconnect" href="https://images.example.com" />
</head>
<body>{children}</body>
</html>
);
}Mejorar CLS
// Siempre especificar dimensiones en imágenes
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority
/>
// Usar aspect-ratio en contenedores de contenido dinámico
<div className="aspect-video w-full overflow-hidden rounded-lg">
<video src="/demo.mp4" className="h-full w-full object-cover" />
</div>
// Skeletons con dimensiones fijas para evitar layout shift
function CardSkeleton() {
return (
<div className="h-[280px] w-full animate-pulse rounded-xl bg-gray-100">
<div className="h-40 rounded-t-xl bg-gray-200" />
<div className="space-y-2 p-4">
<div className="h-4 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-1/2 rounded bg-gray-200" />
</div>
</div>
);
}Lista de verificación de rendimiento
- React Compiler habilitado: Elimina memoización manual innecesaria.
- Server Components por defecto: Solo usa
"use client"cuando necesites interactividad. - Suspense en secciones independientes: Streaming SSR para carga progresiva.
- Dynamic imports para componentes pesados: Code splitting inteligente.
- useTransition en actualizaciones costosas: UI responsiva durante procesos largos.
- Virtualización en listas largas: Más de 100 elementos = virtualizar.
- Imágenes optimizadas: next/image con sizes, priority y placeholder.
- Bundle analizado: Sin dependencias grandes innecesarias.
- Web Vitals monitorizados: LCP < 2.5s, INP < 200ms, CLS < 0.1.
- Fuentes optimizadas: next/font con display swap y preload.
Consejo final: El rendimiento no es algo que se optimiza una vez y se olvida. Es un proceso continuo de medición, análisis y mejora. Configura alertas en tus métricas Web Vitals, analiza tu bundle con cada release, y usa el React DevTools Profiler cuando notes regresiones. Con React 19 y las herramientas de Next.js 15, tienes todo lo necesario para construir aplicaciones excepcionalmente rápidas.