Módulo 15: Suspense y concurrencia
Objetivos del módulo
- Entender qué problema resuelve el renderizado concurrente y qué significa de verdad “interrumpir un render”
- Separar las actualizaciones urgentes de las no urgentes con
useTransitionystartTransition - Retrasar una parte de la interfaz con
useDeferredValuey saber por qué necesitamemo - Elegir entre transición, valor diferido, debounce y throttle
- Declarar estados de carga con
<Suspense>y colocar bien las fronteras - Dividir el código con
lazy, precargarlo y recuperarte cuando falla la descarga - Leer promesas durante el render con
usey conocer su regla más importante: la promesa debe ser estable - Evitar las cascadas de peticiones
- Combinar Suspense con transiciones para que la pantalla no parpadee
- Capturar errores con Error Boundaries, reintentar y saber qué errores no capturan
- Depurar con Owner Stacks y
captureOwnerStack
💡 Consejo: Este módulo mezcla cosas de épocas distintas.
useTransition,useDeferredValue,Suspenseylazyya existían en React 18.3, y las Error Boundaries son aún más antiguas. Lo nuevo va marcado apartado por apartado:use(promesa)y las transiciones asíncronas son de 19.0,captureOwnerStackde 19.1, y las transiciones independientes de 19.3. Las diferencias entre versiones que se citan aquí se han medido en 18.3.1, 19.0.9, 19.2.9 y 19.3.1.
1. Por qué hace falta la concurrencia
En el módulo 14 viste cómo evitar renders innecesarios. Pero hay renders que sí son necesarios y aun así son lentos: abrir una pestaña con 1000 filas, filtrar un listado grande, pintar un gráfico. Ahí memo no ayuda, porque ese trabajo hay que hacerlo.
El problema es que JavaScript se ejecuta en un único hilo, el mismo que atiende los clics, las teclas y el pintado de la página. Si un render tarda un segundo, durante ese segundo el navegador no puede hacer nada más. El usuario pulsa y no pasa nada, escribe y las letras no aparecen.
Hasta React 17, un render empezaba y terminaba de una vez. Desde React 18, React puede renderizar de forma concurrente: hace el trabajo a trozos, devuelve el control al navegador cada pocos milisegundos y, si llega algo más importante, deja lo que estaba haciendo.
Figura 1. Arriba, un render normal retiene el clic hasta acabar. Abajo, un render dentro de una transición cede el hilo y descarta lo que ya no sirve.
Tres ideas para que no te lleves una imagen equivocada:
- No hay paralelismo. Sigue habiendo un solo hilo. React reparte el tiempo: trabaja unos 5 ms (es el intervalo por defecto del paquete
scheduler, el planificador de React), cede el hilo y continúa. - Se interrumpe el render, no el commit. Recuerda las fases del módulo 05. El render calcula, y por eso se puede tirar a la basura sin que nadie se entere. El commit toca el DOM, y ese paso sigue siendo de un solo bloque. Esta es una razón más para que los componentes sean puros: un render concurrente puede ejecutarse a medias y no llegar a la pantalla.
- No es automático. Por defecto, cada actualización sigue siendo urgente y bloqueante. La concurrencia solo se activa cuando tú marcas algo como no urgente.
📘 Concepto: Una actualización urgente refleja lo que el usuario acaba de hacer: la letra que ha tecleado, el botón que se hunde. Debe verse al instante. Una actualización no urgente, o transición, lleva de una vista a otra: el resultado de un filtro, la pestaña nueva. Nadie espera que eso sea instantáneo, y puede abandonarse si llega otra acción.
Prepara el proyecto
Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-15 -- --template react-ts
cd modulo-15
npm install
npm run dev
Descarga la API de ejemplo y arráncala en otra terminal con un retardo de 300 ms por respuesta, para que los estados de carga se vean:
RETARDO=300 node server.mjs
En PowerShell son dos órdenes: $env:RETARDO=300; node server.mjs. La API queda en http://localhost:3000/api.
Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:
: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: 1rem 1.25rem;
max-width: 680px;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
button {
font: inherit;
padding: 0.35rem 0.8rem;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #fff;
color: #283593;
cursor: pointer;
}
button[aria-pressed='true'] {
background: #3f51b5;
color: #fff;
}
input {
font: inherit;
padding: 0.35rem 0.6rem;
border: 1px solid #9fa8da;
border-radius: 6px;
}
ul {
margin: 0;
padding-left: 1.2rem;
}
.pendiente {
opacity: 0.5;
transition: opacity 0.15s;
}
.aviso {
color: #e64a19;
font-size: 0.9rem;
}
.cargando {
color: #5c6bc0;
font-style: italic;
padding: 0.5rem 0;
}
.esqueleto {
height: 1rem;
margin: 0.4rem 0;
border-radius: 4px;
background: #eceff1;
}
.error {
border: 1px solid #ff7043;
background: #fbe9e7;
border-radius: 8px;
padding: 0.75rem 1rem;
}
.error h3 {
margin: 0 0 0.35rem;
color: #bf360c;
font-size: 1rem;
}
Añade también un componente lento a propósito. Ocupa el hilo principal 1 ms por fila, así que pintar las 1000 filas cuesta alrededor de un segundo:
// src/FilasPedidos.tsx
import { memo } from 'react';
// Simula un componente caro: ocupa el hilo principal 1 ms por fila
function FilaPedido({ numero }: { numero: number }) {
const fin = performance.now() + 1;
while (performance.now() < fin) {
// esperamos a propósito
}
return <li>Pedido {numero}</li>;
}
const pedidos = Array.from({ length: 1000 }, (_, i) => 5001 + i);
interface Props {
filtro?: string;
}
export const FilasPedidos = memo(function FilasPedidos({ filtro = '' }: Props) {
const visibles = pedidos.filter((n) => String(n).includes(filtro));
return (
<ul>
{visibles.map((n) => (
<FilaPedido key={n} numero={n} />
))}
</ul>
);
});
oxlint te avisará con su regla purity, porque performance.now() es impura. Tiene razón: en un componente real sería un error. Aquí solo es un simulador de lentitud.
El bloqueo, en vivo
// src/App.tsx
import { useState } from 'react';
import { FilasPedidos } from './FilasPedidos';
type Pestana = 'resumen' | 'pedidos' | 'contacto';
export default function App() {
const [pestana, setPestana] = useState<Pestana>('resumen');
return (
<section className="tarjeta">
<h2>Sin transición</h2>
<div className="fila">
{(['resumen', 'pedidos', 'contacto'] as const).map((p) => (
<button key={p} aria-pressed={p === pestana} onClick={() => setPestana(p)}>
{p}
</button>
))}
</div>
{pestana === 'resumen' && <p>Bienvenido al panel de EMPRESA.</p>}
{pestana === 'pedidos' && <FilasPedidos />}
{pestana === 'contacto' && <p>Escríbenos a soporte@empresa.es</p>}
</section>
);
}
Pulsa pedidos e, inmediatamente, contacto. Te has arrepentido a los 100 ms, pero la página no te hace caso hasta que termina de pintar una lista que ya no quieres ver. En el siguiente apartado lo arreglamos con una sola línea y medimos la diferencia.
2. useTransition: marcar una actualización como no urgente
useTransition devuelve dos cosas: un booleano que indica si hay una transición en marcha y una función para iniciarla. Las actualizaciones de estado que hagas dentro de esa función se vuelven no urgentes.
// src/App.tsx
import { useState, useTransition } from 'react';
import { FilasPedidos } from './FilasPedidos';
type Pestana = 'resumen' | 'pedidos' | 'contacto';
export default function App() {
const [pestana, setPestana] = useState<Pestana>('resumen');
const [pendiente, iniciarTransicion] = useTransition();
function elegir(nueva: Pestana) {
iniciarTransicion(() => {
setPestana(nueva);
});
}
return (
<section className="tarjeta">
<h2>Con transición</h2>
<div className="fila">
{(['resumen', 'pedidos', 'contacto'] as const).map((p) => (
<button key={p} aria-pressed={p === pestana} onClick={() => elegir(p)}>
{p}
</button>
))}
{pendiente && <span className="aviso">Cargando pestaña...</span>}
</div>
<div className={pendiente ? 'pendiente' : ''}>
{pestana === 'resumen' && <p>Bienvenido al panel de EMPRESA.</p>}
{pestana === 'pedidos' && <FilasPedidos />}
{pestana === 'contacto' && <p>Escríbenos a soporte@empresa.es</p>}
</div>
</section>
);
}
Hemos medido las dos versiones en Chromium, con el build de producción: clic en pedidos, otro clic en contacto a los 100 ms, y tiempo hasta ver contacto. Una tarea larga es, para el navegador, cualquier tarea de más de 50 ms.
| Versión | Hasta ver contacto | Tarea larga más larga |
|---|---|---|
| Sin transición | 1076 ms | 1060 ms |
| Con transición | 123 ms | Ninguna |
Con la transición, el segundo clic llega en su momento y React tira el render a medias de pedidos. En npm run dev todo tarda más, porque <StrictMode> duplica los renders y las versiones de desarrollo son más lentas (2576 ms frente a 205 ms). Mide siempre el rendimiento con el build de producción.
Qué ocurre paso a paso
- Llamas a
iniciarTransicion. React ejecuta tu función en el momento, de forma síncrona, y anota que las actualizaciones de dentro son transiciones. - React hace primero un render urgente con
pendienteatruey el estado antiguo. Por eso el aviso aparece de inmediato. - En segundo plano renderiza el estado nuevo con
pendienteafalse, cediendo el hilo cada poco. - Si llega una actualización urgente, deja la transición, atiende la urgente y vuelve a empezar la transición con el estado actualizado.
- Cuando termina, hace el commit de una vez. El usuario nunca ve la lista a medio pintar.
Reglas de la función que recibe
- Se llama transición a lo que ocurre dentro. El
setStatedebe estar dentro de la función. Si envuelves algo que llama asetTimeouty hace elsetStateallí, esa actualización ya no se considera transición. - No puede controlar un campo de texto. Lo vemos ahora.
iniciarTransiciones estable entre renders, como las funcionessetdeuseState, así que no hace falta añadirla a las dependencias de un efecto.
Un filtro: dos estados para un mismo valor
Un campo de texto necesita actualizarse al momento, y la lista que filtra no. La solución con transición es separar el valor en dos estados:
// src/App.tsx
import { useState, useTransition } from 'react';
import { FilasPedidos } from './FilasPedidos';
export default function App() {
const [texto, setTexto] = useState(''); // urgente: lo que se ve en el campo
const [filtro, setFiltro] = useState(''); // no urgente: lo que filtra la lista
const [pendiente, iniciarTransicion] = useTransition();
function cambiar(valor: string) {
setTexto(valor); // fuera de la transición
iniciarTransicion(() => setFiltro(valor));
}
return (
<section className="tarjeta">
<h2>Filtro con transición</h2>
<input aria-label="Filtro" value={texto} onChange={(e) => cambiar(e.target.value)} />
<div className={pendiente ? 'pendiente' : ''}>
<FilasPedidos filtro={filtro} />
</div>
</section>
);
}
⚠️ Importante: No metas el estado del propio campo en la transición. Probamos a teclear
51con una lista lenta debajo ysetTextodentro destartTransition. Durante el tecleo el campo seguía vacío y al final solo contenía5: se perdió una pulsación. La causa es que el campo controlado debe recibir su valor en el mismo evento, y una transición lo aplaza.
Más adelante, en la tabla del apartado 4, verás las medidas de este filtro.
3. startTransition y las transiciones asíncronas
startTransition fuera de los componentes
Si no necesitas el booleano, o estás fuera de un componente y no puedes usar un hook, tienes la función suelta:
import { startTransition } from 'react';
export function abrirSeccion(seccion: string, setSeccion: (s: string) => void) {
startTransition(() => {
setSeccion(seccion);
});
}
Hace lo mismo que el hook, salvo por dos diferencias. No tiene pendiente, así que no puedes mostrar ningún aviso. Y, como veremos enseguida, los errores se tratan de otra forma.
useTransition | startTransition | |
|---|---|---|
| Dónde se usa | Dentro de un componente | En cualquier sitio |
Da pendiente | Sí | No |
Error en una función async | Llega a la Error Boundary | Va a window como error sin capturar |
Transiciones asíncronas 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3 la función debía ser síncrona. Si usas una versión anterior: lleva tú mismo un estado
cargandoconuseState, haz la petición fuera de la transición y envuelve solo elsetStatefinal enstartTransition.
Desde 19.0 la función puede ser async. Mientras dure, pendiente sigue a true, y eso incluye el tiempo de espera de la red. Es la base de las Actions, que se estudian en el módulo 16.
Hay una trampa, y es de JavaScript, no de React. Tras un await, tu código sigue en otra tarea, cuando React ya no sabe que estabas en una transición. Lo que pongas ahí es una actualización normal:
function mal() {
iniciarTransicion(async () => {
await esperar(100);
setId(20); // ❌ fuera de la transición: ya no es no urgente
});
}
function bien() {
iniciarTransicion(async () => {
await esperar(100);
iniciarTransicion(() => setId(30)); // ✅ se envuelve de nuevo
});
}
Lo hemos comprobado con un componente que se suspende al cambiar id (Suspense se explica en el apartado 5). Con mal, la pantalla cambia a Cargando.... Con bien, el contenido anterior sigue visible y pendiente a true hasta que llegan los datos.
Errores dentro de una transición 🔄 19.0
🔄 Cambia en React 19.0. Antes no había transiciones asíncronas ni un camino para llevar sus errores a una Error Boundary. Si usas una versión anterior: rodea la operación con
try/catchy guarda el error en un estado que el componente muestre.
Probamos a lanzar un error en tres casos, con una Error Boundary alrededor:
| Caso | Resultado en 19.3.1 |
|---|---|
useTransition con función síncrona | Lo captura la Error Boundary |
useTransition con función async | Lo captura la Error Boundary |
startTransition suelto con función async | No se captura: llega a window como error |
Es lógico: el hook pertenece a un componente, y por eso React sabe en qué lugar del árbol debe mostrar el error. La función suelta no pertenece a ninguno.
Transiciones independientes 🔄 19.3
🔄 Cambia en React 19.3. Hasta React 19.2 las transiciones simultáneas se agrupan y una lenta retiene a las demás. Si usas una versión anterior: no hay alternativa directa. Evita lanzar a la vez transiciones que no tengan relación, o saca fuera de la transición la que deba verse enseguida.
Hemos lanzado una transición que espera 1500 ms y, después, otra sin relación que no tiene nada que esperar. Esto es lo que tardó la segunda en llegar a la pantalla:
| Versión | Desde el clic hasta ver la transición rápida |
|---|---|
| 18.3.1 | 898 ms |
| 19.0.9 | 989 ms |
| 19.2.9 | 1096 ms |
| 19.3.1 | 7 ms |
En todas las versiones anteriores la rápida espera a que acabe la lenta. En 19.3 cada una va por su cuenta. El módulo 17 vuelve sobre este cambio.
4. useDeferredValue: una copia que llega tarde
useTransition envuelve la actualización, y para eso necesitas acceso a la función set. A veces no la tienes: el valor te llega por props, o viene de una biblioteca. Para eso está useDeferredValue, que envuelve el valor.
const textoDiferido = useDeferredValue(texto);
Te da una copia de texto que se queda atrás. Cuando texto cambia, React renderiza primero con la copia antigua, y por eso la parte urgente aparece al momento. Después lanza en segundo plano otro render con la copia nueva. Ese segundo render se comporta como una transición: es interrumpible y, si llega otra tecla, se abandona.
// src/App.tsx
import { useDeferredValue, useState } from 'react';
import { FilasPedidos } from './FilasPedidos';
export default function App() {
const [texto, setTexto] = useState('');
const textoDiferido = useDeferredValue(texto);
const desfasado = texto !== textoDiferido;
return (
<section className="tarjeta">
<h2>Filtro con valor diferido</h2>
<input aria-label="Filtro" value={texto} onChange={(e) => setTexto(e.target.value)} />
<div className={desfasado ? 'pendiente' : ''}>
<FilasPedidos filtro={textoDiferido} />
</div>
</section>
);
}
Comparar los dos valores (texto !== textoDiferido) sustituye a pendiente: mientras sean distintos, lo que se ve está desfasado.
Sin memo no sirve de nada
Esta es la equivocación más habitual. Al cambiar texto, App se vuelve a renderizar con la copia antigua. Si FilasPedidos no está en memo, se renderiza igualmente con la misma copia antigua, de forma urgente y bloqueante. Has pagado el render lento dos veces.
Medimos cuatro variantes tecleando 5123 con 80 ms entre teclas, en el build de producción. La latencia es el tiempo entre pulsar cada tecla y el siguiente pintado.
| Variante | Latencia de cada tecla (ms) | Tiempo total |
|---|---|---|
| Sin nada | 1055, 148, 16, 4 | 1656 ms |
useTransition | 10, 9, 2, 2 | 424 ms |
useDeferredValue con memo | 10, 8, 2, 3 | 497 ms |
useDeferredValue sin memo | 1036, 1047, 1025, 14 | 3581 ms |
La última fila es peor que no hacer nada. La lista es tan lenta como siempre, y además se renderiza dos veces por tecla.
💡 Consejo: Con React Compiler, visto en el módulo 14, no escribes
memoa mano, porque el compilador memoriza por ti. La idea es la misma: el componente lento debe saltarse el render cuando recibe la copia antigua.
Valor inicial 🔄 19.0
🔄 Cambia en React 19.0. El segundo argumento
initialValueno existe en React 18.3, donde el primer render ya usa el valor real. Si usas una versión anterior: guarda el valor en un estado inicializado con el marcador y actualízalo en un efecto dentro destartTransition.
Por defecto, en el primer montaje no hay valor anterior del que tirar, así que no se aplaza nada. Con initialValue, el primer render usa ese valor y el real llega después en segundo plano. Sirve para montar deprisa un componente caro.
function Saludo({ nombre }: { nombre: string }) {
const diferido = useDeferredValue(nombre, 'cargando...');
console.log('render con', diferido);
return <p>{diferido}</p>;
}
render con cargando...
render con cargando...
render con Ana García
render con Ana García
Cada línea sale dos veces por <StrictMode>.
¿Transición, valor diferido, debounce o throttle?
El debounce espera a que el usuario deje de teclear, y el throttle limita las ejecuciones por segundo; viste el primero en el módulo 12. Ambos usan un tiempo fijo que tú eliges y que no sabe nada de lo rápida que es la máquina. Un valor diferido se adapta: en un ordenador potente casi no se nota, y en un móvil lento se retrasa más. Además, puede interrumpirse. A cambio, no reduce el trabajo de red por sí solo: si la consulta va al servidor, cada render que llegue a terminar lanza una petición.
| Herramienta | Qué envuelve | Interrumpible | Reduce peticiones | Úsala para |
|---|---|---|---|---|
useTransition | La actualización | Sí | No | Tienes el set: pestañas, navegación, Actions |
useDeferredValue | El valor | Sí | No | El valor llega por props, o quieres mostrar datos antiguos |
| Debounce | La llamada | No | Sí | Limitar peticiones al servidor mientras se teclea |
| Throttle | La llamada | No | Sí | Eventos continuos como scroll |
Se pueden combinar: debounce para no saturar la API y valor diferido para pintar sin bloquear.
5. Suspense: declarar la espera
Hasta ahora, cada componente con datos llevaba su propio if (cargando), como en el módulo 09. Suspense le da la vuelta: el componente se escribe como si los datos ya estuvieran, y un ancestro declara qué mostrar mientras tanto.
<Suspense fallback={<p>Cargando...</p>}>
<ListaEmpleados />
</Suspense>
Es parecido a try/catch, pero para esperas. Cuando un componente de dentro no puede terminar su render porque le falta algo, se suspende. React busca hacia arriba la frontera <Suspense> más cercana y muestra su fallback en lugar de todo su contenido. Cuando lo que faltaba llega, vuelve a intentarlo y revela el contenido.
Figura 2. Una frontera reemplaza todo lo que tiene dentro. Las demás ramas no se enteran.
⚠️ Importante: Suspense no detecta un
fetchen unuseEffect. Solo reacciona a las fuentes preparadas para ello:lazy(apartado 6),usecon una promesa (apartado 7) y las bibliotecas compatibles, comouseSuspenseQueryde TanStack Query (módulo 19).
Dónde colocar las fronteras
Una frontera es una decisión de diseño: qué partes de la pantalla deben aparecer juntas.
- Todo dentro de la misma frontera aparece a la vez. Si algo tarda, todo espera.
- Una frontera por zona permite que cada una aparezca cuando esté lista, pero demasiadas fronteras producen una pantalla que salta por todas partes.
- Las fronteras se anidan. Puedes tener una general para la página y otras interiores para los bloques más lentos.
El fallback debe parecerse a lo que va a sustituir. Un esqueleto del mismo tamaño evita que la página salte al llegar el contenido, y reserva la pantalla al usuario.

