Saltar al contenido

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.

3 min de lectura
El logo de React en el centro de flechas que se abanican hacia seis patrones: perfil primero, memoización quirúrgica, virtualización, división de código, colocación y transiciones.

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.

tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
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.

tstypescript
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.

tstypescript
// ❌ 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.

tstypescript
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étricaAntesDespuésHerramienta
Time to Interactive4.2s1.8sLighthouse
Largest Contentful Paint3.1s1.2sChrome DevTools
Tiempo de renderizado del componente320ms18msReact Profiler
Tamaño del bundle (inicial)840KB280KBnext 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%.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX