Módulo 14: Rendimiento y React Compiler

Objetivos del módulo

  • Medir antes de optimizar y distinguir un render de un cambio en el DOM
  • Saber exactamente cuándo y por qué React vuelve a renderizar un componente
  • Evitar renders sobrantes reorganizando el código, sin ninguna API especial
  • Usar memo, useMemo y useCallback, y entender por qué se rompen tan fácilmente
  • Instalar React Compiler 1.0 en un proyecto con Vite 8 y leer el código que genera
  • Conocer las reglas que exige el compilador y qué hace cuando las rompes
  • Depurar con diagnósticos, ESLint, oxlint, 'use no memo' y panicThreshold
  • Usar el compilador con React 17 y 18 mediante react-compiler-runtime
  • Medir con el Profiler de React DevTools, con <Profiler> y con las Performance Tracks del navegador
  • Mostrar miles de filas con paginación, content-visibility y virtualización
  • Dividir el código con lazy, import() dinámico y grupos de trozos en Vite

1. Antes de optimizar, mide

Una aplicación va lenta cuando el usuario lo nota: pulsa un botón y la respuesta tarda, escribe y las letras aparecen con retraso, o la página en blanco dura demasiado. El navegador pinta unas 60 veces por segundo, así que dispone de unos 16 ms para cada fotograma. Si tu JavaScript ocupa el hilo principal mucho más tiempo, la pantalla se congela durante ese rato.

En React, la mayor parte de los problemas de rendimiento pertenecen a uno de estos cuatro grupos:

Síntoma Causa habitual Sección
La interfaz tarda en reaccionar a un clic o al teclado Se renderizan muchos componentes, o uno hace un cálculo caro 2 a 8
Una lista va a tirones al desplazarla Demasiados nodos en el DOM 10
La primera carga es lenta Demasiado JavaScript en un único fichero 11
Todo funciona, pero los datos tardan La red o la API, no React Módulo 19

La regla es no adivinar. La intuición falla mucho en rendimiento, y cada optimización tiene un coste: más código, más dependencias que mantener y errores nuevos. Por eso el orden de trabajo siempre es este:

  1. Reproduce el problema y mídelo con una cifra.
  2. Localiza la causa con una herramienta, no con una corazonada.
  3. Aplica el arreglo más sencillo posible.
  4. Vuelve a medir para confirmar que ha mejorado.

⚠️ Importante: Mide siempre sobre la versión de producción (npm run build y npm run preview). El modo de desarrollo es mucho más lento porque hace comprobaciones adicionales, y <StrictMode> duplica renders y efectos a propósito. Una pantalla que va lenta con npm run dev puede ir perfectamente en producción. En desarrollo sirve para localizar qué se renderiza, no para sacar cifras de tiempo.

Prepara el proyecto

Crea un proyecto como en el módulo 01:

npm create vite@latest modulo-14 -- --template react-ts
cd modulo-14
npm install

Levanta también la API de ejemplo (recursos/empresa-api.zip), que queda en http://localhost:3000/api, porque algunos ejemplos cargan empleados reales. Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css, y en src/main.tsx quita <StrictMode>: aquí contamos renders y queremos que cada número signifique lo que parece.

import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';

// Solo en este módulo: en un proyecto real deja <StrictMode> puesto.
createRoot(document.getElementById('root')!).render(<App />);

Sustituye src/index.css por estas reglas:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 0.75rem 1.25rem;
  max-width: 720px;
  margin: 0 0 0.75rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.1rem;
  color: #1a237e;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  justify-content: space-between;
}

.insignia {
  display: inline-block;
  min-width: 1.5rem;
  padding: 0.1rem 0.5rem;
  border-radius: 999px;
  background: #ff7043;
  color: #fff;
  font-weight: 700;
  text-align: center;
}

button {
  padding: 0.4rem 0.8rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #3f51b5;
  color: #fff;
  cursor: pointer;
}

input {
  padding: 0.4rem 0.6rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
}

table {
  border-collapse: collapse;
  width: 100%;
}

th,
td {
  padding: 0.25rem 0.5rem;
  border-bottom: 1px solid #e8eaf6;
  text-align: left;
  font-size: 0.9rem;
}

.visor {
  overflow-y: auto;
  border: 1px solid #c5cae9;
  border-radius: 6px;
}

.fila-lista {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: 5rem 1fr 9rem 5rem;
  align-items: center;
  padding: 0 0.75rem;
  border-bottom: 1px solid #e8eaf6;
  font-size: 0.9rem;
}

Necesitamos además una forma de ver cuántas veces se renderiza cada componente. Crea src/util.ts con este hook y con los datos que usaremos después:

import { useEffect, useRef } from 'react';

export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  oficio: string;
  salario: number;
  departamento: string;
}

const API = 'http://localhost:3000/api';

export async function cargarEmpleados(): Promise<Empleado[]> {
  const respuesta = await fetch(`${API}/empleados?tamano=100`);
  const pagina = (await respuesta.json()) as { contenido: Empleado[] };
  return pagina.contenido;
}

// Repite los empleados reales hasta llegar a "total" filas, con id únicos.
export function multiplicar(base: Empleado[], total: number): Empleado[] {
  return Array.from({ length: total }, (_, i) => ({
    ...base[i % base.length],
    empId: 100000 + i,
    salario: 1200 + ((i * 37) % 5000),
  }));
}

// Devuelve una ref para un <span>. Cada vez que el componente que usa el hook
// llega al DOM, el span muestra cuántas veces ha ocurrido.
export function useContadorRenders() {
  const insignia = useRef<HTMLSpanElement>(null);
  const cuenta = useRef(0);

  useEffect(() => {
    cuenta.current += 1;
    if (insignia.current) insignia.current.textContent = `${cuenta.current}`;
  });

  return insignia;
}

Fíjate en dos decisiones. El contador se incrementa en un efecto sin dependencias, que se ejecuta tras cada render confirmado, y no durante el render: escribir en una ref mientras se renderiza rompe las reglas, como veremos en la sección 8. Además, el número se escribe directamente en el DOM a través de la ref, porque usar useState provocaría un render más y el contador se alimentaría a sí mismo. Los efectos y las refs se estudiaron en los módulos 09 y 10.


2. Cuándo se vuelve a renderizar un componente

Renderizar significa que React llama a tu función para saber qué debería verse. Solo hay cuatro motivos para que eso ocurra:

Motivo Ejemplo
Es el primer render El componente aparece por primera vez
Cambia su estado setClics(1), o el dispatch de un reductor
Se renderiza su padre El padre devuelve otra vez <Hijo />
Cambia un contexto que lee El proveedor recibe otro value

Observa lo que no está en la tabla: que cambien las props. Es el malentendido más extendido. Un hijo no se renderiza porque le lleguen props distintas, sino porque se ha renderizado su padre. Las props distintas son una consecuencia de lo mismo. Si el padre se renderiza, el hijo también, aunque sus props sean idénticas o no tenga ninguna.

Compruébalo

import { useState } from 'react';
import { useContadorRenders } from './util';

function Cabecera() {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>Cabecera (sin props)</h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
}

function Plantilla({ departamento }: { departamento: string }) {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>Plantilla de {departamento}</h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
}

function FichaEmpleado() {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>Ficha (hija de Pie)</h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
}

function Pie() {
  const insignia = useContadorRenders();
  return (
    <>
      <section className="tarjeta fila">
        <h2>Pie (sin props)</h2>
        <span className="insignia" ref={insignia} />
      </section>
      <FichaEmpleado />
    </>
  );
}

export default function Cascada() {
  const [clics, setClics] = useState(0);
  const insignia = useContadorRenders();

  return (
    <main>
      <section className="tarjeta fila">
        <h2>Panel (tiene el estado)</h2>
        <button onClick={() => setClics(clics + 1)}>Clics: {clics}</button>
        <span className="insignia" ref={insignia} />
      </section>
      <Cabecera />
      <Plantilla departamento="VENTAS" />
      <Pie />
    </main>
  );
}

Tras tres clics, todos los componentes marcan 4: el primer render más uno por clic. El estado solo lo usa el botón, pero se ha renderizado el árbol entero.

Cinco tarjetas con la insignia en 4 después de tres clics

Figura 1. Salida real tras tres clics: Cabecera, Plantilla, Pie y Ficha se renderizan aunque ninguna use clics.

¿Por qué actúa React así? Porque es la opción segura. Un componente es una función, y React no puede saber qué lee por dentro sin ejecutarla. Podría usar una variable global, un contexto o leer el reloj. Renderizar de más nunca da un resultado equivocado; renderizar de menos, sí. Por eso el comportamiento por defecto es renderizar hacia abajo, y evitarlo es una optimización que tienes que pedir.

Dos árboles de componentes: sin memo se renderiza todo; con memo en Pie se detiene esa rama

Figura 2. El render baja por todas las ramas. Un memo corta la suya, y con ella a todos sus descendientes.

Renderizar no es tocar el DOM

Esta es la idea que más tranquiliza. Como vimos en el módulo 05, una actualización tiene tres pasos: trigger, render y commit. React compara el resultado nuevo con el anterior, y en el commit solo toca lo que ha cambiado. En el ejemplo anterior, de los cinco componentes solo cambia un texto, y ese texto es lo único que React modifica en el DOM.

Por tanto, un render de más casi nunca es un problema. Los componentes normales se renderizan en microsegundos. Un render sobra de verdad cuando el componente hace mucho trabajo o tiene muchos descendientes, y eso se repite a menudo: en cada tecla, en cada movimiento del ratón o en cada evento de desplazamiento.

📘 Concepto: Un render ejecuta tu función y compara resultados. Un commit aplica los cambios al DOM. El DOM es lo caro, pero el render puede serlo también si afecta a miles de componentes. Un render sin cambios cuesta el tiempo de ejecutar tu código y no toca la pantalla.

Dos detalles más

Si llamas a setClics con el mismo valor que ya hay, comparado con Object.is, React evita renderizar a los hijos. La documentación advierte de que, en algunos casos, puede llegar a llamar una vez más al propio componente antes de descartar el resultado. Es normal y no tiene efectos visibles.