Figura 3. Un panel con tres fronteras (ejemplo del apartado 7). Las dos primeras zonas ya han llegado y la tercera sigue con su esqueleto.
Qué pasa con el contenido que ya estaba visible
Si un contenido ya mostrado vuelve a suspenderse, React no lo desmonta. Lo oculta, y al revelarlo sigue igual. Lo comprobamos con un contador a 2 que se suspende al pulsar un botón:
- El nodo se quedó en el DOM con
style="display: none !important;". - El contador seguía en 2 al revelarse: el estado se conserva.
- En producción, se ejecutó la limpieza de
useLayoutEffectal ocultarse y volvió a montarse al revelarse.useEffectno se limpió.
Con <StrictMode> en desarrollo verás más líneas, porque al revelar también repite los efectos. Las medidas del DOM de un useLayoutEffect no tendrían sentido en un nodo oculto, y por eso sí se desconectan.
Hermanos: el fallback ya no espera 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3 se renderizan todos los hermanos antes de mostrar el fallback. Si usas una versión anterior: la interfaz funciona igual, pero el fallback tarda más en verse si los hermanos son caros de renderizar. No necesitas cambiar tu código.
Dos hermanos A y B dentro de la misma frontera, los dos suspendidos 300 ms. Este es el orden real, sin <StrictMode>:
18.3.1 render A, render B, fallback, (300 ms) render A, render B
19.3.1 render A, fallback, render A, render B, (300 ms) render A, render B
En React 19, al primer componente que se suspende, React muestra el fallback enseguida, y luego renderiza los hermanos en segundo plano para que empiecen a cargar lo suyo. Es el llamado pre-warming. Si los datos los pide cada componente al renderizarse, las dos peticiones siguen saliendo casi a la vez, y por eso no hay cascada entre hermanos.
Retardo entre revelados 🔄 19.0
🔄 Cambia en React 19.0. Según el changelog, el retardo baja de 500 ms a 300 ms. Si usas una versión anterior: funciona igual, solo que con una espera mayor.
Si un contenido llega justo después de otro, React no lo revela enseguida, para que el usuario no vea la pantalla saltar varias veces seguidas. Comprobado con una frontera exterior que resuelve a los 350 ms y otra interior a los 400 ms, en 19.0.9, 19.2.9 y 19.3.1: la exterior se reveló hacia los 362 ms y la interior hacia los 670 ms, unos 300 ms después, aunque ya tenía sus datos.
6. lazy: cargar código bajo demanda
lazy es la forma de usar la división de código con componentes. Recibe una función que hace un import() dinámico, y devuelve un componente que se suspende hasta que su fichero ha llegado.
// src/Informes.tsx
export default function Informes() {
return (
<section>
<h3>Informe anual</h3>
<p>Este componente vive en su propio fichero JavaScript.</p>
</section>
);
}
export function InformeVentas() {
return <p>Ventas del trimestre: 61 617,46 €</p>;
}
// src/App.tsx
import { lazy, Suspense, useState } from 'react';
function esperar(ms: number) {
return new Promise((resolver) => setTimeout(resolver, ms));
}
// Fuera del componente, y con un retardo artificial para ver el fallback
const Informes = lazy(async () => {
await esperar(1200);
return import('./Informes');
});
// Exportación con nombre: hay que convertirla en { default }
const InformeVentas = lazy(() =>
import('./Informes').then((modulo) => ({ default: modulo.InformeVentas })),
);
export default function App() {
const [abierto, setAbierto] = useState(false);
return (
<section className="tarjeta">
<h2>Panel</h2>
<button onClick={() => setAbierto(!abierto)}>
{abierto ? 'Cerrar informes' : 'Abrir informes'}
</button>
{abierto && (
<Suspense fallback={<p className="cargando">Cargando informes...</p>}>
<Informes />
<InformeVentas />
</Suspense>
)}
</section>
);
}
Al hacer npm run build, Vite separa ese componente en su propio fichero. Este es un recorte de la salida real de nuestro laboratorio:
dist/assets/Informes-DzTrvI2r.js 0.35 kB │ gzip: 0.25 kB
dist/assets/index-BosGCdHH.js 217.50 kB │ gzip: 68.05 kB
Tres detalles:
lazyespera una promesa de{ default: Componente }. Las exportaciones con nombre necesitan el.thendel ejemplo.- La función de carga se ejecuta la primera vez que se renderiza el componente, y el resultado se recuerda.
- Si dos
lazyimportan el mismo módulo, el navegador solo lo descarga una vez.
Dónde declarar lazy
function Panel() {
// ❌ MAL: cada render crea un componente distinto
const Informes = lazy(() => import('./ContadorInforme'));
// ...
}
Lo probamos. Cada vez que el padre se renderizó, se creó un lazy nuevo, que volvió a suspenderse, y el fallback apareció de nuevo. En nuestra prueba el estado interno se conservó, pero no cuentes con ello. oxlint lo señala con su regla static-components. Declara siempre lazy a nivel de módulo.
Cuando falla la descarga
La red falla, y además, tras un despliegue, los ficheros antiguos con su hash pueden haber desaparecido. Si el import() se rechaza, lazy lanza el error a la Error Boundary más cercana, que veremos en el apartado 9.
⚠️ Importante:
lazyrecuerda también el fallo. Probamos a reintentar con el mismolazy: la función de carga no se volvió a ejecutar y el error siguió en pantalla. Solo se recuperó creando otrolazy:
const [Informes, setInformes] = useState(() => lazy(cargar));
// al pulsar Reintentar:
setInformes(() => lazy(cargar));
En una aplicación desplegada, la opción más sencilla ante este error suele ser ofrecer al usuario recargar la página.
7. use(promesa): leer datos en el render 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior: carga los datos con
useEffecty un estadocargando, como en el módulo 09, o usauseSuspenseQueryde TanStack Query, que es compatible con React 18.
use lee el valor de un recurso durante el render. En el módulo 11 lo usaste con Context; aquí lo usamos con promesas. Si la promesa está pendiente, el componente se suspende. Si se cumple, use devuelve el valor. Si se rechaza, lanza el error.
Figura 4. Al resolverse la promesa, React repite el render. Por eso debe recibir la misma promesa.
La regla de oro: la promesa debe ser estable
Fíjate en el paso 4 de la figura: cuando la promesa se resuelve, React vuelve a llamar al componente desde el principio. Si ese componente crea la promesa en cada render, recibe una nueva, pendiente otra vez, y se suspende para siempre.
function ListaDepartamentos() {
// ❌ MAL: cada render crea una promesa nueva
const { contenido } = use(obtener<Pagina<Departamento>>('/departamentos'));
return <p>{contenido.length} departamentos</p>;
}
Lo hemos probado con la API con 300 ms de retardo: en 2 segundos hizo 6 peticiones, la pantalla se quedó en Cargando... para siempre, y la consola no mostró ningún aviso. Ni en desarrollo ni en producción. El código de React contiene un aviso sobre promesas no cacheadas (A component was suspended by an uncached promise), pero en esta prueba no saltó: si tu pantalla se queda cargando y ves peticiones repetidas en la pestaña Red, sospecha de esto.
La solución es una caché: un sitio fuera del componente que devuelva siempre el mismo objeto Promise para la misma clave. Estos dos ficheros los usa el resto del módulo:
// src/api.ts
export const API = 'http://localhost:3000/api';
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
salario: number;
deptId: number;
departamento: string;
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number;
}
export interface Producto {
productoId: number;
nombre: string;
precio: number;
stock: number;
categoria: string;
}
export interface Pedido {
pedidoId: number;
clienteId: number;
fechaPedido: string;
estado: string;
cliente: string;
}
export interface Estadisticas {
empleados: number;
departamentos: number;
clientes: number;
productos: number;
pedidos: number;
facturacion: number;
}
export async function obtener<T>(ruta: string): Promise<T> {
const respuesta = await fetch(`${API}${ruta}`);
if (!respuesta.ok) {
const cuerpo = await respuesta.json();
throw new Error(cuerpo.mensaje ?? `Error ${respuesta.status}`);
}
return respuesta.json();
}
// src/recurso.ts
import { obtener } from './api';
const promesas = new Map<string, Promise<unknown>>();
// Devuelve siempre la MISMA promesa para la misma ruta
export function leer<T>(ruta: string): Promise<T> {
let promesa = promesas.get(ruta);
if (!promesa) {
promesa = obtener<T>(ruta);
promesas.set(ruta, promesa);
}
return promesa as Promise<T>;
}
// Olvida una ruta (o todas) para que la próxima lectura vuelva a pedirla
export function invalidar(ruta?: string) {
if (ruta) promesas.delete(ruta);
else promesas.clear();
}
Ya podemos escribir el componente como si los datos estuvieran ahí:
// src/App.tsx
import { Suspense, use } from 'react';
import { leer } from './recurso';
import type { Departamento, Pagina } from './api';
function ListaDepartamentos() {
const { contenido } = use(leer<Pagina<Departamento>>('/departamentos'));
return (
<ul>
{contenido.map((d) => (
<li key={d.deptId}>
{d.nombre} ({d.localidad})
</li>
))}
</ul>
);
}
export default function App() {
return (
<section className="tarjeta">
<h2>Departamentos</h2>
<Suspense fallback={<p className="cargando">Cargando departamentos...</p>}>
<ListaDepartamentos />
</Suspense>
</section>
);
}
Ahora hay una sola petición, aunque <StrictMode> renderice dos veces. Y no hay cargando ni error en el componente: el primero está en Suspense, y el segundo lo pondremos en una Error Boundary.
⚠️ Importante: Esta caché es deliberadamente mínima, para que veas la idea. No caduca y no reintenta. Además, una promesa rechazada se queda guardada hasta que la invalides. Para una aplicación real, usa TanStack Query (módulo 19), o los datos del enrutador (módulo 18).
💡 Consejo: Quizá hayas visto la función
cachede React. No te sirve aquí: es para Server Components. En el cliente no memoriza nada. Lo comprobamos conconst f = cache(() => Math.random()), yf() === f()diofalsefuera del entorno de servidor.
use frente a los hooks normales
use no es un hook normal. Se puede llamar dentro de un if o de un bucle, cosa que useState no permite:
function Panel({ detallado }: { detallado: boolean }) {
if (!detallado) return <p>Vista básica</p>;
const datos = use(leer<Estadisticas>('/estadisticas'));
return <p>{datos.empleados} empleados</p>;
}
Lo que no puedes hacer es envolverlo en try/catch:
function Lista() {
try {
// ❌ MAL: use no se puede envolver en try/catch
const { contenido } = use(leer<Pagina<Departamento>>('/departamentos'));
return <p>{contenido.length} departamentos</p>;
} catch {
return <p>Fallo al cargar</p>;
}
}
Por dentro, use interrumpe el render lanzando una excepción especial. Tu catch se la queda, y la pantalla muestra Fallo al cargar sin que haya fallado nada. Fue exactamente lo que ocurrió en la prueba, con la API respondiendo bien. En desarrollo React lo avisa, y oxlint lo marca como error (rules-of-hooks):
`use` was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior. To handle errors triggered by `use`, wrap your component in a error boundary.
En producción no hay aviso. Los errores se capturan con una Error Boundary.
Aviso por use condicional 🔄 19.3
🔄 Cambia en React 19.3. Añade un aviso en desarrollo que no existe en 19.2.9. Si usas una versión anterior: no verás el aviso, pero el patrón es igual de incorrecto, así que evítalo también.
El aviso no salta por usar use en un if normal: nuestro Panel de arriba no produjo ninguno. Salta en un caso más concreto, cuando un componente que se está montando se suspende con use, y al reanudarse ya no llama a use. Ocurre si compruebas una caché externa antes:
// ❌ MAL: caché externa que ya tiene el valor cuando React reintenta
let dato: string | null = null;
const promesa = new Promise<string>((resolver) =>
setTimeout(() => {
dato = 'listo';
resolver('listo');
}, 200),
);
function Lector() {
if (dato !== null) return <p>{dato}</p>;
return <p>{use(promesa)}</p>;
}
Error: This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use
Con 19.2.9 ese código no avisa, y con 19.3.1 sí. En ambos casos la pantalla acaba mostrando listo. Solución: llama siempre a use por el mismo camino, sin comprobar antes si el dato ya está.
Zonas que aparecen una a una
Este es el panel de la figura 3. Crea sus promesas a nivel de módulo, así que son estables por construcción. Los retardos son artificiales, para que cada zona llegue en un momento distinto.
// src/App.tsx
import { Suspense, use } from 'react';
import { obtener, type Empleado, type Estadisticas, type Pagina, type Pedido } from './api';
// Retardos artificiales distintos para ver aparecer cada zona
function esperar(ms: number) {
return new Promise((resolver) => setTimeout(resolver, ms));
}
const promesas = {
estadisticas: esperar(300).then(() => obtener<Estadisticas>('/estadisticas')),
empleados: esperar(1500).then(() => obtener<Pagina<Empleado>>('/empleados?orden=-salario&tamano=5')),
pedidos: esperar(3000).then(() => obtener<Pagina<Pedido>>('/pedidos?orden=-fechaPedido&tamano=5')),
};
function Cifras() {
const e = use(promesas.estadisticas);
return (
<p>
{e.empleados} empleados, {e.pedidos} pedidos, {e.facturacion.toLocaleString('es-ES')} € facturados
</p>
);
}
function MejorPagados() {
const { contenido } = use(promesas.empleados);
return (
<ul>
{contenido.map((e) => (
<li key={e.empId}>
{e.nombre} {e.apellidos}: {e.salario} €
</li>
))}
</ul>
);
}
function UltimosPedidos() {
const { contenido } = use(promesas.pedidos);
return (
<ul>
{contenido.map((p) => (
<li key={p.pedidoId}>
{p.pedidoId}: {p.cliente} ({p.estado})
</li>
))}
</ul>
);
}
function Esqueleto({ filas }: { filas: number }) {
return (
<div aria-label="Cargando">
{Array.from({ length: filas }, (_, i) => (
<div key={i} className="esqueleto" />
))}
</div>
);
}
export default function App() {
return (
<>
<section className="tarjeta">
<h2>Panel de EMPRESA</h2>
<Suspense fallback={<Esqueleto filas={1} />}>
<Cifras />
</Suspense>
</section>
<section className="tarjeta">
<h2>Mejor pagados</h2>
<Suspense fallback={<Esqueleto filas={5} />}>
<MejorPagados />
</Suspense>
</section>
<section className="tarjeta">
<h2>Últimos pedidos</h2>
<Suspense fallback={<Esqueleto filas={5} />}>
<UltimosPedidos />
</Suspense>
</section>
</>
);
}
Cascadas: el problema de pedir datos al renderizar
Como un componente suspendido no llega a renderizar a sus hijos, si cada componente pide sus datos, las peticiones van una detrás de otra:
// src/App.tsx
import { Suspense, use } from 'react';
import { leer } from './recurso';
import type { Departamento, Empleado, Pagina } from './api';
function Empleados() {
const { contenido } = use(leer<Pagina<Empleado>>('/departamentos/30/empleados'));
return <p>{contenido.length} empleados</p>;
}
function Departamento30() {
const depto = use(leer<Departamento>('/departamentos/30'));
return (
<div>
<h3>{depto.nombre}</h3>
<Empleados />
</div>
);
}
export default function App() {
return (
<section className="tarjeta">
<Suspense fallback={<p className="cargando">Cargando...</p>}>
<Departamento30 />
</Suspense>
</section>
);
}
La solución es lanzar las peticiones antes del primer use. Como leer guarda la promesa en la caché, el hijo la encontrará ya en marcha:
function Departamento30() {
// Lanzamos las dos peticiones ANTES del primer use
const promesaDepto = leer<Departamento>('/departamentos/30');
leer<Pagina<Empleado>>('/departamentos/30/empleados');
const depto = use(promesaDepto);
return (
<div>
<h3>{depto.nombre}</h3>
<Empleados />
</div>
);
}
Esto es lo que mide el navegador en cada caso, con 300 ms de retardo en la API:
Figura 5. Tiempos reales de las dos peticiones: la versión en paralelo acaba en casi la mitad de tiempo.
📘 Concepto: Esta idea se llama render-as-you-fetch o cargar mientras renderizas: los datos se piden lo antes posible, sin esperar a que cada componente exista. Los enrutadores la llevan más lejos con los
loader, que piden los datos antes incluso de renderizar la ruta (módulo 18).
8. Suspense con transiciones y valores diferidos 🆕 19.0
🆕 Desde React 19.0. Los ejemplos de este apartado leen datos con
use. La combinación de transición y Suspense ya existe en React 18.3 conlazyo con bibliotecas compatibles. Si usas una versión anterior: aplica estas mismas ideas conlazy, o conuseSuspenseQueryde TanStack Query.
Suspense por sí solo tiene un defecto. Si cambias de departamento y el nuevo no está cargado, el contenido actual desaparece y salta el fallback. La página parpadea a cada clic.
Una transición resuelve esto: no oculta un contenido ya revelado para mostrar un fallback. Mantiene lo anterior hasta que lo nuevo esté listo, y entretanto pendiente te permite dar una pista.
// src/App.tsx
import { Suspense, use, useState, useTransition } from 'react';
import { leer } from './recurso';
import type { Departamento, Empleado, Pagina } from './api';
const ids = [10, 20, 30, 40];
function Detalle({ id }: { id: number }) {
const depto = use(leer<Departamento>(`/departamentos/${id}`));
const { contenido } = use(leer<Pagina<Empleado>>(`/departamentos/${id}/empleados`));
return (
<p>
{depto.nombre}: {contenido.length} empleados
</p>
);
}
function SinTransicion() {
const [id, setId] = useState(10);
return (
<section className="tarjeta">
<h2>Sin transición</h2>
<div className="fila">
{ids.map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => setId(n)}>
{n}
</button>
))}
</div>
<Suspense fallback={<p className="cargando">Cargando...</p>}>
<Detalle id={id} />
</Suspense>
</section>
);
}
function ConTransicion() {
const [id, setId] = useState(10);
const [pendiente, iniciarTransicion] = useTransition();
return (
<section className="tarjeta">
<h2>Con transición</h2>
<div className="fila">
{ids.map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => iniciarTransicion(() => setId(n))}>
{n}
</button>
))}
{pendiente && <span className="aviso">Actualizando...</span>}
</div>
<Suspense fallback={<p className="cargando">Cargando...</p>}>
<div className={pendiente ? 'pendiente' : ''}>
<Detalle id={id} />
</div>
</Suspense>
</section>
);
}
export default function App() {
return (
<>
<SinTransicion />
<ConTransicion />
</>
);
}

