← Blog
·11 blog.minutes

La Guía Completa para la Optimización de Rendimiento en React

Desde memo hasta server components: una guía práctica para hacer tu aplicación React más rápida sin sobreingeniería.

La optimización del rendimiento en React tiene un problema de reputación. Pregúntales a diez desarrolladores cómo hacer una aplicación React más rápida, y ocho dirán "useMemo y useCallback para todo". Los dos restantes dirán "la optimización prematura es la raíz de todos los males" y se irán. Ambos grupos están equivocados, y ambos grupos envían aplicaciones que son innecesariamente complejas o innecesariamente lentas.

La realidad es más matizada. El modelo de renderizado de React es eficiente para la mayoría de los casos de fábrica, pero hay patrones bien conocidos donde se queda corto. Esta guía cubre todas las técnicas importantes de optimización de React: cómo funcionan, cuándo ayudan y, críticamente, cuándo perjudican. El objetivo no es hacer que recurras a memo en todas partes. Es darte un modelo mental para el rendimiento que haga obvia la elección correcta en cada situación.

Entendiendo cuándo React re-renderiza

Antes de optimizar nada, necesitas entender qué causa un re-renderizado. React re-renderiza un componente cuando su estado cambia, cuando su padre re-renderiza o cuando un valor de contexto que consume cambia. Suena simple, pero el efecto en cascada es donde se esconden los problemas de rendimiento.

Cuando un componente padre re-renderiza, cada hijo re-renderiza por defecto, incluso si las props del hijo no han cambiado. React hace esto porque no puede saber si el hijo depende del estado del padre sin ejecutar la función de renderizado del hijo. Esto no es un bug, es una decisión de diseño que mantiene el modelo de reconciliación de React simple y predecible. Pero significa que un cambio de estado en un componente de alto nivel puede desencadenar re-renderizados en docenas o cientos de descendientes.

La idea clave es que re-renderizar no es lo mismo que actualizar el DOM. React compara la salida del DOM virtual (el JSX) con la salida anterior y solo confirma las diferencias en el DOM real. Un componente puede re-renderizar mil veces sin una sola mutación del DOM. El costo está en la ejecución de JavaScript: crear objetos del DOM virtual, ejecutar hooks y hacer diff de árboles. Para árboles de componentes pequeños, este costo es despreciable. Para listas grandes, gráficos complejos o componentes que hacen cálculos costosos, se acumula rápidamente.

// Every keystroke in this input re-renders the entire tree
function SearchPage() {
  const [query, setQuery] = useState("");

  return (
    <div>
      <SearchInput value={query} onChange={setQuery} />
      <SearchResults query={query} />
      <Sidebar>
        <FilterPanel />
        <RecentSearches />
      </Sidebar>
    </div>
  );
}

// Without optimization, FilterPanel and RecentSearches
// re-render on every keystroke, even though nothing
// they depend on has changed.

El primer paso en cualquier investigación de rendimiento es identificar si realmente tienes un problema. La optimización prematura añade complejidad sin beneficio medible. Primero perfila, luego optimiza, pero cuando hayas identificado un cuello de botella, las técnicas de esta guía son las herramientas a las que recurres.

Memoización: memo, useMemo y useCallback

La memoización es la técnica de optimización de React más discutida y la más mal aplicada. La idea central es simple: si una función produce la misma salida dadas las mismas entradas, almacena en caché el resultado y salta el cómputo en llamadas subsiguientes. React proporciona tres herramientas de memoización, cada una con un propósito diferente.

React.memo envuelve un componente y evita re-renderizados cuando sus props no han cambiado (usando comparación superficial). Es más efectivo para componentes hoja que reciben las mismas props con frecuencia: piensa en elementos de lista, elementos de gráfico o cualquier componente puro cuya salida de renderizado dependa solo de sus props.

import { memo } from "react";

