React Compiler: ¿Adiós a useMemo y useCallback manuales?

1. Descripción general

Muchos proyectos React tienen fragmentos de useMemo, useCallback y React.memo esparcidos por todo el codebase. El propósito es evitar re-renders innecesarios, pero el precio es código más largo y propenso a errores en las dependencias. React Compiler traslada esta memoización al momento de la compilación: analiza el componente e inserta automáticamente caché donde es necesario.

Este artículo explica qué es React Compiler y por qué está ganando atención. Aprenderás cómo habilitarlo en un proyecto Vite, cómo hacer una migración gradual parte por parte, cómo escribir pruebas para el comportamiento de memoización, y en qué casos deberías evitarlo.

2. El problema con la memoización manual

Considera una página de lista de pedidos con un filtro y un contador no relacionado:

import { useState, useMemo, useCallback, memo } from "react";

type Order = { id: number; customer: string; total: number };

const OrderList = memo(function OrderList({
  orders,
  onSelect,
}: {
  orders: Order[];
  onSelect: (id: number) => void;
}) {
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => onSelect(o.id)}>
          {o.customer}: {o.total}
        </li>
      ))}
    </ul>
  );
});

export function OrdersPage({ orders }: { orders: Order[] }) {
  const [keyword, setKeyword] = useState("");
  const [clicks, setClicks] = useState(0);

  const filtered = useMemo(
    () => orders.filter((o) => o.customer.includes(keyword)),
    [orders, keyword]
  );

  const handleSelect = useCallback((id: number) => {
    console.log("Selected order", id);
  }, []);

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={filtered} onSelect={handleSelect} />
    </>
  );
}

El código funciona correctamente, pero tiene tres puntos débiles:

  • Si omites memo en OrderList, tanto useMemo como useCallback se vuelven casi inútiles.
  • Si olvidas keyword en el array de dependencias, la lista filtrada mostrará datos antiguos.
  • Solo necesitas un nuevo prop de literal de objeto como style={{ color: "red" }} y todo el esfuerzo de memoización se arruina.

La memoización manual es un sistema implícito. Cualquiera que modifique el componente debe entenderlo, y los errores generalmente no se detectan hasta que alguien perfila el rendimiento.

3. ¿Qué es React Compiler?

React Compiler es una herramienta que se ejecuta en el momento de la compilación, generalmente como un plugin de Babel. Lee el código del componente y hook, comprende el flujo de datos y luego genera código con caché para:

  • Valores computados: equivalente a useMemo.
  • Funciones: equivalente a useCallback.
  • Elementos JSX: equivalente a envolver un hijo con memo. Si las entradas de un elemento no cambian, el compilador reutiliza el elemento antiguo y React omite el re-renderizado del subárbol.

El siguiente fragmento ilustra la idea del código después de la compilación. Esta es una versión simplificada, no es la salida real:

function OrdersPage({ orders }) {
  const $ = useMemoCache(6); // array de caché asociado a la instancia del componente
  const [keyword, setKeyword] = useState("");

  let filtered;
  if ($[0] !== orders || $[1] !== keyword) {
    filtered = orders.filter((o) => o.customer.includes(keyword));
    $[0] = orders;
    $[1] = keyword;
    $[2] = filtered;
  } else {
    filtered = $[2];
  }
  // ... similar para handleSelect y elementos JSX
}

El compilador compara cada entrada con el valor del renderizado anterior. Solo recalcula cuando la entrada cambia. En comparación con la memoización manual, hay dos diferencias importantes:

  1. Más granular: el compilador puede memoizar incluso los fragmentos que vienen después de las declaraciones condicionales, algo que useMemo no puede hacer porque los hooks no se pueden llamar condicionalmente.
  2. Sin arrays de dependencias escritos manualmente: las dependencias se infieren del código, por lo que es imposible “olvidarlas”.

4. Por qué React Compiler está ganando atención