Cambiar la key de un componente es algo distinto de renderizarlo: React lo desmonta y lo monta de nuevo, y pierde su estado y su DOM. Por eso una key inestable, como Math.random(), es una de las peores cosas que le puedes hacer a una lista, tal y como explicamos en el módulo 06. No hay memo que lo arregle.


3. Arreglos sin ninguna API especial

Antes de memorizar nada, mira la estructura. Muchos renders sobrantes desaparecen moviendo el estado de sitio.

Problema: el estado está demasiado arriba

import { useState, type ReactNode } from 'react';
import { useContadorRenders } from './util';

function TablaLenta({ nombre }: { nombre: string }) {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>{nombre}</h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
}

// ❌ El estado del buscador vive junto a la tabla, que no lo necesita.
function PanelMal() {
  const [texto, setTexto] = useState('');
  return (
    <>
      <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Estado arriba" />
      <TablaLenta nombre="A. Tabla con el estado arriba" />
    </>
  );
}

// ✅ Solución 1: bajar el estado a quien lo usa.
function Buscador() {
  const [texto, setTexto] = useState('');
  return <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Estado abajo" />;
}

function PanelEstadoAbajo() {
  return (
    <>
      <Buscador />
      <TablaLenta nombre="B. Tabla con el estado abajo" />
    </>
  );
}

// ✅ Solución 2: la parte que cambia recibe lo demás como children.
function Marco({ children }: { children: ReactNode }) {
  const [abierto, setAbierto] = useState(true);
  return (
    <>
      <button onClick={() => setAbierto(!abierto)}>{abierto ? 'Plegar' : 'Desplegar'} marco</button>
      <div style={{ opacity: abierto ? 1 : 0.4 }}>{children}</div>
    </>
  );
}

export default function Colocar() {
  return (
    <main>
      <PanelMal />
      <PanelEstadoAbajo />
      <Marco>
        <TablaLenta nombre="C. Tabla como children" />
      </Marco>
    </main>
  );
}

Escribiendo tres letras en cada caja y pulsando tres veces el botón, estos son los contadores reales:

Caso Renders de la tabla
A. Estado por encima de la tabla 4
B. Estado bajado a Buscador 1
C. Tabla pasada como children 1

Bajar el estado funciona porque Buscador y TablaLenta pasan a ser hermanos. Cuando cambia el estado de Buscador, se renderiza él y sus hijos, y TablaLenta no es ninguna de las dos cosas.

Pasar la parte pesada como children es más sutil. <TablaLenta /> se crea en Colocar, que no tiene estado y no se vuelve a ejecutar. Cuando Marco cambia su estado, la prop children sigue siendo el mismo objeto y React ve que ese elemento no ha cambiado, así que se lo salta. Quien crea el elemento determina cuándo se renderiza, no quien lo coloca en pantalla.

💡 Consejo: Estas dos técnicas no tienen coste, no añaden código y además dejan los componentes mejor organizados. Pruébalas antes que cualquier otra cosa. El contrapeso es la regla de elevar el estado: sube el estado solo hasta el ancestro común que lo necesite, y no más arriba.


4. memo: saltarse un render si las props no cambian

memo envuelve un componente y devuelve otro. Antes de renderizar, React compara las props nuevas con las anteriores. Si todas son iguales, reutiliza el resultado anterior y no ejecuta tu función, ni tampoco la de sus descendientes.

import { memo } from 'react';

function Tarjeta({ titulo }: { titulo: string }) {
  return <h2>{titulo}</h2>;
}

const TarjetaMemo = memo(Tarjeta);

La comparación es superficial: compara cada prop con Object.is. Con textos y números, compara el valor. Con objetos, arrays y funciones, compara la referencia: si son dos objetos diferentes en memoria, los considera distintos aunque contengan lo mismo.

Y aquí está la trampa. Un objeto o una función escritos dentro del componente se crean de nuevo en cada render:

<TarjetaMemo filtro={{ departamento: 'VENTAS' }} />   // objeto nuevo cada vez
<TarjetaMemo alGuardar={() => guardar()} />            // función nueva cada vez

memo ve una prop distinta y renderiza. El componente hace la comparación y luego el trabajo: pagas más que sin memo.

Comparación de tres props: el texto coincide; el objeto y la función son otros en memoria

Figura 3. Dos objetos con el mismo contenido no son el mismo objeto.

Seis casos comparados

import { memo, useCallback, useMemo, useState } from 'react';
import { useContadorRenders } from './util';

interface Filtro {
  departamento: string;
}

interface Props {
  titulo: string;
  filtro?: Filtro;
  alGuardar?: () => void;
}

function Tarjeta({ titulo }: Props) {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>{titulo}</h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
}

// Dos versiones del mismo componente: una normal y otra memorizada.
const TarjetaMemo = memo(Tarjeta);

export default function Comparativa() {
  const [clics, setClics] = useState(0);
  const [departamento] = useState('VENTAS');

  const filtroEstable = useMemo(() => ({ departamento }), [departamento]);
  const guardarEstable = useCallback(() => {
    console.log('guardado', departamento);
  }, [departamento]);

  return (
    <main>
      <section className="tarjeta fila">
        <h2>Panel</h2>
        <button onClick={() => setClics(clics + 1)}>Clics: {clics}</button>
      </section>
      <Tarjeta titulo="1. Sin memo" />
      <TarjetaMemo titulo="2. memo, props primitivas" />
      <TarjetaMemo titulo="3. memo, objeto nuevo" filtro={{ departamento }} />
      <TarjetaMemo titulo="4. memo + useMemo" filtro={filtroEstable} />
      <TarjetaMemo titulo="5. memo, función nueva" alGuardar={() => console.log('guardado')} />
      <TarjetaMemo titulo="6. memo + useCallback" alGuardar={guardarEstable} />
    </main>
  );
}

Seis tarjetas: los casos 1, 3 y 5 marcan 4 renders; los casos 2, 4 y 6 marcan 1

Figura 4. Salida real tras tres clics. memo solo funciona si todas las props son estables.

Los casos 3 y 5 son el error más común: alguien añade memo, no consigue nada y concluye que React “no le hace caso”.

Lo que memo no detiene

Causa de render ¿La detiene memo?
El padre se renderiza con las mismas props Sí
Cambia el estado del propio componente No
Cambia un contexto que lee el componente No
Una prop cambia de referencia No

Hay otra prop que rompe memo sin que se note: children. Cada <TarjetaMemo><p>Hola</p></TarjetaMemo> crea un elemento <p> nuevo, que es un objeto nuevo. Si el hijo es una zona de JSX, memo no te ayudará salvo que memorices ese JSX.

Comparación personalizada

memo acepta una función que decide si las props son equivalentes. Debe devolver true si no hace falta renderizar, al revés que un shouldRender:

const TarjetaMemo = memo(
  TarjetaSalario,
  (antes, despues) =>
    antes.empleado.empId === despues.empleado.empId &&
    antes.empleado.salario === despues.empleado.salario,
);

Úsala lo menos posible. Si olvidas comparar una prop, el componente se quedará mostrando un dato viejo: un error mucho peor que un render de más. Si comparas funciones, recuerda que una función anterior conserva los valores de su render, y usarla puede dar resultados antiguos. Y una comparación profunda sobre datos grandes puede tardar más que el propio render.

Cuándo merece la pena

Tiene sentido cuando el componente se renderiza a menudo con las mismas props, y su render es perceptiblemente caro. Las tres condiciones. Un ejemplo típico son las filas de una tabla larga, cuando al seleccionar una no quieres repintar las demás. No lo pongas por defecto en todo: el compilador lo hace mejor, como verás en la sección 8.


5. useMemo: conservar un cálculo entre renders

useMemo guarda el resultado de una función, y solo la vuelve a ejecutar si cambia alguna dependencia:

const visibles = useMemo(() => ordenarYFiltrar(empleados, texto), [empleados, texto]);

Tiene dos usos diferentes:

  1. Evitar un cálculo caro que no ha cambiado.
  2. Conservar una referencia estable, para que un memo o un useEffect no vean un objeto nuevo.

Primer uso: cálculos caros

import { useEffect, useMemo, useState } from 'react';
import { cargarEmpleados, multiplicar, type Empleado } from './util';

function ordenarYFiltrar(empleados: Empleado[], texto: string): Empleado[] {
  const buscado = texto.toLowerCase();
  return empleados
    .filter((e) => `${e.nombre} ${e.apellidos}`.toLowerCase().includes(buscado))
    .toSorted((a, b) => b.salario - a.salario || a.apellidos.localeCompare(b.apellidos, 'es'));
}

export default function Calculo() {
  const [empleados, setEmpleados] = useState<Empleado[]>([]);
  const [texto, setTexto] = useState('');
  const [oscuro, setOscuro] = useState(false);

  useEffect(() => {
    cargarEmpleados().then((base) => setEmpleados(multiplicar(base, 50_000)));
  }, []);

  const visibles = useMemo(() => ordenarYFiltrar(empleados, texto), [empleados, texto]);

  return (
    <main className="tarjeta" style={{ background: oscuro ? '#c5cae9' : '#fff' }}>
      <input value={texto} onChange={(e) => setTexto(e.target.value)} />
      <button onClick={() => setOscuro(!oscuro)}>Tema</button>
      <p>{visibles.length} resultados</p>
    </main>
  );
}

Medido en la build de producción con 50 000 empleados, pulsando diez veces el botón de tema:

Versión Llamadas a ordenarYFiltrar Tiempo por render
Sin useMemo 10 67 ms de media
Con useMemo 0 0 ms

67 ms son unos cuatro fotogramas: se nota. Con 500 empleados, el mismo cálculo no se notaría y useMemo no aportaría nada. Si quieres saber si un cálculo es caro, envuélvelo con console.time('filtrado') y console.timeEnd('filtrado') en una build de producción. Como orientación, a partir de 1 ms ya puede merecer la pena.

useMemo no resuelve el caso en el que cambian sus dependencias: mientras se escribe en el buscador, el cálculo se repite en cada tecla y la escritura se nota lenta. Para eso sirven useDeferredValue y useTransition, que estudiaremos en el módulo 15.

Segundo uso: referencias estables

Ya lo has visto en el caso 4 de la sección anterior. También sirve para que un objeto no dispare un efecto en cada render:

// ❌ opciones es un objeto nuevo en cada render: el efecto se repite siempre
const opciones = { pagina, tamano: 20 };
useEffect(() => { cargar(opciones); }, [opciones]);

// ✅ Mejor aún: depende de los valores primitivos
useEffect(() => { cargar({ pagina, tamano: 20 }); }, [pagina]);

Siempre que puedas, usa la segunda forma. Mover el objeto dentro del efecto resulta más sencillo que memorizarlo.

Lo que hay que saber de useMemo

  • Es una optimización, no una garantía. React puede descartar la caché, por ejemplo al editar el fichero en desarrollo. El código debe funcionar igual sin useMemo.
  • La función debe ser pura: nada de peticiones ni de modificar nada desde dentro. Para eso están los efectos.
  • <StrictMode> llama a la función dos veces en desarrollo para destapar impurezas.
  • Guarda solo el último resultado, y cada instancia del componente tiene el suyo.
  • Las dependencias se comparan con Object.is, igual que en memo.

6. useCallback: conservar una función entre renders

useCallback es useMemo para funciones. Estas dos líneas son equivalentes:

const guardar = useCallback(() => enviar(id), [id]);
const guardar = useMemo(() => () => enviar(id), [id]);

Un malentendido habitual es creer que useCallback evita crear la función. No lo hace: la función se crea en cada render, y React te devuelve la antigua si las dependencias no han cambiado. Por sí solo no ahorra nada. Solo sirve si esa referencia estable le importa a alguien:

  • Se pasa a un componente envuelto en memo (casos 5 y 6 de la sección 4).
  • Es dependencia de un useEffect o de otro useMemo.
  • La devuelve un hook personalizado, y quien lo use podría depender de ella.
// ❌ No sirve de nada: <button> no es un componente memorizado
const pulsar = useCallback(() => setAbierto(true), []);
return <button onClick={pulsar}>Abrir</button>;

Dependencias y funciones actualizadoras

Si la función usa el estado, tendrá que declararlo, y cambiará cada vez que él cambie. La función actualizadora del módulo 04 evita esa dependencia:

// Cambia con cada elemento añadido
const anadir = useCallback((e: Empleado) => setLista([...lista, e]), [lista]);

// ✅ Estable para siempre: setLista no cambia nunca
const anadir = useCallback((e: Empleado) => setLista((previa) => [...previa, e]), []);

Si solo necesitas leer el último valor desde un efecto, recuerda useEffectEvent, del módulo 09, en lugar de cargar la lista de dependencias.


7. memo y Context

Un contexto se salta memo: si cambia su value, se renderizan todos los componentes que lo leen, aunque estén memorizados. Esto ya se explicó en el módulo 11; aquí importa recordar las dos reglas de rendimiento:

  • Memoriza el value del proveedor si es un objeto. Si no, cada render del proveedor crea un valor nuevo y despierta a todos los consumidores.
  • Separa los contextos por frecuencia de cambio. Si tema y sesión van juntos, quien lee la sesión se renderiza cuando cambia el tema.

El ejercicio 6 te pide comprobarlo con contadores.


8. React Compiler 1.0

Si memo, useMemo y useCallback te parecen frágiles, no eres el único. Debes pensar en referencias, listar dependencias a mano y estabilizar cada eslabón. Si uno falla, el resto no sirve de nada, como en los casos 3 y 5. El equipo de React resolvió esto con una herramienta de compilación: React Compiler analiza tu código y añade la memorización por ti.

La versión 1.0 es estable desde el 7 de octubre de 2025, y el paquete es babel-plugin-react-compiler. En su anuncio, el equipo de React cita el caso de la Meta Quest Store: cargas iniciales y navegaciones “hasta un 12 %” más rápidas, ciertas interacciones “más de 2,5 veces” más rápidas y un consumo de memoria neutro, con el aviso de que los resultados de cada aplicación pueden variar.

📘 Concepto: React Compiler es una herramienta de compilación, no una biblioteca que uses desde tu código. Lee tus componentes durante la build, calcula de qué depende cada valor y genera una versión que reutiliza lo que no ha cambiado. No cambia lo que hace tu código, siempre que este cumpla las reglas de React.

Flujo: tu código entra en el compilador, que produce una función compilada o la deja sin tocar si rompe una regla

Figura 5. El compilador decide función a función. Si no puede garantizar que algo es seguro, no lo toca.

Instalación en un proyecto con Vite

Desde @vitejs/plugin-react 6 hay dos caminos. El estable y recomendado por la documentación pasa por Babel:

npm install -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler

Y en vite.config.ts:

import babel from '@rolldown/plugin-babel';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});

No necesitas tocar el código de tus componentes. Con las versiones del curso, esa orden instala @rolldown/plugin-babel 0.2.4, @babel/core 8.0.10 y babel-plugin-react-compiler 1.0.0. Lo hemos compilado y ejecutado con esa combinación. Si vas a actualizar, conviene fijar la versión exacta del compilador con --save-exact, porque una versión nueva puede memorizar de otra forma.

El segundo camino es un compilador escrito en Rust, más nuevo:

npm install -D oxc-transform-react
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react({ compiler: true })],
});

No requiere Babel, pero la documentación del plugin lo marca como experimental. En nuestras pruebas, los dos caminos dieron los mismos contadores. Usa Babel salvo que tengas un motivo para lo contrario.

⚠️ Importante: Si en internet encuentras react({ babel: { plugins: [...] } }), esa forma es de @vitejs/plugin-react 5 o anterior. La opción babel se eliminó en la versión 6, la que usa este curso con Vite 8.

Si empiezas un proyecto desde cero, el código fuente de create-vite incluye la plantilla react-compiler-ts, que trae esto ya configurado:

npm create vite@latest mi-app -- --template react-compiler-ts

Pruébalo

Este componente no tiene memo, ni useMemo, ni useCallback:

import { useEffect, useState } from 'react';
import { useContadorRenders, type Empleado } from './util';

const BASE: Empleado[] = [
  { empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL', salario: 6500, departamento: 'DIRECCIÓN' },
  { empId: 101, nombre: 'Luis', apellidos: 'Martínez Ruiz', oficio: 'ANALISTA', salario: 3100, departamento: 'INFORMÁTICA' },
  { empId: 102, nombre: 'Marta', apellidos: 'Sánchez Gil', oficio: 'DIRECTORA', salario: 4800, departamento: 'VENTAS' },
];

function Filtros({ alCambiar }: { alCambiar: (texto: string) => void }) {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>Filtros</h2>
      <input placeholder="Sin usar" onChange={(e) => alCambiar(e.target.value)} />
      <span className="insignia" ref={insignia} />
    </section>
  );
}

function Tabla({ empleados }: { empleados: Empleado[] }) {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta">
      <div className="fila">
        <h2>Tabla ({empleados.length})</h2>
        <span className="insignia" ref={insignia} />
      </div>
      <table>
        <tbody>
          {empleados.map((e) => (
            <tr key={e.empId}>
              <td>{e.nombre}</td>
              <td>{e.salario} €</td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}

export default function Compilado() {
  const [clics, setClics] = useState(0);
  const [texto, setTexto] = useState('');
  const insignia = useContadorRenders();

  // Sin useMemo, sin useCallback y sin memo en ningún sitio.
  const visibles = BASE.filter((e) => e.nombre.includes(texto));
  const alCambiar = (valor: string) => setTexto(valor);

  // Con el compilador, visibles es estable y este efecto no se repite.
  useEffect(() => {
    document.title = `${visibles.length} empleados`;
  }, [visibles]);

  return (
    <main>
      <section className="tarjeta fila">
        <h2>Panel</h2>
        <button onClick={() => setClics(clics + 1)}>Clics: {clics}</button>
        <span className="insignia" ref={insignia} />
      </section>
      <Filtros alCambiar={alCambiar} />
      <Tabla empleados={visibles} />
    </main>
  );
}

Hicimos la misma prueba en las tres variantes, en producción y con tres clics:

Variante Panel Filtros Tabla Ejecuciones del efecto
Sin compilador 4 4 4 4
Compilador con Babel 4 1 1 1
Compilador en Rust 4 1 1 1

Sin compilador, Panel, Filtros y Tabla marcan 4

Figura 6. Sin compilador: los tres componentes se renderizan en cada clic.

Con compilador, Panel marca 4, y Filtros y Tabla marcan 1

Figura 7. Mismo código con el compilador: solo Panel, que tiene el estado que cambia.

Fíjate en que Filtros no lleva memo, y se salta igualmente. Al compilar, el JSX también se memoriza: si las props de <Filtros /> no cambian, Panel devuelve exactamente el mismo elemento, y React lo reconoce y no baja. Es el mismo mecanismo que con children en la sección 3.

Qué código genera

Esta es la salida real, antes de transformar el JSX, para este componente:

export function Tarjeta({ empleado, alElegir }) {
  const etiqueta = `${empleado.nombre} ${empleado.apellidos}`;
  return (
    <button className="tarjeta" onClick={() => alElegir(empleado.empId)}>
      {etiqueta}
    </button>
  );
}
import { c as _c } from "react/compiler-runtime";

export function Tarjeta(t0) {
  const $ = _c(6);
  const {
    empleado,
    alElegir
  } = t0;
  const etiqueta = `${empleado.nombre} ${empleado.apellidos}`;
  let t1;
  if ($[0] !== alElegir || $[1] !== empleado.empId) {
    t1 = () => alElegir(empleado.empId);
    $[0] = alElegir;
    $[1] = empleado.empId;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  let t2;
  if ($[3] !== etiqueta || $[4] !== t1) {
    t2 = <button className="tarjeta" onClick={t1}>{etiqueta}</button>;
    $[3] = etiqueta;
    $[4] = t1;
    $[5] = t2;
  } else {
    t2 = $[5];
  }
  return t2;
}

No hace falta memorizar el código generado, pero sí entenderlo:

  • _c(6) pide a React una caché de 6 posiciones, propia de esa instancia del componente, que se conserva entre renders.
  • Cada bloque comprueba sus dependencias. Si cambian, recalcula y guarda; si no, reutiliza lo guardado.
  • La función de onClick depende de alElegir y de empleado.empId. El compilador ha llegado a un detalle más fino que el objeto empleado entero, algo que muy pocas personas harían a mano.
  • La plantilla de texto etiqueta no se memoriza: es barata y su resultado es un texto, que se compara por valor.
  • El JSX se memoriza según etiqueta y t1. Si no cambian, devuelve el mismo elemento.

Hay algo que useMemo no puede hacer y el compilador sí: memorizar después de un return anticipado o dentro de un condicional, ya que no está sujeto a las reglas de los hooks.

💡 Consejo: Para saber si un componente está compilado, puedes mirar la salida de la build buscando memo_cache_sentinel o _c(. Según la documentación, React DevTools además muestra la insignia Memo ✨ junto a los componentes optimizados.

Las reglas de React

El compilador supone que tu código sigue las reglas de React. Son las de siempre: componentes y hooks puros durante el render, hooks en el nivel superior y no modificar lo que no te pertenece. Antes podías saltártelas y a menudo no pasaba nada. Con el compilador sí importa, porque memorizar algo impuro daría resultados incorrectos.

Si detecta una infracción, hace lo más seguro: deja esa función sin compilar, no da ningún error y sigue con las demás. La aplicación funciona, pero esa función no se memoriza. Probamos este fichero:

import { useMemo, useRef, useState } from 'react';

export function MutaProp({ empleado }) {
  empleado.salario += 100;
  return <p>{empleado.salario}</p>;
}

export function LeeRef() {
  const veces = useRef(0);
  veces.current += 1;
  return <p>{veces.current}</p>;
}

export function Hora() {
  const ahora = Date.now();
  return <p>{ahora}</p>;
}

export function DepsManuales({ empleado }) {
  const nombre = useMemo(() => empleado.nombre.toUpperCase(), [empleado.apellidos]);
  return <p>{nombre}</p>;
}

export function ComponenteDentro({ nombre }) {
  function Hijo() {
    return <b>{nombre}</b>;
  }
  return <Hijo />;
}

export function BucleRender({ valor }) {
  const [x, setX] = useState(0);
  setX(valor);
  return <p>{x}</p>;
}

export function Correcto({ empleados }) {
  const [texto, setTexto] = useState('');
  const visibles = empleados.filter((e) => e.nombre.includes(texto));
  return <input value={texto} title={visibles.length} onChange={(e) => setTexto(e.target.value)} />;
}

El resultado de la build y del linter, función a función:

Función Qué hace mal Compilador ESLint
MutaProp Modifica una prop No compila This value cannot be modified
LeeRef Lee y escribe una ref durante el render No compila Cannot access refs during render
Hora Llama a Date.now() en el render Sí compila Cannot call impure function during render
DepsManuales Dependencias de useMemo incorrectas No compila missing dependency: 'empleado.nombre'
ComponenteDentro Declara un componente dentro de otro Sí compila Cannot create components during render
BucleRender setState en el render No compila Cannot call setState during render
Correcto Nada Compila Sin avisos

Hay dos conclusiones importantes. Primera: el fichero se compila parcialmente, y Correcto se beneficia aunque sus vecinas estén mal. Segunda: el compilador no sustituye al linter. Hora y ComponenteDentro se compilan porque la herramienta no las considera un obstáculo para memorizar, pero siguen siendo incorrectas: Hora quedará congelada en la primera hora, y Hijo se recreará y perderá su estado.

Lo mismo ocurre con una función que modifica una variable global durante el render. En una de nuestras pruebas, un componente Lista incrementaba un contador declarado fuera de él: el compilador dejó esa función sin tocar y compiló su componente padre.

Depurar: por qué no se ha compilado

Con el linter. eslint-plugin-react-hooks 7 incluye las reglas del compilador en sus configuraciones recommended y recommended-latest, y no requiere tener instalado el compilador:

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

export default [reactHooks.configs.flat.recommended];

Sobre el fichero anterior, ESLint 10 da esta salida resumida:

   4:3   error    Error: This value cannot be modified
  10:3   error    Error: Cannot access refs during render
  11:14  error    Error: Cannot access refs during render
  15:17  error    Error: Cannot call impure function during render
  20:63  warning  React Hook useMemo has a missing dependency: 'empleado.nombre'
  28:11  error    Error: Cannot create components during render
  33:3   error    Error: Cannot call setState during render

En el módulo 09 vimos que typescript-eslint todavía no admite TypeScript 7, por lo que este proyecto usa oxlint. oxlint tiene varias de estas reglas, pero no vienen activas por defecto. Añádelas a .oxlintrc.json:

{
  "plugins": ["react"],
  "rules": {
    "react/purity": "warn",
    "react/refs": "warn",
    "react/immutability": "warn",
    "react/set-state-in-render": "warn",
    "react/static-components": "warn"
  }
}

Con esta configuración, oxlint 1.90 detectó las infracciones de MutaProp, LeeRef, Hora, ComponenteDentro y BucleRender. Esa versión no avisó de las dependencias de DepsManuales.

Con los diagnósticos de la build. Si usas el camino de Rust, activa reportDiagnostics y Vite te lo cuenta durante la compilación. Es la opción que sustituye a logDiagnostics, que sigue funcionando pero está marcada como obsoleta:

react({ compiler: { reportDiagnostics: true } })

Si usas Babel, pasa una función logger:

reactCompilerPreset({
  logger: {
    logEvent(fichero, evento) {
      if (evento.kind === 'CompileError') {
        console.warn(`[compilador] ${fichero}: ${evento.detail?.reason}`);
      }
    },
  },
})

Los eventos que recibe son, por ejemplo, CompileSuccess, con el número de posiciones de caché (memoSlots), CompileError y CompileSkip. Para nuestro fichero:

CompileError  linea 3   This value cannot be modified
CompileError  linea 8   Cannot access refs during render
OK Hora               slots 1
CompileError  linea 19  Existing memoization could not be preserved
OK ComponenteDentro   slots 2
CompileError  linea 31  Calling setState during render may trigger an infinite loop
OK Correcto           slots 9

Hacer que la build falle. Por defecto una infracción no es un error. Con panicThreshold: 'all_errors' lo es, lo cual resulta útil en integración continua si tu código ya está limpio:

reactCompilerPreset({ panicThreshold: 'all_errors' })
Error: This value cannot be modified

Modifying component props or hook arguments is not allowed. Consider using a local variable instead.

  3 | export function MutaProp({ empleado }) {
> 4 |   empleado.salario += 100;
    |   ^^^^^^^^ value cannot be modified

Bibliotecas incompatibles

Algunas bibliotecas devuelven funciones que cambian de resultado sin cambiar de referencia. Memorizar eso dejaría la interfaz desfasada, así que el compilador conoce algunas y salta los componentes que las usan. Al compilar el ejemplo de virtualización de la sección 10 apareció este aviso real:

! react-compiler(IncompatibleLibrary): Use of incompatible library
  const virtualizador = useVirtualizer({
                        ^^^^^^^^^^^^^^
    TanStack Virtual's `useVirtualizer()` API returns functions that cannot be memoized safely
  help: This API returns functions which cannot be memoized without leading
        to stale UI. To prevent this, by default React Compiler will skip
        memoizing this component/hook.

No es un fallo: el componente funciona con normalidad, simplemente no se memoriza. ESLint avisa del mismo problema con la regla react-hooks/incompatible-library. Si ese componente tiene partes caras, sácalas a un componente hijo, que sí podrá compilarse.

Controlar qué se compila

Si un componente se comporta mal al compilarse, exclúyelo con una directiva, y arréglalo después:

function Problematico() {
  'use no memo';
  // ...
}

Para adoptarlo poco a poco en una aplicación grande, usa el modo por anotación, en el que solo se compila lo que marques con 'use memo':

reactCompilerPreset({ compilationMode: 'annotation' })
function PantallaCritica() {
  'use memo';
  // ...
}

Añadimos a nuestro fichero de pruebas una función con 'use memo', y esa opción dejó compilada solo esa función.

El compilador puede cambiar cuándo se ejecuta un efecto

Vuelve a la tabla: el efecto con [visibles] pasó de ejecutarse 4 veces a 1. Sin compilador, visibles era un array nuevo en cada render, y el efecto se repetía. Con él, es estable. Normalmente esto es lo que quieres, pero si tu código dependía sin saberlo de ese efecto repetido, cambiará de comportamiento. No es un fallo del compilador: el código estaba mal planteado.

Por eso, al activarlo en un proyecto existente, activa primero el linter, corrige lo que encuentre, y prueba de extremo a extremo los flujos importantes.

¿Sigo usando memo, useMemo y useCallback?

Situación Recomendación
Código nuevo con compilador No los escribas salvo que los necesites
Necesitas controlar con precisión una dependencia de un efecto useMemo o useCallback siguen siendo válidos
Código existente Déjalos, o quítalos con pruebas: retirarlos puede cambiar la salida
Proyecto sin compilador Úsalos donde midas un problema real
Componente excluido o con biblioteca incompatible Memoriza a mano si hace falta

Por eso seguimos estudiándolos. No todos los proyectos usan el compilador, seguirás leyendo mucho código que los contiene, y entender el problema que resuelven es lo que permite diagnosticar un fallo cuando aparezca. El compilador tampoco resuelve lo demás: un componente que renderiza 10 000 filas seguirá creando 10 000 filas, y un fichero enorme seguirá siendo enorme. Las secciones 10 y 11 siguen siendo necesarias.

Compilador con React 17 o 18 🔄 19.0

🔄 Cambia en React 19.0. Desde 19.0, React trae el runtime del compilador en react/compiler-runtime; en 18.3 esa ruta no existe y la importación falla. Si usas una versión anterior: instala react-compiler-runtime como dependencia normal, no de desarrollo, y añade target: '18' o target: '17' a las opciones del compilador.

npm install react-compiler-runtime
babel({ presets: [reactCompilerPreset({ target: '18' })] })

El valor es siempre un texto y sin versión de parche: '18', no 18 ni '18.3.1'. Lo hemos comprobado de verdad, con estas versiones exactas: react-compiler-runtime 1.0.0 frente a React 17.0.2 y 18.3.1, en modo de desarrollo y de producción, y también mediante una build de Vite 8 ejecutada en Chromium.

Prueba (3 clics) Clics Render del hijo Ejecuciones del efecto
React 17.0.2 sin compilador 3 4 4
React 17.0.2, target: '17' 3 1 1
React 18.3.1 sin compilador 3 4 4
React 18.3.1, target: '18' 3 1 1
React 18.3.1 con el target por defecto Falla    

La última fila es el error que verás si olvidas el target:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './compiler-runtime' is not defined by "exports"

La diferencia está en el código generado: la primera línea pasa a ser import { c as _c } from "react-compiler-runtime". Por dentro, ese paquete usa la versión de React cuando está disponible, y si no, construye la caché con un useMemo.


9. Herramientas de medida

El Profiler de React DevTools

Es la herramienta principal para saber qué se renderiza y por qué. Instala la extensión React Developer Tools (módulo 01), arranca la aplicación con npm run dev y abre la pestaña Profiler:

  1. Abre los ajustes (la rueda), entra en Profiler y activa Record why each component rendered while profiling.
  2. Pulsa el botón redondo de grabar.
  3. Haz en la aplicación lo que va lento, y detén la grabación.
  4. Navega por los commits con la barra superior derecha: cada barra es un commit, y su altura indica lo que tardó.
  5. En la vista Flamegraph, cada barra es un componente. Las grises no se renderizaron; cuanto más cálido el color, más tiempo gastó en ese commit.
  6. La vista Ranked ordena los componentes de más a menos lentos.
  7. Selecciona un componente y lee Why did this render?: “Props changed”, “Hook 1 changed”, “The parent component rendered”.

También es muy útil, en la pestaña Components, abrir los ajustes y marcar Highlight updates when components render. Cada componente que se renderiza parpadea en la página, y ves de un vistazo si al escribir una letra se ilumina toda la pantalla.

💡 Consejo: El motivo “The parent component rendered” te indica dónde cortar. Si el hijo es caro, sube por el árbol hasta dar con el estado que provoca el render y plantéate: ¿puedo bajarlo? ¿puedo pasar al hijo como children? ¿es un problema que resuelva el compilador?

<Profiler> para medir por código

El componente <Profiler> mide un trozo del árbol desde tu código, y sirve para enviar métricas o para hacer comparaciones automáticas:

import { memo, Profiler, useState, type ProfilerOnRenderCallback } from 'react';

interface Medida {
  id: string;
  fase: string;
  real: number;
  base: number;
}

const medidas: Medida[] = [];

const registrar: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
  medidas.push({ id, fase: phase, real: actualDuration, base: baseDuration });
};

function trabajo(ms: number) {
  const fin = performance.now() + ms;
  while (performance.now() < fin);
}

function Grafica() {
  trabajo(8);
  return <p>Gráfica de salarios</p>;
}

const GraficaMemo = memo(Grafica);

export default function Perfil() {
  const [clics, setClics] = useState(0);
  const [, refrescar] = useState(0);

  return (
    <main className="tarjeta">
      <h2>Panel con &lt;Profiler&gt;</h2>
      <button onClick={() => setClics(clics + 1)}>Clics: {clics}</button>
      <button onClick={() => refrescar((n) => n + 1)}>Ver medidas</button>
      <Profiler id="sin-memo" onRender={registrar}>
        <Grafica />
      </Profiler>
      <Profiler id="con-memo" onRender={registrar}>
        <GraficaMemo />
      </Profiler>
      <table>
        <thead>
          <tr>
            <th>id</th>
            <th>fase</th>
            <th>actualDuration</th>
            <th>baseDuration</th>
          </tr>
        </thead>
        <tbody>
          {medidas.slice(0, 6).map((m, i) => (
            <tr key={i}>
              <td>{m.id}</td>
              <td>{m.fase}</td>
              <td>{m.real.toFixed(1)} ms</td>
              <td>{m.base.toFixed(1)} ms</td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}

trabajo(8) simula un componente que tarda 8 ms. Las medidas se guardan fuera de React para que onRender no provoque renders: por eso hay un botón “Ver medidas”. Tras dos clics:

Tabla con mount de 8 ms en ambos, y update de 8 ms sin memo frente a 0,1 ms con memo

Figura 8. Medidas reales con la build de perfilado: actualDuration cae a 0,1 ms con memo, y baseDuration sigue en 8 ms.

Parámetro de onRender Qué indica
id La prop id del <Profiler>
phase "mount", "update" o "nested-update"
actualDuration Milisegundos usados en esta actualización
baseDuration Estimación del coste de renderizar todo el subárbol sin optimizar
startTime Marca de tiempo del inicio del render
commitTime Marca de tiempo del commit, compartida por todos los Profiler

La clave es comparar las dos duraciones. Si actualDuration es mucho menor que baseDuration, tu memorización funciona. Si son parecidas en cada actualización, todo el subárbol se está renderizando.

Medir en producción 🔄 19.0

🔄 Cambia en React 19.0. El cliente de react-dom es desde 19.0 un módulo propio, así que el alias se pone sobre react-dom/client. Si usas una versión anterior: en React 18, react-dom/client importa internamente react-dom, y el alias exacto se pone sobre react-dom: { find: /^react-dom$/, replacement: 'react-dom/profiling' }.

<Profiler> no hace nada en una build normal: en nuestra prueba, onRender se llamó cero veces. Para medir en producción hay que usar la build de perfilado de React, con un alias en Vite:

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { 'react-dom/client': 'react-dom/profiling' },
  },
});

Tiene un pequeño coste: en nuestra aplicación de prueba pasó de 210,09 kB a 227,57 kB. Actívala para investigar, no por defecto.

Performance Tracks 🆕 19.2

🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: usa el Profiler de React DevTools y <Profiler>. Pierdes ver el trabajo de React en la misma línea de tiempo que la red y el JavaScript del navegador.

El panel Performance de Chrome muestra todo lo que hace el navegador. React 19.2 añade allí sus propias pistas, para que veas sus tareas junto con todo lo demás. No necesitas instalar nada ni escribir código:

  1. Abre las herramientas de desarrollo y entra en Performance.
  2. Pulsa grabar, haz la interacción y detén la grabación.
  3. Despliega los grupos con el símbolo ⚛.

Esquema de las pistas Scheduler, con Blocking, Transition, Suspense e Idle, y Components con un diagrama de llamas

Figura 9. Esquema de la disposición de las pistas: no es una captura. Los nombres son los que aparecen en una traza real de nuestro ejemplo Cascada.

Scheduler ⚛ muestra qué hace React según la prioridad del trabajo:

Subpista Qué contiene
Blocking Actualizaciones síncronas, como las de un clic
Transition Trabajo no bloqueante (módulo 15)
Suspense Trabajo relacionado con límites de Suspense
Idle Trabajo de la prioridad más baja

Dentro de cada una verás el evento que lo provocó (Event: click), y fases como Update, Render, Commit y Remaining Effects. Es muy útil cuando la pregunta es “¿qué pasa entre el clic y la pantalla?”.

Components ⚛ es un diagrama de llamas con la duración de cada componente, incluidos sus efectos cuando duran 0,05 ms o más. Existe además un grupo Server para Server Components, que tocaremos en el módulo 23.

Registramos una traza con Chromium en cada tipo de build, y estas son las pistas que aparecieron:

Build Scheduler ⚛ Components ⚛
Desarrollo, 19.1.10 No No
Desarrollo, 19.2.9 Sí Sí
Desarrollo, 19.3.1 Sí Sí
Perfilado, 19.3.1 Sí No
Producción normal, 19.3.1 No No

Según la documentación, en la build de perfilado la pista de componentes se muestra para lo que esté dentro de un <Profiler>, o si tienes activa la extensión React DevTools.

Herramienta Mejor para Dónde funciona
React DevTools Profiler Saber qué componente se renderizó y por qué Desarrollo, build de perfilado
<Profiler> Medir desde código, comparar y enviar métricas Desarrollo, build de perfilado
Performance Tracks Ver React junto a la red, el JavaScript y el pintado Desarrollo, build de perfilado
console.time Medir un cálculo concreto En todas partes

10. Listas grandes

La memorización no resuelve este caso. Si muestras 10 000 filas, en algún momento hay que crear 10 000 filas y mantenerlas en el DOM, y ese es el trabajo caro. Medimos cuatro soluciones con producción, cargando los empleados de la API y multiplicándolos hasta el total indicado:

Técnica Filas Tiempo hasta verse Nodos en el DOM Memoria aprox.
Lista completa 10 000 2 879 ms 50 017 26 MB
content-visibility 10 000 1 670 ms 50 017 26 MB
Ventana hecha a mano 10 000 66 ms 123 10 MB
TanStack Virtual 10 000 86 ms 98 10 MB
Lista completa 100 000 30 597 ms 500 017 202 MB
Ventana hecha a mano 100 000 148 ms 123 10 MB
TanStack Virtual 100 000 157 ms 98 12 MB

Son medidas sueltas en un equipo concreto, así que fíjate en el orden de magnitud. Con la lista completa, multiplicar los datos por diez multiplica el coste por diez. Con la virtualización, el tiempo apenas cambia.

Primera opción: no mostrarlas todas

Una persona no va a leer 10 000 filas. Antes de virtualizar, pregúntate si no bastaría con filtrar y paginar. La API EMPRESA pagina en el servidor (?pagina=2&tamano=20), y el módulo 19 explica cómo hacerlo con una caché. Enviar menos datos es siempre lo mejor: ahorras red, memoria y render a la vez.

content-visibility, solo con CSS

content-visibility: auto pide al navegador que no calcule el diseño ni el pintado de lo que está fuera de la pantalla. contain-intrinsic-size le indica cuánto mide cada elemento mientras tanto, para que la barra de desplazamiento sea correcta.

<div
  className="fila-lista"
  style={{ height: 36, contentVisibility: 'auto', containIntrinsicSize: 'auto 36px' }}
/>

No toca React, y encaja bien en listas de unos cientos o miles de elementos. En la prueba bajó casi a la mitad, pero sigue habiendo 50 017 nodos, porque React crea todo igualmente.

La idea de la virtualización

Virtualizar (también windowing) significa crear solo las filas que se ven, más unas pocas de margen. Un contenedor interior mide lo mismo que medirían todas las filas juntas, para que la barra de desplazamiento sea real. Al desplazar, se calcula qué filas tocan y se colocan con posicionamiento absoluto.

Lista virtualizada: el contenedor interior mide 360 000 px y solo existen las filas cercanas al visor

Figura 10. El visor enseña unas filas; el resto de la altura es un hueco sin nodos.

Hacerlo a mano ayuda a entenderlo, y son pocas líneas:

import { useState } from 'react';
import type { Empleado } from './util';

const ALTO_FILA = 36;
const ALTO_VISOR = 396;
const MARGEN = 5;

function ListaVentana({ empleados }: { empleados: Empleado[] }) {
  const [desplazamiento, setDesplazamiento] = useState(0);

  const primera = Math.max(0, Math.floor(desplazamiento / ALTO_FILA) - MARGEN);
  const visiblesEnVisor = Math.ceil(ALTO_VISOR / ALTO_FILA);
  const ultima = Math.min(empleados.length, primera + visiblesEnVisor + 2 * MARGEN);

  return (
    <div
      className="visor"
      style={{ height: ALTO_VISOR }}
      onScroll={(e) => setDesplazamiento(e.currentTarget.scrollTop)}
    >
      <div style={{ height: empleados.length * ALTO_FILA, position: 'relative' }}>
        {empleados.slice(primera, ultima).map((e, i) => (
          <div
            key={e.empId}
            className="fila-lista"
            style={{
              position: 'absolute',
              top: (primera + i) * ALTO_FILA,
              left: 0,
              right: 0,
              height: ALTO_FILA,
            }}
          >
            <span>{e.empId}</span>
            <span>
              {e.nombre} {e.apellidos}
            </span>
            <span>{e.departamento}</span>
            <span>{e.salario} €</span>
          </div>
        ))}
      </div>
    </div>
  );
}

Todo se reduce a tres cuentas: la primera fila sale de dividir el desplazamiento entre el alto de fila, la última de sumar las filas que caben en el visor, y la posición de cada fila es su índice por su altura.

TanStack Virtual

En un proyecto real, usa una biblioteca, que se ocupa de lo que el ejemplo anterior no hace: alturas variables, listas horizontales, redimensionado y desplazamiento a un elemento. Una de las más usadas es TanStack Virtual:

npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import type { Empleado } from './util';

const ALTO_FILA = 36;

function ListaTanStack({ empleados }: { empleados: Empleado[] }) {
  const visor = useRef<HTMLDivElement>(null);

  const virtualizador = useVirtualizer({
    count: empleados.length,
    getScrollElement: () => visor.current,
    estimateSize: () => ALTO_FILA,
    overscan: 5,
  });

  return (
    <div className="visor" ref={visor} style={{ height: 396 }}>
      <div style={{ height: virtualizador.getTotalSize(), position: 'relative' }}>
        {virtualizador.getVirtualItems().map((elemento) => {
          const empleado = empleados[elemento.index];
          return (
            <div
              key={empleado.empId}
              className="fila-lista"
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                right: 0,
                height: elemento.size,
                transform: `translateY(${elemento.start}px)`,
              }}
            >
              <span>{empleado.empId}</span>
              <span>
                {empleado.nombre} {empleado.apellidos}
              </span>
              <span>{empleado.departamento}</span>
              <span>{empleado.salario} €</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Lista de 10 000 empleados desplazada hasta la fila 102000 mostrando once filas

Figura 11. Lista virtualizada real con 3.14.25 de TanStack Virtual, desplazada por la mitad de la lista.

Opción Parámetro Para qué
count Número Total de filas
getScrollElement Función Devuelve el elemento que hace scroll
estimateSize Función Altura estimada de cada fila
overscan Número Filas extra por arriba y por abajo

Alternativa: react-window 2 ofrece componentes listos, List y Grid, en lugar de un hook.

⚠️ Importante: La virtualización tiene un precio. Lo que no está en el DOM no existe para Ctrl+F ni para quien usa un lector de pantalla, a menos que lo gestiones. El foco se pierde si la fila que lo tiene sale del visor y se desmonta, y las key deben ser identificadores estables, como en cualquier lista. Si puedes paginar, pagina.


11. División de código

Por defecto Vite empaqueta tu aplicación en un solo fichero JavaScript. El navegador debe descargarlo, analizarlo y ejecutarlo antes de que aparezca nada, aunque el usuario solo quiera ver la página de inicio. Dividir el código consiste en separar en trozos lo que no hace falta al principio, y descargarlo cuando se necesite.

La pieza fundamental es el import() dinámico de JavaScript, que devuelve una promesa. Vite detecta cada import() y crea un fichero aparte.

lazy y Suspense

lazy convierte un import() en un componente, y Suspense muestra algo mientras se descarga. Suspense y lazy se estudian a fondo en el módulo 15. Aquí nos interesa el efecto sobre los ficheros.

En este ejemplo el panel usa Chart.js, una biblioteca grande que solo necesitan quienes abren esa pestaña (npm install chart.js):

// src/PanelEstadisticas.tsx
import { BarController, BarElement, CategoryScale, Chart, LinearScale, Tooltip } from 'chart.js';
import { useEffect, useRef } from 'react';

Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip);

interface Estadisticas {
  porDepartamento: { nombre: string; salarioMedio: number }[];
}

export default function PanelEstadisticas() {
  const lienzo = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    let grafica: Chart | undefined;
    const control = new AbortController();

    fetch('http://localhost:3000/api/estadisticas', { signal: control.signal })
      .then((r) => r.json() as Promise<Estadisticas>)
      .then((datos) => {
        grafica = new Chart(lienzo.current!, {
          type: 'bar',
          data: {
            labels: datos.porDepartamento.map((d) => d.nombre),
            datasets: [
              {
                label: 'Salario medio',
                data: datos.porDepartamento.map((d) => d.salarioMedio),
                backgroundColor: '#3f51b5',
              },
            ],
          },
          options: { animation: false },
        });
      })
      .catch(() => {});

    return () => {
      control.abort();
      grafica?.destroy();
    };
  }, []);

  return <canvas ref={lienzo} width={600} height={260} />;
}
// src/exportar.ts
export function exportarCsv(filas: { nombre: string; salario: number }[]) {
  const csv = ['nombre;salario', ...filas.map((f) => `${f.nombre};${f.salario}`)].join('\n');
  const enlace = document.createElement('a');
  enlace.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
  enlace.download = 'empleados.csv';
  enlace.click();
}
// src/App.tsx
import { lazy, Suspense, useState } from 'react';

const PanelEstadisticas = lazy(() => import('./PanelEstadisticas'));

const precargarPanel = () => import('./PanelEstadisticas');

export default function App() {
  const [vista, setVista] = useState<'inicio' | 'panel'>('inicio');

  async function descargar() {
    const { exportarCsv } = await import('./exportar');
    exportarCsv([{ nombre: 'Ana', salario: 6500 }]);
  }

  return (
    <main className="tarjeta">
      <h1>EMPRESA</h1>
      <nav className="fila">
        <button onClick={() => setVista('inicio')}>Inicio</button>
        <button onMouseEnter={precargarPanel} onFocus={precargarPanel} onClick={() => setVista('panel')}>
          Panel
        </button>
        <button onClick={descargar}>Exportar CSV</button>
      </nav>

      {vista === 'inicio' && <p>Bienvenida a la gestión de EMPRESA.</p>}
      {vista === 'panel' && (
        <Suspense fallback={<p>Cargando panel...</p>}>
          <PanelEstadisticas />
        </Suspense>
      )}
    </main>
  );
}

npm run build genera estos ficheros:

dist/assets/exportar-JEhkn6_m.js             0.25 kB │ gzip:  0.21 kB
dist/assets/PanelEstadisticas-By7ppfRA.js  150.25 kB │ gzip: 52.42 kB
dist/assets/index-5QgvsRYJ.js              221.91 kB │ gzip: 69.55 kB

Y esto es lo que descargó realmente el navegador, paso a paso:

Acción Ficheros JavaScript descargados
Abrir la página index-5QgvsRYJ.js
Pulsar Exportar CSV exportar-JEhkn6_m.js
Pasar el ratón sobre Panel PanelEstadisticas-By7ppfRA.js
Pulsar Panel Ninguno: ya estaba en caché

El usuario de la página de inicio se ahorra 150 kB, y quien abre el panel no nota la descarga porque ha empezado al pasar el ratón. Llamar a import() varias veces no descarga el fichero otra vez: el navegador recuerda el módulo.

Panel de EMPRESA con el gráfico de barras de salario medio por departamento

Figura 12. El panel, con datos reales de /api/estadisticas, cargado en un fichero aparte.

Hay tres errores típicos con lazy:

  • Declararlo dentro de un componente. Cada render crearía un componente nuevo y se perdería el estado. Va siempre fuera, en el nivel superior del módulo.
  • Exportaciones con nombre. lazy exige una exportación por defecto. Si no la hay: lazy(() => import('./x').then((m) => ({ default: m.VistaPedidos }))).
  • Dividir lo mínimo. Cada trozo es una petición de red. Divide por rutas y por funciones pesadas, no por cada botón. La carga perezosa de rutas se verá en el módulo 18.

Qué ocupa cada cosa

rollup-plugin-visualizer dibuja el contenido de cada fichero. Funciona con Vite 8:

npm install -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer';

// en plugins:
visualizer({ filename: 'stats.html', gzipSize: true, template: 'treemap' })

Al hacer la build se genera stats.html, que puedes abrir en el navegador:

Mapa de rectángulos: un bloque con chart.js en PanelEstadisticas, y otro con react-dom en index

Figura 13. Mapa real de la build: a la izquierda Chart.js, en su propio fichero; a la derecha, casi todo el fichero principal es react-dom. Hemos sustituido en la imagen las rutas locales por “proyecto”.

Es la mejor forma de descubrir qué biblioteca pesa más de la cuenta, o qué dependencia entró por accidente. Importa solo lo que uses: import { BarController } from 'chart.js' deja fuera lo que no se use, y no pasa lo mismo si registras con chart.js/auto todos los tipos de gráfico.

Separar las dependencias en trozos propios

El código de react cambia menos que el tuyo. Si va en un fichero aparte, con cada versión de tu aplicación el navegador podrá conservar su caché de React y descargar solo lo que cambia. En Vite 8, que usa Rolldown, se configura con codeSplitting:

export default defineConfig({
  plugins: [react()],
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            { name: 'react', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ },
            { name: 'graficas', test: /node_modules[\\/]chart\.js[\\/]/ },
          ],
        },
      },
    },
  },
});
dist-trozos/assets/exportar-JEhkn6_m.js           0.25 kB │ gzip:  0.21 kB
dist-trozos/assets/PanelEstadisticas-BtqtyqXg.js  0.68 kB │ gzip:  0.45 kB
dist-trozos/assets/index-CWt1y1cv.js              3.47 kB │ gzip:  1.61 kB
dist-trozos/assets/graficas-plcCeT5c.js         149.68 kB │ gzip: 52.12 kB
dist-trozos/assets/react-C85Qt7ft.js            218.72 kB │ gzip: 68.25 kB

Tu código ahora son unos pocos kB. La opción manualChunks que verás en muchas guías es de Rollup: Rolldown la mantiene como compatibilidad, pero está marcada como obsoleta y no admite la forma de objeto.

Si falla la descarga de un trozo

Cuando publicas una versión nueva, los ficheros cambian de nombre, y quien tuviera la página abierta pedirá un trozo que ya no existe. Vite lanza el evento vite:preloadError, y una solución sencilla es recargar:

window.addEventListener('vite:preloadError', () => {
  window.location.reload();
});

Un import() que falla también hace que lazy lance un error, que se captura con un Error Boundary (módulo 15).


12. Errores frecuentes

Error Por qué ocurre Solución
Medir en npm run dev El modo de desarrollo es mucho más lento Mide con npm run build y npm run preview
Creer que un hijo se renderiza porque cambian sus props Se renderiza porque lo hace su padre Usa el Profiler y lee el motivo
memo sin efecto Objetos o funciones nuevos en las props Estabilízalos, o usa el compilador
useCallback en un <button> Un elemento HTML no compara props Quítalo
useMemo para algo trivial La comparación y la caché cuestan más que el cálculo Calcula directamente
Comparador de memo incompleto Falta una prop, y el dato queda desactualizado Evítalo, o compara todas las props
useMemo con efectos secundarios El cálculo debe ser puro Usa useEffect
Escribir en una ref durante el render Rompe las reglas, y el compilador no lo compila Escribe en un efecto o en un evento
Olvidar target en React 18 No existe react/compiler-runtime Pon target: '18' e instala el runtime
react-compiler-runtime en devDependencies Hace falta en ejecución Instálalo como dependencia normal
Dejar un 'use no memo' para siempre El fallo real sigue ahí Corrige la causa y quita la directiva
Declarar lazy dentro de un componente Crea un componente nuevo en cada render Decláralo fuera
Virtualizar 50 filas Pierdes Ctrl+F y añades complejidad Solo cuando el DOM sea un problema real

13. Ejercicios

Ejercicio 1: Predice los renders

Con este árbol, indica cuántas veces se habrá renderizado cada componente tras dos clics, sin compilador. Después compruébalo con useContadorRenders.

const Memorizado = memo(function Memorizado({ datos }: { datos: string[] }) {
  return <p>{datos.length}</p>;
});

function Normal() {
  return <p>Normal</p>;
}

function App() {
  const [n, setN] = useState(0);
  const [lista] = useState(['a']);
  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Normal />
      <Memorizado datos={lista} />
      <Memorizado datos={['a']} />
    </>
  );
}
Ver solución
Componente Renders Motivo
App 3 El primero, más dos por el estado
Normal 3 Se renderiza su padre, y no tiene memo
Memorizado con lista 1 lista viene del estado y conserva su referencia
Memorizado con ['a'] 3 Es un array nuevo en cada render

El tercer caso es el de la sección 4. El contenido es igual, pero Object.is compara referencias.

Ejercicio 2: Seleccionar una fila sin renderizarlas todas

Una lista de cuatro empleados debe resaltar el seleccionado. Consigue que, al pulsar uno, solo se rendericen las dos filas afectadas: la que deja de estar seleccionada y la nueva.

Ver solución
import { memo, useCallback, useState } from 'react';
import { useContadorRenders } from './util';

interface Empleado {
  empId: number;
  nombre: string;
}

const EMPLEADOS: Empleado[] = [
  { empId: 100, nombre: 'Ana' },
  { empId: 101, nombre: 'Luis' },
  { empId: 102, nombre: 'Marta' },
  { empId: 103, nombre: 'Pablo' },
];

interface FilaProps {
  empleado: Empleado;
  seleccionado: boolean;
  alSeleccionar: (id: number) => void;
}

const FilaEmpleado = memo(function FilaEmpleado({ empleado, seleccionado, alSeleccionar }: FilaProps) {
  const insignia = useContadorRenders();
  return (
    <li className="fila">
      <button onClick={() => alSeleccionar(empleado.empId)}>{empleado.nombre}</button>
      {seleccionado && <strong>Seleccionado</strong>}
      <span className="insignia" ref={insignia} />
    </li>
  );
});

export default function ListaEmpleados() {
  const [seleccionado, setSeleccionado] = useState<number | null>(null);

  // setSeleccionado es estable: esta función no necesita dependencias.
  const alSeleccionar = useCallback((id: number) => setSeleccionado(id), []);

  return (
    <ul className="tarjeta">
      {EMPLEADOS.map((e) => (
        <FilaEmpleado
          key={e.empId}
          empleado={e}
          seleccionado={e.empId === seleccionado}
          alSeleccionar={alSeleccionar}
        />
      ))}
    </ul>
  );
}

Al pulsar Luis y después Marta, los contadores son: Ana 1, Luis 3, Marta 2 y Pablo 1. Cada fila recibe un booleano, no el identificador seleccionado. Es lo que permite el truco: si pasaras seleccionado={seleccionado} a todas las filas, todas cambiarían.

Ejercicio 3: Estado bien colocado

Ficha contiene un campo texto para notas y un componente HistorialEmpleado lento, que no usa texto. Reorganiza el código, sin usar memo, para que escribir no renderice el historial.

function Ficha() {
  const [texto, setTexto] = useState('');
  return (
    <>
      <textarea value={texto} onChange={(e) => setTexto(e.target.value)} />
      <HistorialEmpleado />
    </>
  );
}
Ver solución

Basta con mover el estado a un componente hermano:

function Notas() {
  const [texto, setTexto] = useState('');
  return <textarea value={texto} onChange={(e) => setTexto(e.target.value)} />;
}

function Ficha() {
  return (
    <>
      <Notas />
      <HistorialEmpleado />
    </>
  );
}

Ficha deja de tener estado, así que no vuelve a renderizarse al escribir, y tampoco HistorialEmpleado. Es el caso B de la sección 3, cuyo contador pasó de 4 a 1.

Ejercicio 4: ¿Merece la pena useMemo?

Decide en qué casos usarías useMemo, y explica por qué:

  1. const total = precio * cantidad;
  2. Filtrar y ordenar 50 000 empleados en un componente que se renderiza en cada tecla de otro campo.
  3. const nombreCompleto = ${e.nombre} ${e.apellidos};
  4. Un objeto de opciones que se pasa a un componente memo caro.
  5. Un objeto que se usa solo dentro de un useEffect.
Ver solución
  1. No. Es una multiplicación, y además devuelve un número, que se compara por valor.
  2. Sí. Medimos 67 ms por render frente a 0 ms. Esto da un motivo real.
  3. No. Es trivial, y un texto se compara por valor.
  4. Sí, porque de lo contrario memo no sirve (caso 4 de la sección 4). Si el objeto solo tiene valores fijos, mejor aún: sácalo fuera del componente, como constante.
  5. No. Mételo dentro del efecto y depende de los valores primitivos.

Con el compilador activo, ninguno de estos casos necesita useMemo para las referencias.

Ejercicio 5: Comparador personalizado

Cada vez que Recarga pide los datos al servidor, recibe un objeto nuevo con los mismos valores. Crea un memo con comparador para que TarjetaSalario no se renderice si los datos no cambian, y comprueba que subir el sueldo sí actualiza la pantalla.

Ver solución
import { memo, useState } from 'react';
import { useContadorRenders } from './util';

interface Empleado {
  empId: number;
  nombre: string;
  salario: number;
}

interface Props {
  empleado: Empleado;
}

function TarjetaSalario({ empleado }: Props) {
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>
        {empleado.nombre}: {empleado.salario} €
      </h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
}

const TarjetaMemo = memo(
  TarjetaSalario,
  (antes, despues) =>
    antes.empleado.empId === despues.empleado.empId &&
    antes.empleado.nombre === despues.empleado.nombre &&
    antes.empleado.salario === despues.empleado.salario,
);

const leerDelServidor = (): Empleado => ({ empId: 100, nombre: 'Ana', salario: 6500 });

export default function Recarga() {
  const [empleado, setEmpleado] = useState(leerDelServidor);

  return (
    <main>
      <button onClick={() => setEmpleado(leerDelServidor())}>Recargar</button>
      <button onClick={() => setEmpleado({ ...empleado, salario: empleado.salario + 100 })}>Subir sueldo</button>
      <TarjetaMemo empleado={empleado} />
    </main>
  );
}

Tras dos recargas, el contador sigue en 1. Al subir el sueldo pasa a 2, y la pantalla muestra 6600 €. Si añades un campo a Empleado y a la tarjeta, deberás acordarte de añadirlo al comparador: ese es el riesgo de esta técnica. En una aplicación real es mejor que la capa de datos conserve las referencias, algo que TanStack Query hace por ti (módulo 19).

Ejercicio 6: Contextos separados

Crea dos contextos, sesión y tema, y dos componentes con memo, cada uno de los cuales lea solo uno. Cambiar el tema no debe renderizar el componente de la sesión. El ejemplo usa <Contexto value>, 🆕 19.0: en versiones anteriores escribe <Contexto.Provider value>.

Ver solución
import { createContext, memo, useContext, useMemo, useState, type ReactNode } from 'react';
import { useContadorRenders } from './util';

type Tema = 'claro' | 'oscuro';

interface ValorTema {
  tema: Tema;
  cambiarTema: () => void;
}

const ContextoTema = createContext<ValorTema | null>(null);
const ContextoSesion = createContext<string>('invitado');

function useTema() {
  const valor = useContext(ContextoTema);
  if (!valor) throw new Error('useTema exige un ProveedorTema');
  return valor;
}

function ProveedorTema({ children }: { children: ReactNode }) {
  const [tema, setTema] = useState<Tema>('claro');
  const valor = useMemo(
    () => ({ tema, cambiarTema: () => setTema((t) => (t === 'claro' ? 'oscuro' : 'claro')) }),
    [tema],
  );
  return <ContextoTema value={valor}>{children}</ContextoTema>;
}

const BotonTema = memo(function BotonTema() {
  const { tema, cambiarTema } = useTema();
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <button onClick={cambiarTema}>Tema: {tema}</button>
      <span className="insignia" ref={insignia} />
    </section>
  );
});

const Usuario = memo(function Usuario() {
  const nombre = useContext(ContextoSesion);
  const insignia = useContadorRenders();
  return (
    <section className="tarjeta fila">
      <h2>{nombre}</h2>
      <span className="insignia" ref={insignia} />
    </section>
  );
});

export default function App() {
  return (
    <ContextoSesion value="ana.garcia@empresa.es">
      <ProveedorTema>
        <BotonTema />
        <Usuario />
      </ProveedorTema>
    </ContextoSesion>
  );
}

Tras dos cambios de tema: BotonTema marca 3 y Usuario marca 1. memo no impide el render de BotonTema, porque lee el contexto que cambia. Usuario no se entera, porque lee otro. ProveedorTema recibe children desde App, así que al cambiar su estado no recrea a sus hijos: es la técnica de la sección 3.

Ejercicio 7: Corrige lo que el compilador rechaza

Reescribe MutaProp, LeeRef, DepsManuales, ComponenteDentro y BucleRender, de la sección 8, de forma que cumplan las reglas. Compruébalo con los diagnósticos.

Ver solución
import { useEffect, useMemo, useRef, useState } from 'react';

interface Empleado {
  nombre: string;
  salario: number;
}

// Calcula un valor nuevo en lugar de modificar la prop.
export function MutaProp({ empleado }: { empleado: Empleado }) {
  const salarioNuevo = empleado.salario + 100;
  return <p>{salarioNuevo}</p>;
}

// La ref se escribe en un efecto, y se lee en un evento.
export function LeeRef() {
  const veces = useRef(0);
  const [mostrado, setMostrado] = useState(0);

  useEffect(() => {
    veces.current += 1;
  });

  return <button onClick={() => setMostrado(veces.current)}>Renders: {mostrado}</button>;
}

// Las dependencias coinciden con lo que se usa dentro.
export function DepsManuales({ empleado }: { empleado: Empleado }) {
  const nombre = useMemo(() => empleado.nombre.toUpperCase(), [empleado.nombre]);
  return <p>{nombre}</p>;
}

// El componente va fuera, y recibe lo que necesita por props.
function Hijo({ nombre }: { nombre: string }) {
  return <b>{nombre}</b>;
}

export function ComponenteDentro({ nombre }: { nombre: string }) {
  return <Hijo nombre={nombre} />;
}

// Si un dato se puede calcular de una prop, no es estado.
export function BucleRender({ valor }: { valor: number }) {
  return <p>{valor}</p>;
}

Con el logger, todas las funciones dan CompileSuccess. En DepsManuales, con el compilador ya puedes quitar useMemo y dejar const nombre = empleado.nombre.toUpperCase();.

Ejercicio 8: Buscador sobre una lista virtualizada

Carga empleados de la API, multiplícalos hasta 20 000 y muéstralos con useVirtualizer y un campo de búsqueda. Al escribir, la lista debe volver arriba.

Ver solución
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useRef, useState } from 'react';
import { cargarEmpleados, multiplicar, type Empleado } from './util';

const ALTO_FILA = 36;

export default function BuscadorVirtual() {
  const [empleados, setEmpleados] = useState<Empleado[]>([]);
  const [texto, setTexto] = useState('');
  const visor = useRef<HTMLDivElement>(null);

  useEffect(() => {
    cargarEmpleados().then((base) => setEmpleados(multiplicar(base, 20_000)));
  }, []);

  const buscado = texto.toLowerCase();
  const visibles = empleados.filter((e) => `${e.nombre} ${e.apellidos}`.toLowerCase().includes(buscado));

  const virtualizador = useVirtualizer({
    count: visibles.length,
    getScrollElement: () => visor.current,
    estimateSize: () => ALTO_FILA,
    overscan: 5,
  });

  return (
    <main className="tarjeta">
      <input
        value={texto}
        onChange={(e) => {
          setTexto(e.target.value);
          visor.current?.scrollTo({ top: 0 });
        }}
        placeholder="Buscar"
      />
      <p>{visibles.length} empleados</p>
      <div className="visor" ref={visor} style={{ height: 360 }}>
        <div style={{ height: virtualizador.getTotalSize(), position: 'relative' }}>
          {virtualizador.getVirtualItems().map((fila) => {
            const empleado = visibles[fila.index];
            return (
              <div
                key={empleado.empId}
                className="fila-lista"
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  right: 0,
                  height: fila.size,
                  transform: `translateY(${fila.start}px)`,
                }}
              >
                <span>{empleado.empId}</span>
                <span>
                  {empleado.nombre} {empleado.apellidos}
                </span>
                <span>{empleado.departamento}</span>
                <span>{empleado.salario} €</span>
              </div>
            );
          })}
        </div>
      </div>
    </main>
  );
}

Hay 20 000 empleados pero solo 15 filas en el DOM. Escribiendo ana después de desplazar, quedaron 834 empleados y el visor volvió a la posición 0. El cálculo de visibles se repite en cada render, pero aquí es pequeño. Si fuera caro, usarías useDeferredValue (módulo 15).

Ejercicio 9: Pestañas con carga perezosa

Crea dos vistas en ficheros aparte. VistaPedidos usa exportación con nombre y VistaClientes por defecto. Cárgalas con lazy, y precarga cada una al pasar el ratón o llevar el foco al botón.

Ver solución
// src/vistas/pedidos.tsx
export function VistaPedidos() {
  return <p>Pedidos pendientes de EMPRESA</p>;
}
// src/vistas/clientes.tsx
export default function VistaClientes() {
  return <p>Clientes de EMPRESA</p>;
}
// src/App.tsx
import { lazy, Suspense, useState } from 'react';

// VistaPedidos es una exportación con nombre: lazy exige "default".
const cargarPedidos = () => import('./vistas/pedidos').then((m) => ({ default: m.VistaPedidos }));
const cargarClientes = () => import('./vistas/clientes');

const VISTAS = {
  pedidos: { titulo: 'Pedidos', cargar: cargarPedidos, Componente: lazy(cargarPedidos) },
  clientes: { titulo: 'Clientes', cargar: cargarClientes, Componente: lazy(cargarClientes) },
};

type Vista = keyof typeof VISTAS;

export default function Pestanas() {
  const [activa, setActiva] = useState<Vista>('pedidos');
  const { Componente } = VISTAS[activa];

  return (
    <main className="tarjeta">
      <nav className="fila">
        {(Object.keys(VISTAS) as Vista[]).map((v) => (
          <button key={v} onMouseEnter={VISTAS[v].cargar} onFocus={VISTAS[v].cargar} onClick={() => setActiva(v)}>
            {VISTAS[v].titulo}
          </button>
        ))}
      </nav>
      <Suspense fallback={<p>Cargando...</p>}>
        <Componente />
      </Suspense>
    </main>
  );
}

En la prueba, pedidos se descarga al abrir, porque es la vista activa, y clientes al pasar el ratón. Al pulsar la pestaña no se descarga nada. lazy se llama en la definición de VISTAS, en el nivel superior del módulo, que es lo que exige.

Ejercicio 10 (propuesto): Activa el compilador

Toma los ejemplos de la sección 4 y activa React Compiler con Babel. Elimina memo, useMemo y useCallback, y compara los contadores antes y después. Busca memo_cache_sentinel en dist/assets para comprobar qué funciones se han compilado, y añade un logger que cuente los CompileSuccess.

Ejercicio 11 (propuesto): Informe de rendimiento