Figura 6. Sin transición el contenido desaparece. Con ella, sigue ahí atenuado hasta que llega el nuevo.
Fíjate en un detalle de la figura: con transición, el botón sigue marcando 10 hasta que acaba, porque id aún no ha cambiado en pantalla. Si quieres que el botón responda al instante, guarda un estado urgente aparte para la selección, igual que hicimos con el campo de texto.
⚠️ Importante: Una transición solo protege las fronteras que ya existen. Una frontera
<Suspense>que aparece por primera vez sí muestra su fallback. Esto causa dos errores muy comunes, los dos comprobados en este módulo:
- Montar la frontera dentro del condicional:
{abierto && <Suspense>...</Suspense>}. Es nueva cada vez que se abre. Sube elSuspensepor encima del condicional (ejercicio 3).- Poner
keyen un ancestro de la frontera. Sikeycambia, todo lo de dentro se crea de nuevo, incluida la frontera. Probamos el caso final conkey={seccion}en la Error Boundary: la transición dejó de servir y aparecióCargando sección...en cada cambio.
Eso sí, a veces quieres justo eso. Si navegas de un perfil a otro, mostrar un momento el perfil anterior sería confuso: pon key={id} en la frontera y tendrás un fallback limpio.
Buscador con valor diferido
useDeferredValue da el mismo efecto cuando la consulta viene de un campo de texto. Mientras la consulta nueva se suspende, se mantienen los resultados anteriores:
// src/App.tsx
import { Suspense, use, useDeferredValue, useState } from 'react';
import { leer } from './recurso';
import type { Empleado, Pagina } from './api';
function Resultados({ consulta }: { consulta: string }) {
const ruta = `/empleados?q=${encodeURIComponent(consulta)}&tamano=6`;
const { contenido, total } = use(leer<Pagina<Empleado>>(ruta));
if (total === 0) return <p>Sin resultados para "{consulta}".</p>;
return (
<>
<p>{total} empleados</p>
<ul>
{contenido.map((e) => (
<li key={e.empId}>
{e.nombre} {e.apellidos} ({e.departamento})
</li>
))}
</ul>
</>
);
}
export default function App() {
const [texto, setTexto] = useState('');
const consulta = useDeferredValue(texto);
const desfasado = texto !== consulta;
return (
<section className="tarjeta">
<h2>Buscar empleados</h2>
<div className="fila">
<input
aria-label="Buscar"
placeholder="Nombre, apellidos..."
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
{desfasado && <span className="aviso">Buscando...</span>}
</div>
<Suspense fallback={<p className="cargando">Cargando empleados...</p>}>
<div className={desfasado ? 'pendiente' : ''}>
<Resultados consulta={consulta} />
</div>
</Suspense>
</section>
);
}

