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,useMemoyuseCallback, 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'ypanicThreshold - 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-visibilityy 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:
- Reproduce el problema y mídelo con una cifra.
- Localiza la causa con una herramienta, no con una corazonada.
- Aplica el arreglo más sencillo posible.
- Vuelve a medir para confirmar que ha mejorado.
⚠️ Importante: Mide siempre sobre la versión de producción (
npm run buildynpm 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 connpm run devpuede 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.

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.
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.
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>
);
}

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:
- Evitar un cálculo caro que no ha cambiado.
- Conservar una referencia estable, para que un
memoo unuseEffectno 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 enmemo.
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
useEffecto de otrouseMemo. - 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
valuedel 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.
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-react5 o anterior. La opciónbabelse 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 |

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

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
onClickdepende dealElegiry deempleado.empId. El compilador ha llegado a un detalle más fino que el objetoempleadoentero, algo que muy pocas personas harían a mano. - La plantilla de texto
etiquetano se memoriza: es barata y su resultado es un texto, que se compara por valor. - El JSX se memoriza según
etiquetayt1. 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_sentinelo_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: instalareact-compiler-runtimecomo dependencia normal, no de desarrollo, y añadetarget: '18'otarget: '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:
- Abre los ajustes (la rueda), entra en Profiler y activa Record why each component rendered while profiling.
- Pulsa el botón redondo de grabar.
- Haz en la aplicación lo que va lento, y detén la grabación.
- Navega por los commits con la barra superior derecha: cada barra es un commit, y su altura indica lo que tardó.
- 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.
- La vista Ranked ordena los componentes de más a menos lentos.
- 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 <Profiler></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:

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-domes desde 19.0 un módulo propio, así que el alias se pone sobrereact-dom/client. Si usas una versión anterior: en React 18,react-dom/clientimporta internamentereact-dom, y el alias exacto se pone sobrereact-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:
- Abre las herramientas de desarrollo y entra en Performance.
- Pulsa grabar, haz la interacción y detén la grabación.
- Despliega los grupos con el símbolo ⚛.
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.
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>
);
}

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+Fni 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 laskeydeben 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.

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.
lazyexige 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:

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é:
const total = precio * cantidad;- Filtrar y ordenar 50 000 empleados en un componente que se renderiza en cada tecla de otro campo.
const nombreCompleto =${e.nombre} ${e.apellidos};- Un objeto de opciones que se pasa a un componente
memocaro. - Un objeto que se usa solo dentro de un
useEffect.
Ver solución
- No. Es una multiplicación, y además devuelve un número, que se compara por valor.
- Sí. Medimos 67 ms por render frente a 0 ms. Esto da un motivo real.
- No. Es trivial, y un texto se compara por valor.
- Sí, porque de lo contrario
memono 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. - 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 |