Saltar al contenido principal
Volver al blog

Optimización de rendimiento en React 19: guía completa

Ray MartínRay Martín
10 min de lectura
Optimización de rendimiento en React 19: guía completa

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:

typescript
// 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

bash
# Instalar el plugin del compilador
npm install -D babel-plugin-react-compiler
typescript
// 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:

bash
npm install -D eslint-plugin-react-compiler
typescript
// 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.

typescript
// 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

typescript
// 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

typescript
// 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:

typescript
// 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:

typescript
// 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

typescript
// 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:

typescript
// 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 useMemo ni useCallback. 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.

bash
# TanStack Virtual es la opción más moderna y flexible
npm install @tanstack/react-virtual
typescript
// 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:

typescript
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 priority solo en imágenes above-the-fold (hero, logo, LCP image). Nunca en imágenes que requieren scroll.
  • Define sizes correctamente para que el navegador cargue la imagen del tamaño adecuado según el viewport.
  • Usa placeholder="blur" con blurDataURL para 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:

bash
# Instalar el analizador de bundle
npm install -D @next/bundle-analyzer
typescript
// 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);
bash
# Ejecutar el análisis
ANALYZE=true npm run build

Esto 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, lodash completo, o editores de texto pesados. Considera alternativas más ligeras como date-fns o importaciones selectivas.
  • Código duplicado: Módulos que aparecen en múltiples chunks. Configura splitChunks para deduplicar.
  • Imports innecesarios: Componentes o utilidades que se importan pero no se usan.

Consejos para tree-shaking efectivo

typescript
// 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 necesitan

React 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

  1. Instala React DevTools: Extensión de Chrome o Firefox.
  2. Abre la pestaña Profiler: En las DevTools del navegador, busca la pestaña "Profiler" de React.
  3. Graba una interacción: Haz clic en "Record", interactúa con tu app, y detén la grabación.
  4. Analiza el flamegraph: Los componentes que tardan más aparecen más anchos. Los colores indican la duración del render.

Profiler programático

typescript
// 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 priority en 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

typescript
// 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

typescript
// 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

typescript
// 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

  1. React Compiler habilitado: Elimina memoización manual innecesaria.
  2. Server Components por defecto: Solo usa "use client" cuando necesites interactividad.
  3. Suspense en secciones independientes: Streaming SSR para carga progresiva.
  4. Dynamic imports para componentes pesados: Code splitting inteligente.
  5. useTransition en actualizaciones costosas: UI responsiva durante procesos largos.
  6. Virtualización en listas largas: Más de 100 elementos = virtualizar.
  7. Imágenes optimizadas: next/image con sizes, priority y placeholder.
  8. Bundle analizado: Sin dependencias grandes innecesarias.
  9. Web Vitals monitorizados: LCP < 2.5s, INP < 200ms, CLS < 0.1.
  10. 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.

Compartir:

Artículos relacionados