Figura 7. El campo ya muestra gar, y debajo sigue la lista anterior, atenuada, hasta que llega la nueva.
En este caso Resultados no necesita memo para funcionar, porque no es lento de renderizar: simplemente se suspende. Pero conviene tenerlo presente si fuese caro. El ejercicio 2 lo añade.
9. Error Boundaries: cuando algo se rompe
Sin ninguna protección, un error durante el render desmonta toda la aplicación y deja la página en blanco. React lo hace a propósito: prefiere no mostrar nada antes que una interfaz corrupta, por ejemplo con un importe equivocado.
Una Error Boundary o frontera de errores es un componente que captura los errores de render de sus descendientes y muestra una alternativa. Es el catch de Suspense: se envuelve una zona, y si algo falla dentro, solo cae esa zona.
Y aquí viene una rareza. Hoy siguen siendo componentes de clase. No existe ningún hook equivalente, así que es el único caso de este curso en que escribirás una. Solo necesitas dos métodos, y se escribe una vez:
// src/ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
alternativa: (error: Error, reiniciar: () => void) => ReactNode;
alReiniciar?: () => void;
claveReinicio?: unknown; // si cambia, la frontera se recupera sola
}
interface Estado {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, Estado> {
state: Estado = { error: null };
// Fase de render: decide qué estado mostrar. Debe ser pura.
static getDerivedStateFromError(error: unknown): Estado {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
// Fase de commit: aquí sí se permiten efectos, como enviar el error a un servicio
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('Error en la interfaz:', error.message, info.componentStack?.split('\n')[1]?.trim());
}
componentDidUpdate(anteriores: Props) {
if (this.state.error && anteriores.claveReinicio !== this.props.claveReinicio) {
this.setState({ error: null });
}
}
reiniciar = () => {
this.props.alReiniciar?.();
this.setState({ error: null });
};
render() {
if (this.state.error) {
return this.props.alternativa(this.state.error, this.reiniciar);
}
return this.props.children;
}
}
| Método | Cuándo se ejecuta | Para qué |
|---|---|---|
static getDerivedStateFromError | En el render, tras el error | Cambiar el estado para mostrar la alternativa |
componentDidCatch | En el commit | Registrar el error |
componentDidUpdate | Tras cada actualización | Aquí, recuperarse si cambia claveReinicio |
El error llega como unknown porque en JavaScript se puede lanzar cualquier cosa. Si prefieres no escribir la clase, existe el paquete react-error-boundary (versión 6.1.6, compatible con React 18 y 19). En este curso no lo usamos para que veas cómo funciona.
Una frontera en acción
La API responde 404 para el empleado 999. use lanza el error de la promesa rechazada y la frontera lo muestra:
// src/App.tsx
import { Suspense, use, useState } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { invalidar, leer } from './recurso';
import type { Empleado } from './api';
function FichaEmpleado({ id }: { id: number }) {
const empleado = use(leer<Empleado>(`/empleados/${id}`));
return (
<p>
{empleado.nombre} {empleado.apellidos}, {empleado.oficio}
</p>
);
}
function BotonRoto() {
return (
<button
onClick={() => {
throw new Error('Fallo en un manejador');
}}
>
Fallo en manejador
</button>
);
}
export default function App() {
const [id, setId] = useState(999);
return (
<section className="tarjeta">
<h2>Ficha de empleado</h2>
<div className="fila">
<button onClick={() => setId(100)}>Empleado 100</button>
<button onClick={() => setId(999)}>Empleado 999</button>
<BotonRoto />
</div>
<ErrorBoundary
claveReinicio={id}
alReiniciar={() => invalidar(`/empleados/${id}`)}
alternativa={(error, reiniciar) => (
<div className="error" role="alert">
<h3>No se ha podido cargar la ficha</h3>
<p>{error.message}</p>
<button onClick={reiniciar}>Reintentar</button>
</div>
)}
>
<Suspense fallback={<p className="cargando">Cargando ficha...</p>}>
<FichaEmpleado id={id} />
</Suspense>
</ErrorBoundary>
</section>
);
}

Figura 8. Solo cae la zona protegida. Los botones de arriba siguen funcionando.
Recuperarse de un error
Una frontera con error se queda así hasta que alguien la reinicie. Fíjate en las dos formas que usa el ejemplo:
- Reintentar:
alReiniciarinvalida la caché ysetStateborra el error. Si no invalidases,leerdevolvería la misma promesa rechazada, y el error volvería al instante. Es un olvido muy habitual. - Cambiar de empleado: al cambiar
claveReinicio,componentDidUpdateborra el error. PulsarEmpleado 100muestraAna García López, DIRECTOR GENERAL.
También podrías usar key={id} en la frontera, que la recrea entera, pero recuerda lo visto en el apartado 8: eso crea también una frontera Suspense nueva, y deja sin efecto cualquier transición.
Qué no captura una Error Boundary
Solo capturan errores durante el render, en los métodos de ciclo de vida y en los efectos de sus descendientes.
| Origen del error | ¿Lo captura? | Qué hacer |
|---|---|---|
| Render de un hijo | Sí | |
useEffect de un hijo | Sí | |
Promesa rechazada leída con use | Sí | |
Fallo de lazy | Sí | |
Transición de useTransition | Sí | |
| Manejador de evento | No | try/catch y un estado de error |
Código asíncrono: setTimeout, fetch tras un await | No | try/catch |
startTransition suelto | No | try/catch dentro de la función |
| La propia frontera | No | Otra frontera por encima |
Lo comprobamos con BotonRoto: el clic lanzó el error a window y la alternativa no apareció. Tiene sentido: tras un error en un clic la interfaz sigue siendo coherente, porque no se ha roto ningún render.
Dónde colocarlas
Igual que con Suspense, es una decisión de diseño. Lo normal es tener una general cerca de la raíz, con un mensaje y un botón de recargar, y otras más cercanas a cada zona independiente: un gráfico roto no debería tirar la barra de navegación. Si usas ambas fronteras, el orden habitual es ErrorBoundary por fuera y Suspense por dentro, para que la frontera de error cubra también lo que falle en el fallback.
Cómo se notifican los errores 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3 los errores se relanzan y cada uno llega varias veces a
window. Si usas una versión anterior: usacomponentDidCatchpara registrar y filtra los errores repetidos en tu servicio de monitorización.
Hemos contado, sin <StrictMode>, cuántas veces se notifica cada error en desarrollo:
| Versión | Error capturado: window / console.error | Error sin capturar: window / console.error |
|---|---|---|
| 18.3.1 | 2 / 1 | 3 / 1 |
| 19.0.9 | 0 / 1 | 1 / 0 |
| 19.2.9 | 0 / 1 | 1 / 0 |
| 19.3.1 | 0 / 1 | 1 / 0 |
Si un error ya está capturado, 19 no lo manda a window, y si no lo está, llega una sola vez.
Opciones de la raíz 🆕 19.0
🆕 Desde React 19.0.
onCaughtErroryonUncaughtErrorno existen en React 18.3. Si usas una versión anterior: registra encomponentDidCatchlos errores capturados, y escucha el eventoerrordewindowpara los demás.
createRoot admite tres opciones que centralizan la notificación. Antes tenías que repartir esa lógica por cada frontera:
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!, {
onCaughtError(error, info) {
console.info('[capturado]', error, info.componentStack);
},
onUncaughtError(error, info) {
console.info('[sin capturar]', error, info.componentStack);
},
onRecoverableError(error, info) {
console.info('[recuperable]', error, info.componentStack);
},
}).render(
<StrictMode>
<App />
</StrictMode>,
);
onRecoverableError recibe errores de los que React se recupera solo, como algunos fallos de hidratación del módulo 23. Si defines estas opciones, sustituyen a lo que React escribe en consola por defecto: en la prueba, el error capturado dejó de salir por console.error.
10. Owner Stacks: quién ha creado qué
Cuando algo falla, la pregunta suele ser “¿qué componente ha renderizado esto con estos datos?”. La pila de componentes clásica (componentStack) no siempre lo responde, porque enumera todo lo que hay por encima, incluidos los envoltorios que solo pasan children.
Una Owner Stack o pila de propietarios muestra otra cosa: qué componente escribió el JSX que creó a cada uno. Es decir, quién tiene la responsabilidad.
function Tarjeta({ children }: { children: ReactNode }) {
return <div className="tarjeta">{children}</div>;
}
function Salario({ importe }: { importe: number }) {
if (importe < 0) throw new Error('Salario negativo');
return <p>{importe} €</p>;
}
function FichaNomina() {
return (
<Tarjeta>
<Salario importe={-1} />
</Tarjeta>
);
}
Salario está dentro de Tarjeta en el árbol, pero quien pasó importe={-1} fue FichaNomina. Ahí está el fallo que debes buscar.
Figura 9. La pila de propietarios se salta los envoltorios como Tarjeta.
Esta es la salida real de los dos, con las URL recortadas:
componentStack:
at Salario (.../d14-owner.tsx:17:20)
at div (<anonymous>)
at Tarjeta (.../d14-owner.tsx:6:20)
at FichaNomina (<anonymous>)
at ErrorBoundary (...)
captureOwnerStack():
at FichaNomina (.../d14-owner.tsx:28:70)
at App (.../d14-owner.tsx:53:29)
No hace falta hacer nada para aprovecharlas. Desde React 19.1 los errores en consola y las React DevTools ya las usan. Solo necesitas la función de abajo si quieres la pila en tu propio código.
captureOwnerStack 🆕 19.1
🆕 Desde React 19.1. No existe en React 19.0 ni anteriores. Si usas una versión anterior: tienes
info.componentStackencomponentDidCatch, que muestra la jerarquía, pero no quién creó cada componente.
import { captureOwnerStack } from 'react';
function Salario({ importe }: { importe: number }) {
if (import.meta.env.DEV) console.log(captureOwnerStack());
// ...
}
Devuelve string | null. Dos advertencias, las dos comprobadas:
- Solo existe en desarrollo. En el paquete de producción
captureOwnerStackesundefined: llamarla sin comprobar nada provoca unTypeError. Por eso va siempre detrás deimport.meta.env.DEV. - Solo funciona mientras React está ejecutando tu código. Devolvió la pila en el render, en un efecto y en un manejador de
onClick, yonCaughtErrortambién la obtiene. Dentro de unsetTimeoutdevolviónull.
Un uso típico es adjuntarla a los errores que envías durante el desarrollo, y lo verás en el ejercicio 8.
11. Caso completo: panel de EMPRESA 🆕 19.0
🆕 Desde React 19.0. Este caso lee datos con
use. Si usas una versión anterior: sustituyeuse(leer(...))poruseSuspenseQueryde TanStack Query (módulo 19); la estructura de transición, Suspense y frontera de error no cambia.
Juntemos todo. Una barra de secciones con transición, una sección con lazy, datos con use y una frontera por sección. La sección de pedidos pide un pedido que no existe, para que veas el error.
// src/App.tsx
import { lazy, Suspense, use, useState, useTransition } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { invalidar, leer } from './recurso';
import type { Empleado, Estadisticas, Pagina, Pedido } from './api';
type Seccion = 'inicio' | 'empleados' | 'pedidos' | 'informes';
const secciones: { id: Seccion; texto: string }[] = [
{ id: 'inicio', texto: 'Inicio' },
{ id: 'empleados', texto: 'Empleados' },
{ id: 'pedidos', texto: 'Pedidos' },
{ id: 'informes', texto: 'Informes' },
];
const rutas = {
inicio: '/estadisticas',
empleados: '/empleados?orden=apellidos&tamano=8',
pedidos: '/pedidos/9999', // no existe: provoca un error 404 a propósito
};
const Informes = lazy(() => import('./Informes'));
function Inicio() {
const e = use(leer<Estadisticas>(rutas.inicio));
return (
<ul>
<li>{e.empleados} empleados</li>
<li>{e.pedidos} pedidos</li>
<li>{e.facturacion.toLocaleString('es-ES')} € facturados</li>
</ul>
);
}
function Empleados() {
const { contenido } = use(leer<Pagina<Empleado>>(rutas.empleados));
return (
<ul>
{contenido.map((e) => (
<li key={e.empId}>
{e.apellidos}, {e.nombre}
</li>
))}
</ul>
);
}
function Pedidos() {
const pedido = use(leer<Pedido>(rutas.pedidos));
return <p>Pedido {pedido.pedidoId}</p>;
}
function Contenido({ seccion }: { seccion: Seccion }) {
switch (seccion) {
case 'inicio':
return <Inicio />;
case 'empleados':
return <Empleados />;
case 'pedidos':
return <Pedidos />;
case 'informes':
return <Informes />;
}
}
export default function App() {
const [seccion, setSeccion] = useState<Seccion>('inicio');
const [pendiente, iniciarTransicion] = useTransition();
return (
<section className="tarjeta">
<h2>EMPRESA</h2>
<nav className="fila" aria-label="Secciones">
{secciones.map((s) => (
<button
key={s.id}
aria-pressed={s.id === seccion}
onClick={() => iniciarTransicion(() => setSeccion(s.id))}
>
{s.texto}
</button>
))}
{pendiente && <span className="aviso">Cargando...</span>}
</nav>
<ErrorBoundary
claveReinicio={seccion}
alReiniciar={() => {
if (seccion !== 'informes') invalidar(rutas[seccion]);
}}
alternativa={(error, reiniciar) => (
<div className="error" role="alert">
<h3>Esta sección no está disponible</h3>
<p>{error.message}</p>
<button onClick={reiniciar}>Reintentar</button>
</div>
)}
>
<Suspense fallback={<p className="cargando">Cargando sección...</p>}>
<div className={pendiente ? 'pendiente' : ''}>
<Contenido seccion={seccion} />
</div>
</Suspense>
</ErrorBoundary>
</section>
);
}
Recorrido real, con la API a 300 ms:
| Acción | Qué se ve |
|---|---|
| Carga inicial | Cargando sección..., y luego 24 empleados, 24 pedidos, 61.617,46 € facturados |
Pulsar Empleados | Las cifras siguen atenuadas con el aviso Cargando..., y luego la lista |
Pulsar Pedidos | La frontera muestra Pedido 9999 no encontrado y el botón Reintentar |
Pulsar Informes | La frontera se reinicia y aparece Informe anual, cargado con lazy |
Observa cómo cada pieza se ocupa de una sola cosa: useTransition del cambio sin parpadeo, Suspense de la primera carga, ErrorBoundary de los fallos y use de leer los datos. Los componentes de sección no tienen ni un if (cargando).
12. Errores frecuentes y depuración
| Síntoma | Causa probable | Solución |
|---|---|---|
La pantalla se queda en Cargando... y la pestaña Red muestra peticiones repetidas | Promesa creada durante el render | Una caché fuera del componente |
| Muestra el mensaje de error sin que haya fallado nada | use dentro de try/catch | Quita el try/catch y usa una Error Boundary |
Reintentar vuelve a fallar al instante | La promesa rechazada sigue en la caché | Invalida la clave antes de reiniciar |
Reintentar no hace nada con lazy | lazy recuerda el fallo | Crea otro lazy o recarga la página |
| La transición no evita el fallback | La frontera es nueva, o hay una key que cambia por encima | Sube el Suspense y quita la key |
Tras un await vuelve a parpadear | El setState quedó fuera de la transición | Envuélvelo otra vez en iniciarTransicion |
useDeferredValue no mejora nada, o empeora | El componente lento no está en memo | Añade memo, o usa React Compiler |
| El campo de texto pierde letras | Su estado está dentro de una transición | Saca el setState del campo de la transición |
| La Error Boundary no captura un fallo de un clic | No captura manejadores | try/catch, y estado de error |
captureOwnerStack is not a function en producción | Solo existe en desarrollo | Protégela con import.meta.env.DEV |
El fallback parpadea en cada render del padre | lazy declarado dentro del componente | Decláralo a nivel de módulo |
Para depurar estos casos:
- React DevTools: en la pestaña Components puedes forzar que una frontera muestre su
fallback, y el panel Profiler (módulo 14) muestra qué se ha renderizado y por qué. - Pestaña Red del navegador: es la primera que debes mirar si sospechas de cascadas o bucles. La columna de tiempos enseña qué petición espera a cuál.
- Limitar la CPU y la red: en la pestaña Rendimiento puedes ralentizar la CPU, y en Red, simular una conexión lenta. Lo que en tu máquina va sobrado puede bloquearse en un portátil de hace años.
oxlint: detectauseentry/catchylazydentro del render.
13. Ejercicios
Salvo que se indique otra cosa, usa los ficheros de los apartados anteriores: api.ts, recurso.ts, ErrorBoundary.tsx, FilasPedidos.tsx e Informes.tsx.
Ejercicio 1: Botón de pestaña con su propia transición
Crea un componente BotonPestana que reciba activa, alElegir y children. Debe gestionar su propia transición y mostrar ` … junto al texto solo en el botón pulsado. Las pestañas son Inicio (un párrafo) y Pedidos (
Ver solución
// src/App.tsx
import { useState, useTransition, type ReactNode } from 'react';
import { FilasPedidos } from './FilasPedidos';
interface PropsBoton {
activa: boolean;
alElegir: () => void;
children: ReactNode;
}
function BotonPestana({ activa, alElegir, children }: PropsBoton) {
const [pendiente, iniciarTransicion] = useTransition();
return (
<button
aria-pressed={activa}
className={pendiente ? 'pendiente' : ''}
disabled={activa}
onClick={() => iniciarTransicion(alElegir)}
>
{children}
{pendiente && ' ...'}
</button>
);
}
type Pestana = 'inicio' | 'pedidos';
export default function App() {
const [pestana, setPestana] = useState<Pestana>('inicio');
return (
<section className="tarjeta">
<div className="fila">
<BotonPestana activa={pestana === 'inicio'} alElegir={() => setPestana('inicio')}>
Inicio
</BotonPestana>
<BotonPestana activa={pestana === 'pedidos'} alElegir={() => setPestana('pedidos')}>
Pedidos
</BotonPestana>
</div>
{pestana === 'inicio' ? <p>Bienvenido</p> : <FilasPedidos />}
</section>
);
}
Al pulsar Pedidos, la barra muestra Inicio | Pedidos ... mientras sigue visible Bienvenido. Cada botón tiene su propio useTransition, y por eso el aviso aparece donde se ha pulsado. La función alElegir del padre se ejecuta dentro de la transición del hijo, y el set de dentro cuenta como transición: lo que importa es dónde se ejecuta, no dónde se escribió.
Ejercicio 2: Catálogo con búsqueda diferida
Crea un buscador de productos contra GET /api/productos?q=...&orden=nombre&tamano=8. Debe mostrar el total, una lista con nombre, precio y categoría, y atenuar los resultados mientras llegan los nuevos. El campo nunca debe bloquearse. Haz que el componente de resultados esté protegido contra renders inútiles.
Ver solución
// src/App.tsx
import { memo, Suspense, use, useDeferredValue, useState } from 'react';
import { leer } from './recurso';
import type { Pagina, Producto } from './api';
const Resultados = memo(function Resultados({ consulta }: { consulta: string }) {
const ruta = `/productos?q=${encodeURIComponent(consulta)}&orden=nombre&tamano=8`;
const { contenido, total } = use(leer<Pagina<Producto>>(ruta));
return (
<>
<p>{total} productos</p>
<ul>
{contenido.map((p) => (
<li key={p.productoId}>
{p.nombre}: {p.precio} € ({p.categoria})
</li>
))}
</ul>
</>
);
});
export default function App() {
const [texto, setTexto] = useState('');
const consulta = useDeferredValue(texto);
const desfasado = texto !== consulta;
return (
<section className="tarjeta">
<h2>Catálogo</h2>
<input aria-label="Buscar producto" value={texto} onChange={(e) => setTexto(e.target.value)} />
<Suspense fallback={<p className="cargando">Cargando catálogo...</p>}>
<div className={desfasado ? 'pendiente' : ''}>
<Resultados consulta={consulta} />
</div>
</Suspense>
</section>
);
}
Al inicio salen 20 productos. Tras teclear port se ve la lista anterior atenuada, y al final 4 productos: los tres portátiles y Silla Ergonómica, porque la API busca también en la descripción y esa palabra aparece en ella. Fíjate en que cada consulta intermedia se queda en la caché, de modo que borrar letras no repite peticiones.
Ejercicio 3: Precarga sin fallback
Importa InformeVentas con lazy. Al pasar el ratón por el botón Ver ventas, o al llegar a él con el teclado, debe empezar la descarga. Después, al pulsarlo, el informe debe aparecer sin que se vea ningún fallback.
Ver solución
// src/App.tsx
import { lazy, Suspense, useState, useTransition } from 'react';
const cargarVentas = () =>
import('./Informes').then((modulo) => ({ default: modulo.InformeVentas }));
const InformeVentas = lazy(cargarVentas);
export default function App() {
const [abierto, setAbierto] = useState(false);
const [pendiente, iniciarTransicion] = useTransition();
function precargar() {
void cargarVentas();
}
return (
<section className="tarjeta">
<button
onMouseEnter={precargar}
onFocus={precargar}
onClick={() => iniciarTransicion(() => setAbierto(true))}
>
{pendiente ? 'Abriendo...' : 'Ver ventas'}
</button>
<Suspense fallback={<p className="cargando">Cargando...</p>}>
{abierto && <InformeVentas />}
</Suspense>
</section>
);
}
Lo comprobamos observando el DOM con un MutationObserver:
| Variante | ¿Se ve el fallback? |
|---|---|
Solo precarga, Suspense dentro del condicional | Sí |
Precarga y transición, Suspense dentro del condicional | Sí: la frontera es nueva |
Precarga, transición y Suspense fuera del condicional | No |
La precarga no basta porque, aunque el fichero ya esté en memoria, lazy resuelve una promesa y eso lleva al menos un instante. Sin transición, ese instante basta para que React monte el fallback. Quien evita el fallback es la transición sobre una frontera que ya existe. La precarga aporta otra cosa: que la espera sea mínima, porque el fichero ya está descargado cuando pulsas.
Ejercicio 4: Detalle de pedido
Muestra tres botones (5001, 5002 y 5003) y, debajo, el detalle del pedido de GET /api/pedidos/:id: cliente, líneas y total. Al cambiar de pedido debe verse el fallback, no el pedido anterior.
Ver solución
// src/App.tsx
import { Suspense, use, useState } from 'react';
import { leer } from './recurso';
interface Linea {
numLinea: number;
producto: string;
cantidad: number;
precioUnitario: number;
}
interface PedidoDetalle {
pedidoId: number;
cliente: string;
estado: string;
total: number;
lineas: Linea[];
}
function DetallePedido({ id }: { id: number }) {
const pedido = use(leer<PedidoDetalle>(`/pedidos/${id}`));
return (
<>
<h3>
Pedido {pedido.pedidoId}: {pedido.cliente}
</h3>
<ul>
{pedido.lineas.map((l) => (
<li key={l.numLinea}>
{l.cantidad} x {l.producto} a {l.precioUnitario} €
</li>
))}
</ul>
<p>Total: {pedido.total.toLocaleString('es-ES')} €</p>
</>
);
}
export default function App() {
const [id, setId] = useState(5001);
return (
<section className="tarjeta">
<div className="fila">
{[5001, 5002, 5003].map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => setId(n)}>
{n}
</button>
))}
</div>
<Suspense key={id} fallback={<p className="cargando">Cargando pedido...</p>}>
<DetallePedido id={id} />
</Suspense>
</section>
);
}
El pedido 5001 muestra Pedido 5001: Talleres Martín S.L., sus tres líneas y Total: 1802,9 €. Aquí key={id} en la frontera es deliberado: mostrar un pedido antiguo con el botón de otro sería confuso. Como no hay transición, ya saldría el fallback sin key, pero con ella el código deja clara la intención, y sigue funcionando si luego añades una transición al cambio de pedido.
Ejercicio 5: Llevar a la frontera un error de un manejador
Las Error Boundaries no ven los errores de un manejador asíncrono. Escribe un hook useLanzarError que devuelva una función para reenviar un error a la frontera más cercana. Úsalo en un componente con dos botones, Comprobar 100 y Comprobar 999, que pidan /empleados/:id con obtener.
Ver solución
// src/App.tsx
import { Suspense, useCallback, useState } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { obtener, type Empleado } from './api';
function useLanzarError() {
const [, setEstado] = useState<null>(null);
return useCallback((error: unknown) => {
setEstado(() => {
throw error; // se ejecuta durante el render, donde la frontera sí lo ve
});
}, []);
}
function Comprobador() {
const lanzarError = useLanzarError();
const [empleado, setEmpleado] = useState<Empleado | null>(null);
async function comprobar(id: number) {
try {
setEmpleado(await obtener<Empleado>(`/empleados/${id}`));
} catch (error) {
lanzarError(error);
}
}
return (
<div className="fila">
<button onClick={() => comprobar(100)}>Comprobar 100</button>
<button onClick={() => comprobar(999)}>Comprobar 999</button>
{empleado && <span>{empleado.nombre}</span>}
</div>
);
}
export default function App() {
return (
<section className="tarjeta">
<ErrorBoundary
alternativa={(error, reiniciar) => (
<div className="error" role="alert">
<p>{error.message}</p>
<button onClick={reiniciar}>Volver</button>
</div>
)}
>
<Suspense fallback={null}>
<Comprobador />
</Suspense>
</ErrorBoundary>
</section>
);
}
El truco está en la función actualizadora de setEstado: React la ejecuta durante el render, así que el throw ocurre donde la frontera sí escucha. Comprobar 100 muestra Ana, Comprobar 999 muestra Empleado 999 no encontrado y Volver restaura los botones. Con useTransition no necesitas este truco: basta con lanzar el error dentro de la transición, como viste en el apartado 3.
Ejercicio 6: De cascada a paralelo
Muestra la ficha del cliente 1001 (GET /api/clientes/1001) y, en un componente hijo, cuántos pedidos tiene (GET /api/clientes/1001/pedidos). Con la API a 300 ms, todo debe aparecer en unos 300 ms, no en 600. Compruébalo en la pestaña Red.
Ver solución
// src/App.tsx
import { Suspense, use } from 'react';
import { leer } from './recurso';
import type { Pagina, Pedido } from './api';
interface ClienteDetalle {
clienteId: number;
nombre: string;
ciudad: string;
}
function PedidosCliente({ id }: { id: number }) {
const { contenido } = use(leer<Pagina<Pedido>>(`/clientes/${id}/pedidos`));
return <p>{contenido.length} pedidos</p>;
}
function FichaCliente({ id }: { id: number }) {
const rutaCliente = `/clientes/${id}`;
const promesaCliente = leer<ClienteDetalle>(rutaCliente);
leer(`/clientes/${id}/pedidos`); // precarga: arranca antes de que el hijo se monte
const cliente = use(promesaCliente);
return (
<>
<h3>{cliente.nombre}</h3>
<PedidosCliente id={id} />
</>
);
}
export default function App() {
return (
<section className="tarjeta">
<Suspense fallback={<p className="cargando">Cargando cliente...</p>}>
<FichaCliente id={1001} />
</Suspense>
</section>
);
}
Tiempos medidos: /clientes/1001 de 0 a 308 ms, y /clientes/1001/pedidos de 1 a 310 ms. El resultado es Talleres Martín S.L. y 3 pedidos. Como use interrumpe el render, cualquier petición escrita después del primer use no se lanzaría hasta la vuelta. Por eso el orden es lo importante.
Ejercicio 7: Paginación sin parpadeo
Pagina los empleados de 5 en 5 con GET /api/empleados?orden=apellidos&tamano=5&pagina=N. Debe mostrar Página N de M y los botones Anterior y Siguiente. Al cambiar de página, la lista anterior debe seguir visible y atenuada hasta que llegue la nueva.
Ver solución
// src/App.tsx
import { Suspense, use, useState, useTransition } from 'react';
import { leer } from './recurso';
import type { Empleado, Pagina } from './api';
function ruta(pagina: number) {
return `/empleados?orden=apellidos&tamano=5&pagina=${pagina}`;
}
function Tabla({ pagina }: { pagina: number }) {
const { contenido } = use(leer<Pagina<Empleado>>(ruta(pagina)));
return (
<ul>
{contenido.map((e) => (
<li key={e.empId}>
{e.apellidos}, {e.nombre}
</li>
))}
</ul>
);
}
function Paginas({ pagina, alCambiar }: { pagina: number; alCambiar: (n: number) => void }) {
const { paginas } = use(leer<Pagina<Empleado>>(ruta(pagina)));
return (
<div className="fila">
<button disabled={pagina === 1} onClick={() => alCambiar(pagina - 1)}>Anterior</button>
<span>Página {pagina} de {paginas}</span>
<button disabled={pagina === paginas} onClick={() => alCambiar(pagina + 1)}>Siguiente</button>
</div>
);
}
export default function App() {
const [pagina, setPagina] = useState(1);
const [pendiente, iniciarTransicion] = useTransition();
function cambiar(nueva: number) {
iniciarTransicion(() => setPagina(nueva));
}
return (
<section className="tarjeta">
<h2>Empleados</h2>
<Suspense fallback={<p className="cargando">Cargando empleados...</p>}>
<Paginas pagina={pagina} alCambiar={cambiar} />
<div className={pendiente ? 'pendiente' : ''}>
<Tabla pagina={pagina} />
</div>
</Suspense>
</section>
);
}
La primera carga muestra Página 1 de 5, de Castro Méndez, Iván a Gil Soto, Nuria. Al pulsar Siguiente, a los 100 ms sigue la página 1 atenuada y, al llegar, la página 2 empieza por Gómez Díaz, Pedro. No hay que escribir ningún estado cargando, y la misma ruta se lee en dos componentes sin duplicar la petición gracias a la caché.
Ejercicio 8: Registro central de errores
Crea src/registro.ts, que exporte un array errores y las opciones de createRoot. Cada entrada debe guardar el tipo (capturado, sin capturar, recuperable), el mensaje, el componentStack y, solo en desarrollo, la pila de propietarios. Pruébalo con un componente que lance un error y un botón que muestre el registro.
Ver solución
// src/registro.ts
import { captureOwnerStack, type ErrorInfo } from 'react';
export interface EntradaError {
tipo: 'capturado' | 'sin capturar' | 'recuperable';
mensaje: string;
arbol: string;
propietarios: string | null;
}
export const errores: EntradaError[] = [];
function guardar(tipo: EntradaError['tipo'], error: unknown, info: ErrorInfo) {
errores.push({
tipo,
mensaje: error instanceof Error ? error.message : String(error),
arbol: info.componentStack ?? '',
// captureOwnerStack solo existe en desarrollo
propietarios: import.meta.env.DEV ? captureOwnerStack() : null,
});
}
export const opciones = {
onCaughtError: (error: unknown, info: ErrorInfo) => guardar('capturado', error, info),
onUncaughtError: (error: unknown, info: ErrorInfo) => guardar('sin capturar', error, info),
onRecoverableError: (error: unknown, info: ErrorInfo) => guardar('recuperable', error, info),
};
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { opciones } from './registro';
createRoot(document.getElementById('root')!, opciones).render(
<StrictMode>
<App />
</StrictMode>,
);
// src/App.tsx
import { useState } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { errores } from './registro';
function Salario({ importe }: { importe: number }) {
if (importe < 0) throw new Error('Salario negativo');
return <p>{importe} €</p>;
}
function Nomina() {
return <Salario importe={-1} />;
}
export default function App() {
const [ver, setVer] = useState(false);
return (
<section className="tarjeta">
<ErrorBoundary alternativa={(e) => <p className="error">{e.message}</p>}>
<Nomina />
</ErrorBoundary>
<button onClick={() => setVer(true)}>Ver registro</button>
{ver && <pre>{JSON.stringify(errores.map((e) => [e.tipo, e.mensaje, e.propietarios]))}</pre>}
</section>
);
}
El registro contiene una entrada de tipo capturado, con el mensaje Salario negativo, y en propietarios aparecen Nomina y App. Hay una sola entrada aunque <StrictMode> renderice dos veces, porque React solo notifica el error una vez. En un proyecto real, guardar enviaría los datos a un servicio de monitorización.
Ejercicio 9 (propuesto): Panel de pedidos por estado
Crea pestañas con los estados de los pedidos (GET /api/pedidos?estado=...). Usa una transición para cambiar de estado, muestra los pedidos en una tabla y permite reintentar si falla. Añade Suspense y una Error Boundary de forma que el cambio de pestaña no muestre nunca el fallback, pero la primera carga sí. Después simula un fallo deteniendo la API, y comprueba que Reintentar funciona al volver a arrancarla.
Ejercicio 10 (propuesto): lazy con reintentos
Escribe una función lazyConReintentos(cargar, intentos) que devuelva un componente lazy, y que reintente automáticamente la descarga hasta intentos veces, esperando un segundo entre cada intento, antes de dar el error por definitivo. Pruébala con una función de carga que falle las dos primeras veces. Pista: el reintento debe estar dentro de la función que le pasas a lazy.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Actualización no urgente | useTransition | Interrumpible, con pendiente. El estado de un campo controlado no va dentro |
| Transición fuera de componentes | startTransition | Sin pendiente, y sus errores async no llegan a la frontera |
| Transición asíncrona 🔄 19.0 | iniciarTransicion(async () => ...) | Tras un await hay que envolver de nuevo el setState |
| Transiciones independientes 🔄 19.3 | Automático | Una lenta ya no retiene a las demás |
| Valor retrasado | useDeferredValue | Envuelve el valor. El componente lento debe estar en memo |
| Valor inicial 🔄 19.0 | useDeferredValue(v, inicial) | Monta primero con un marcador |
| Declarar una espera | <Suspense fallback> | La frontera más cercana sustituye todo su contenido |
| División de código | lazy | A nivel de módulo. Recuerda también los fallos |
| Leer una promesa 🆕 19.0 | use(promesa) | La promesa debe ser estable: necesita caché. Nunca en try/catch |
| Evitar cascadas | Lanzar antes del primer use | Empieza las peticiones antes de suspender |
| Sin parpadeo | Transición con Suspense | Protege fronteras existentes, no las nuevas ni las que cambian de key |
| Capturar errores | Error Boundary (clase) | getDerivedStateFromError y componentDidCatch. No capturan manejadores |
| Reintentar | setState e invalidar | Limpia también la caché de la promesa rechazada |
| Notificar errores 🆕 19.0 | onCaughtError, onUncaughtError | En createRoot, centralizados |
| Quién creó qué 🆕 19.1 | captureOwnerStack | Solo en desarrollo, y solo mientras React ejecuta tu código |