Hay varias razones por las que este tema está recibiendo mucha atención:

  • Reduce boilerplate: el código vuelve a la forma “React puro”, fácil de leer y revisar.
  • Mejor rendimiento por defecto: el equipo no necesita un experto en perfilado para evitar re-renders innecesarios.
  • Impone el cumplimiento de las Rules of React: el compilador solo puede optimizar código “correcto”. Por lo tanto, viene con un linter que ayuda a detectar código que infringe las reglas, incluso si aún no has habilitado el compilador.
  • Compatible gradualmente: puedes habilitarlo para una carpeta o un componente, sin necesidad de reescribir toda la aplicación.

Nota: El compilador no hace que el código se ejecute más rápido en todas partes. Reduce el trabajo de re-renderizado. Si tu cuello de botella es la red, el tamaño del bundle o un bucle pesado que se ejecuta cada vez que la entrada cambia, el compilador no puede resolverlo.

5. Habilitar React Compiler en el proyecto

5.1. Instalar con Vite

Instala el plugin y el linter. Los nombres de los packages pueden cambiar con el tiempo, así que verifica la documentación oficial de React para la versión que estés usando.

npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks

Agrega el plugin a la configuración de Vite:

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler", {}]],
      },
    }),
  ],
});

El compilador debe ejecutarse primero en la cadena de plugins de Babel, ya que necesita leer el código original antes de que otros plugins lo transformen.

Si tu proyecto usa una versión anterior de React, el compilador necesita un paquete de runtime adicional y la opción target para generar código compatible. Esto depende de la versión, así que consulta la documentación antes de configurar.

Con frameworks como Next.js o Expo, generalmente hay flags de configuración separados para habilitar el compilador. Los nombres de los flags han cambiado entre versiones, así que lee las notas de lanzamiento del framework en lugar de copiar configuraciones antiguas.

5.2. Verificar que el compilador funciona

Abre React DevTools. Los componentes optimizados por el compilador tendrán una etiqueta tipo “Memo ✨” al lado del nombre. Si no hay etiqueta, significa que el compilador omitió el componente, generalmente por infringir las reglas de la sección 6.

5.3. Habilitar el linter

// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [
  reactHooks.configs.recommended,
];

El linter reporta por adelantado fragmentos de código que el compilador omitirá o podría ejecutar incorrectamente. Debes habilitar el linter antes del compilador y solucionar todas las advertencias.

6. Rules of React: condiciones para que el compilador funcione

El compilador asume que el componente es una función pura durante el renderizado. El siguiente ejemplo infringe esto:

export function CartSummary({ items }: { items: { price: number }[] }) {
  items.sort((a, b) => b.price - a.price); // ¡muta el prop!
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Top item: {items[0]?.price}, total: {total}</p>;
}

items.sort modifica directamente el array que se pasa. Con el caché, el compilador puede no reconocer que items ha “cambiado” porque la referencia sigue siendo la misma, y la UI muestra incorrectamente. El linter advertirá así:

error  Mutating a value returned from a function whose return value should not be mutated  react-hooks/...

El mensaje específico depende de la versión del plugin. La solución es crear una copia:

const sorted = [...items].sort((a, b) => b.price - a.price);

Infracciones comunes:

  • Mutar props, estado o valores retornados de hooks.
  • Leer o escribir ref.current durante el renderizado.
  • Llamar hooks condicionalmente o dentro de bucles.
  • Efectos secundarios directamente en el cuerpo del componente, como escribir en variables de módulo o llamar a una API.

Cuando encuentra código como este, el compilador generalmente omite ese componente en lugar de romperlo. Este es un comportamiento seguro, pero perderás la optimización.

7. Migración gradual en proyectos grandes

7.1. Modo opt-in

Con un codebase grande, puedes compilar solo los componentes que se marquen:

// vite.config.js (fragmento)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  "use memo";
  // ... este componente se compila
}

La directiva "use memo" al inicio del cuerpo de la función le dice al compilador que procese este componente. Los otros componentes permanecen sin cambios.

7.2. Excluir temporalmente un componente

Cuando has habilitado globalmente pero un componente causa errores, puedes desactivar temporalmente el compilador para él:

export function LegacyChart(props: ChartProps) {
  "use no memo";
  // ... código antiguo que usa una librería que mutates datos
}

Nota: "use no memo" es una salida temporal. Debes anotar la razón y crear una tarea para solucionarlo, no lo dejes como predeterminado.

7.3. ¿Qué pasa con los useMemo antiguos?

El compilador funciona con código que ya tiene useMemo/useCallback, así que no necesitas eliminarlos de inmediato. Eliminarlos en lotes puede cambiar comportamientos sutiles, especialmente cuando el valor se usa como dependencia de useEffect. Elimínalos gradualmente con pruebas. Para componentes nuevos, solo escribe código puro y deja que el compilador se encargue.

8. Escribir pruebas para el comportamiento de memoización

Puedes verificar que el hijo no se re-renderice cuando cambia el estado no relacionado. El siguiente ejemplo usa Vitest y React Testing Library, ejecutándose con la misma configuración de Vite que tiene habilitado el compilador:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";

const renderSpy = vi.fn();

function OrderList({ orders }: { orders: string[] }) {
  renderSpy();
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

function OrdersPage() {
  const [clicks, setClicks] = useState(0);
  const orders = ["Alice", "Bob"]; // sin useMemo
  return (
    <>
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={orders} />
    </>
  );
}

describe("OrdersPage con React Compiler", () => {
  it("no re-renderiza OrderList cuando solo cambia el contador", async () => {
    render(<OrdersPage />);
    await userEvent.click(screen.getByRole("button"));
    await userEvent.click(screen.getByRole("button"));

    expect(screen.getByRole("button")).toHaveTextContent("Clicks: 2");
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });
});

Explicación:

  • orders es un array literal sin useMemo. Sin el compilador, un nuevo array se crea en cada renderizado y OrderList se re-renderiza.
  • Con el compilador, tanto el array orders como el elemento <OrderList /> están en caché porque no dependen de clicks.
  • renderSpy se llama solo una vez, en el primer renderizado.

Resultado esperado:

✓ OrdersPage con React Compiler > no re-renderiza OrderList cuando solo cambia el contador
Test Files  1 passed (1)

Si desactivas el compilador, la aserción final fallará con un valor de 3. El matcher toHaveTextContent requiere agregar @testing-library/jest-dom en el archivo de configuración.

Nota: Llamar al spy en el cuerpo del componente es un efecto secundario. Esta forma solo es aceptable en pruebas para medir el número de renderizados. No la apliques en código de producción.

9. Cuándo NO debes usar (o cuando debes tener cuidado)

  • Codebase que infringe mucho las Rules of React: el compilador omitirá la mayoría de los componentes, o peor, expondrá bugs que estaban ocultos. Arregla según el linter primero.
  • Librerías basadas en mutación: algunas librerías de estado o formularios cambian objetos en su lugar y esperan que el componente lea valores nuevos cada vez que se renderiza. Para componentes que usan estas, podría ser necesario "use no memo".
  • Pipeline de compilación personalizado: si no usas Babel o tu toolchain no soporta plugins, la integración será costosa. Verifica el soporte del bundler primero.
  • Aplicación pequeña, poca interacción: el beneficio puede no ser significativo comparado con el tiempo de compilación aumentado y la complejidad en el debugging.
  • Necesitas referencias estables para efectos: el compilador memoiza para optimizar, pero no promete semántica. Si tu lógica correcta/incorrecta depende de que un valor mantenga la misma referencia, por ejemplo para que un efecto no se ejecute de nuevo, mantén useMemo/useCallback explícitamente para mostrar intención.

Además, cuando depuras, el código que se ejecuta en el navegador es diferente del código que escribiste. Los source maps ayudan, pero debes familiarizarte con DevTools y las herramientas de playground del compilador para ver la salida cuando sea necesario.

10. Conclusión

React Compiler no elimina useMemo y useCallback, pero los convierte de herramientas de uso diario en un escape hatch para casos especiales. El requisito previo es que el código cumpla con las Rules of React. Por lo tanto, el primer paso siempre es habilitar el linter y arreglar las infracciones.

Ruta recomendada: habilita el linter, arregla las advertencias, habilita el compilador en modo annotation para algunos componentes, escribe pruebas que midan el número de renderizados, luego expande gradualmente. Al hacerlo así, obtendrás mejor rendimiento sin necesidad de arrays de dependencias escritos manualmente.

reactreact-compilerperformancefrontendtech-trends