const ExpenseRow = memo(function ExpenseRow({ label, amount }: {
  label: string;
  amount: number;
}) {
  return (
    <tr>
      <td>{label}</td>
      <td className={amount < 0 ? "text-red-500" : "text-green-500"}>
        ${amount.toFixed(2)}
      </td>
    </tr>
  );
});

// Now ExpenseRow only re-renders when label or amount changes.
// Without memo, it re-renders every time the parent re-renders.

useMemo almacena en caché el resultado de un cómputo entre renderizados. Es útil cuando un componente hace un cálculo costoso en cada renderizado: filtrar un array grande, formatear datos o ejecutar una transformación compleja. Sin useMemo, ese trabajo se repite en cada re-renderizado incluso si las entradas no han cambiado.

function Dashboard({ transactions, filter }: Props) {
  // Without useMemo: this runs on every render
  const visibleTransactions = useMemo(
    () => transactions
      .filter(t => t.date >= filter.start && t.date <= filter.end)
      .sort((a, b) => b.amount - a.amount),
    [transactions, filter]
  );

  // Without useMemo, filtering and sorting 10,000 items
  // happens on every keystroke in any input on the page.
  return <TransactionList items={visibleTransactions} />;
}

useCallback es lo mismo que useMemo pero para funciones. Devuelve una versión memoizada de una función callback que solo cambia cuando sus dependencias cambian. Esto importa porque las funciones definidas en el cuerpo del renderizado se recrean en cada renderizado, lo que rompe la comparación superficial que usa memo.

Aquí está la regla crítica que la mayoría de las guías tienen mal: no envuelvas todo en useMemo y useCallback. Cada llamada de memoización tiene un costo: almacenar el array de dependencias, compararlo en cada renderizado y asignar memoria de cierre. Aplicar memoización a operaciones baratas hace tu aplicación más lenta, no más rápida. La heurística es simple: solo memoiza si has medido un problema, o si el cómputo es obviamente costoso (transformaciones de datos complejas, operaciones recursivas, renderizados de listas grandes).

La memoización más cara es la que no debería haberse escrito. Primero perfila, luego envuelve. Cada abstracción tiene un costo, y la memoización es una abstracción sobre el ciclo de renderizado.

Un antipatrón común es envolver todo en memo defensivamente, esperando prevenir problemas de rendimiento. No lo hace. Añade sobrecarga a cada comparación de renderizado, infla el bundle con código extra y hace que la base de código sea más difícil de razonar. Añade memoización como una optimización dirigida después de que la creación de perfiles confirme que ayuda, no como un patrón predeterminado.

División de código con React.lazy y Suspense

El tamaño del bundle es la dimensión de rendimiento más pasada por alto en las aplicaciones React. Los desarrolladores obsesionados con la optimización del renderizado mientras envían 500 KB de JavaScript que cada usuario debe descargar, analizar y ejecutar antes de ver nada. La división de código resuelve esto dividiendo tu bundle en fragmentos que se cargan bajo demanda.

React.lazy te permite renderizar un componente importado dinámicamente como un componente normal. Combinado con Suspense, puedes mostrar una UI de respaldo mientras el fragmento se carga. La ganancia de rendimiento es doble: el bundle inicial es más pequeño, por lo que la página carga más rápido, y los usuarios solo pagan por el código que realmente usan.

import { lazy, Suspense } from "react";

const AnalyticsDashboard = lazy(
  () => import("./AnalyticsDashboard")
);
const DataExportPanel = lazy(
  () => import("./DataExportPanel")
);

function App() {
  const [showAnalytics, setShowAnalytics] = useState(false);

  return (
    <div>
      <button onClick={() => setShowAnalytics(true)}>
        View Analytics
      </button>
      <Suspense fallback={<div>Loading...</div>}>
        {showAnalytics && <AnalyticsDashboard />}
      </Suspense>
    </div>
  );
}

// AnalyticsDashboard and its dependencies are only loaded
// when the user clicks the button, not on initial page load.

Los mejores candidatos para lazy loading son componentes a nivel de ruta, librerías de visualización pesadas (gráficos, mapas), editores de texto enriquecido y cualquier componente que esté debajo del pliegue o que se active por interacción del usuario. Una buena regla general: si un componente añade más de 20 KB al bundle y no es visible en el renderizado inicial, probablemente debería cargarse de forma lazy.

El comportamiento mejorado de Suspense en React 19 hace que la división de código sea aún más práctica. La obtención de datos dentro de los límites de Suspense ahora está completamente integrada con el ciclo de vida de renderizado, eliminando el problema de cascada donde tenías que cargar el fragmento primero y luego obtener sus datos. Combinado con el nuevo hook use() para leer promesas, los límites entre los estados de carga se vuelven más limpios y más componibles.

Scroll virtual y optimización de listas

Renderizar listas grandes es el problema de rendimiento más común en aplicaciones React reales. Una lista de 10,000 elementos funciona bien en desarrollo y se desmorona en producción porque React debe crear y reconciliar 10,000 nodos del DOM virtual en cada renderizado. El motor de diseño del navegador luego debe calcular posiciones para 10,000 nodos del DOM. El resultado es una UI congelada y un usuario frustrado.

El scroll virtual resuelve esto renderizando solo los elementos que son visibles en el viewport, más un pequeño buffer arriba y abajo. A medida que el usuario se desplaza, los elementos fuera del viewport se desmontan y se montan nuevos elementos. El DOM se mantiene pequeño (típicamente 20-30 nodos independientemente del tamaño de la lista) y el costo de reconciliación de React se mantiene constante.

import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // estimated row height
    overscan: 5, // render 5 extra items off-screen
  });

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              height: virtualItem.size,
              width: "100%",
            }}
          >
            <ItemRenderer item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

// Only ~25-30 rows are rendered regardless of list size.
// 100,000 items? Same render cost.

Más allá del scroll virtual, el rendimiento de las listas depende de claves estables. Usar el índice del array como clave hace que React identifique incorrectamente qué elementos cambiaron, lo que lleva a desmontajes y remontajes innecesarios de nodos del DOM. Usa un identificador único de tus datos (el ID del elemento) como clave. Esto permite que React reutilice nodos del DOM cuando los elementos se reordenan, insertan o eliminan, lo que es significativamente más barato que destruirlos y recrearlos.

  • Usa scroll virtual para cualquier lista que probablemente supere los 200-500 elementos. El umbral depende de la complejidad del elemento, pero 500 filas de complejidad moderada es donde empiezas a notar tirones.
  • Usa siempre claves estables y únicas derivadas de tus datos (IDs de elementos). Las claves de índice son un último recurso para listas estáticas que nunca cambian.
  • Mantén los componentes de los elementos de lista ligeros. Cada elemento debe ser un componente de presentación simple con llamadas de hook mínimas. Los cálculos costosos dentro de los elementos de lista se multiplican en cada fila renderizada.
  • Considera el windowing o la paginación para listas que no necesitan mostrar todos los elementos a la vez. A veces la mejor optimización es no renderizar algo en absoluto.

Creación de perfiles, análisis de bundle y patrones de React 19+

La herramienta de rendimiento más importante no es una librería ni un hook. Es el perfilador de React DevTools. Antes de optimizar nada, graba una sesión de perfilado mientras reproduces el comportamiento lento. El perfilador te muestra exactamente qué componentes se renderizaron, por qué se renderizaron (cambio de prop, cambio de estado, cambio de contexto, re-renderizado del padre) y cuánto tiempo tomó cada renderizado. Sin estos datos, estás adivinando.

El análisis de bundle es la segunda herramienta esencial. Una aplicación React rápida que envía 800 KB de JavaScript sigue siendo lenta porque el navegador debe descargarlo y analizarlo. Herramientas como vite-bundle-visualizer, statoscope o source-map-explorer generan una representación visual de tu bundle, mostrando qué paquetes contribuyen con más bytes. Los resultados suelen ser sorprendentes: una sola dependencia grande (moment.js, una librería de gráficos, un conjunto de iconos) puede representar la mitad de tu bundle.