Coge una pantalla de tu proyecto con una tabla de empleados, añade <Profiler> alrededor de la tabla y activa la build de perfilado. Compara actualDuration y baseDuration antes y después de aplicar una de las técnicas del módulo. Si usas React 19.2 o posterior, graba la misma interacción en el panel Performance y localiza en la pista Components ⚛ cuál es el componente que más tarda.


Resumen

Concepto API Idea clave
Medir primero npm run build, preview Las cifras solo valen en producción
Motivos de render Estado, padre, contexto Las props distintas no son la causa: el render baja desde el padre
Render y commit   Renderizar no es tocar el DOM
Colocar el estado children Sin coste y antes que nada
Saltar un componente memo Compara cada prop con Object.is
Comparador memo(C, iguales) Devuelve true para saltar; peligroso si falta una prop
Cálculos y objetos useMemo Solo si es caro, o si importa la referencia
Funciones estables useCallback No evita crearlas; solo sirve si alguien compara la referencia
Contexto useMemo para value Salta memo; separa por frecuencia de cambio
Compilador babel-plugin-react-compiler Memoriza en la build, función a función
Vite 8 reactCompilerPreset() Con @rolldown/plugin-babel; la opción babel de react() ya no existe
Reglas eslint-plugin-react-hooks 7 Si rompes una regla, esa función no se compila, sin avisar
Diagnóstico logger, reportDiagnostics, panicThreshold Para saber qué ha quedado sin compilar
Excluir o anotar 'use no memo', 'use memo' Adopción gradual con compilationMode: 'annotation'
React 17 y 18 react-compiler-runtime, target Comprobado; dependencia normal, y target obligatorio
Profiler visual React DevTools Qué se renderiza, y por qué
Profiler por código <Profiler onRender> Compara actualDuration y baseDuration
Perfilado en producción react-dom/profiling Sin alias, onRender no se llama
Línea de tiempo Performance Tracks 🆕 19.2 React junto a la red y el JavaScript del navegador
Listas content-visibility, useVirtualizer Mejor paginar; si no, solo crear lo visible
Dividir el código lazy, Suspense, import() Solo se descarga lo que se usa
Trozos en Vite 8 codeSplitting.groups Sustituye a manualChunks