React 19 introduce patrones que cambian el panorama de la optimización del rendimiento. Los Server Components se ejecutan en el servidor y envían solo el HTML renderizado al cliente, eliminando JavaScript del lado del cliente para componentes que no necesitan interactividad. Esta no es una optimización de nicho: cualquier componente que obtenga datos y los renderice sin estado del lado del cliente o manejadores de eventos debería ser un Server Component. El JavaScript que se habría enviado al cliente es cero.

La distinción entre Server Components y Client Components crea un nuevo modelo mental para el rendimiento. Antes de recurrir a memo, pregúntate: ¿este componente necesita ejecutarse en el cliente? Si la respuesta es no, has eliminado todo costo de re-renderizado, todo costo de hidratación y cada byte de JavaScript que ese componente habría contribuido al bundle. Los Server Components no son un reemplazo de las herramientas de optimización del lado del cliente de React. Son una estrategia de nivel superior que hace que las optimizaciones del lado del cliente sean innecesarias para una gran parte de tu árbol de componentes.

La optimización de imágenes es otra área donde el framework maneja lo que los desarrolladores solían hacer manualmente. Los componentes Image de Next.js sirven automáticamente imágenes responsivas en el tamaño y formato correctos, cargan imágenes lazy debajo del pliegue y evitan el cambio de diseño reservando espacio antes de que la imagen se cargue. Si todavía estás usando etiquetas img simples con JPEGs grandes, este único cambio a menudo produce una mejora de rendimiento mayor que cualquier estrategia de memoización.

  • Ejecuta el perfilador de React DevTools en la interacción más lenta de tu aplicación antes de hacer cualquier cambio. Guarda la grabación como línea de base.
  • Analiza tu bundle con vite-bundle-visualizer o source-map-explorer. Busca dependencias grandes que puedan intercambiarse por alternativas más ligeras.
  • Convierte los componentes de obtención de datos a Server Components si estás usando React 19 con un framework de servidor. Esto elimina todo su costo del lado del cliente.
  • Reemplaza las etiquetas img con el componente Image del framework (next/image, etc.) para imágenes responsivas automáticas y lazy loading.
  • Usa la pestaña de rendimiento en Chrome DevTools para medir el retraso de interacción, los cambios de diseño y las tareas largas, no solo el tiempo de renderizado de React.

Poniéndolo todo junto

La optimización del rendimiento en React sigue una jerarquía clara. En la cima está la arquitectura: Server Components que se ejecutan en el servidor, división de código que mantiene los bundles pequeños y scroll virtual que limita el tamaño del DOM. En el medio está la memoización dirigida: memo para componentes hoja que se re-renderizan innecesariamente, useMemo para cálculos costosos y useCallback para referencias de callback estables. En la base (y aquí es donde la mayoría de la gente empieza) está la microoptimización: funciones inline, referencias a objetos y envoltura prematura que añade complejidad sin beneficio medible.

Trabaja de arriba abajo. Primero perfila, luego revisa tu arquitectura, luego aplica memoización dirigida. No empieces con las microoptimizaciones. La diferencia entre una aplicación React bien optimizada y una mal optimizada casi nunca es si usaste memo en los lugares correctos. Es si tu arquitectura permite que React haga su trabajo de manera eficiente: árboles de componentes pequeños, bundles pequeños, tamaños de DOM pequeños y un flujo de datos claro.

Las técnicas de esta guía son completas, pero el principio es simple: haz que el comportamiento predeterminado de React sea rápido dándole menos trabajo que hacer. Renderiza menos componentes. Envía menos JavaScript. Calcula menos valores. Cuanto menos le pidas a React que haga, más rápido será, y menos necesitarás pensar en el rendimiento.