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 useTransition y startTransition
  • Retrasar una parte de la interfaz con useDeferredValue y saber por qué necesita memo
  • 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 use y 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, Suspense y lazy ya 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, captureOwnerStack de 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.

Comparación de un render de un bloque que retrasa un clic con un render a trozos que atiende el clic y descarta el trabajo a medias

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:

  1. 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.
  2. 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.
  3. 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

  1. Llamas a iniciarTransicion. React ejecuta tu función en el momento, de forma síncrona, y anota que las actualizaciones de dentro son transiciones.
  2. React hace primero un render urgente con pendiente a true y el estado antiguo. Por eso el aviso aparece de inmediato.
  3. En segundo plano renderiza el estado nuevo con pendiente a false, cediendo el hilo cada poco.
  4. Si llega una actualización urgente, deja la transición, atiende la urgente y vuelve a empezar la transición con el estado actualizado.
  5. 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 setState debe estar dentro de la función. Si envuelves algo que llama a setTimeout y hace el setState allí, esa actualización ya no se considera transición.
  • No puede controlar un campo de texto. Lo vemos ahora.
  • iniciarTransicion es estable entre renders, como las funciones set de useState, 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 51 con una lista lenta debajo y setTexto dentro de startTransition. Durante el tecleo el campo seguía vacío y al final solo contenía 5: 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 cargando con useState, haz la petición fuera de la transición y envuelve solo el setState final en startTransition.

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/catch y 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 memo a 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 initialValue no 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 de startTransition.

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.

Árbol con tres fronteras Suspense donde el componente Ficha se suspende y activa el fallback de su frontera, sin afectar a las demás

Figura 2. Una frontera reemplaza todo lo que tiene dentro. Las demás ramas no se enteran.

⚠️ Importante: Suspense no detecta un fetch en un useEffect. Solo reacciona a las fuentes preparadas para ello: lazy (apartado 6), use con una promesa (apartado 7) y las bibliotecas compatibles, como useSuspenseQuery de 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.

Panel con cifras y lista de empleados ya cargadas, y la zona de últimos pedidos todavía con barras grises

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 useLayoutEffect al ocultarse y volvió a montarse al revelarse. useEffect no 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:

  • lazy espera una promesa de { default: Componente }. Las exportaciones con nombre necesitan el .then del ejemplo.
  • La función de carga se ejecuta la primera vez que se renderiza el componente, y el resultado se recuerda.
  • Si dos lazy importan 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: lazy recuerda también el fallo. Probamos a reintentar con el mismo lazy: la función de carga no se volvió a ejecutar y el error siguió en pantalla. Solo se recuperó creando otro lazy:

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 useEffect y un estado cargando, como en el módulo 09, o usa useSuspenseQuery de 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.

Ciclo de use: render, promesa pendiente, fallback, resolución, nuevo render y entrega del valor; si se rechaza va a la Error Boundary

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 cache de React. No te sirve aquí: es para Server Components. En el cliente no memoriza nada. Lo comprobamos con const f = cache(() => Math.random()), y f() === f() dio false fuera 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:

Diagrama de tiempos: en cascada, la segunda petición empieza cuando acaba la primera y termina a los 620 ms; en paralelo, las dos acaban hacia los 319 ms

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 con lazy o con bibliotecas compatibles. Si usas una versión anterior: aplica estas mismas ideas con lazy, o con useSuspenseQuery de 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 />
    </>
  );
}

Dos tarjetas tras pulsar un botón: sin transición se ve Cargando y el botón ya ha cambiado; con transición sigue el departamento anterior atenuado junto al aviso Actualizando

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:

  1. Montar la frontera dentro del condicional: {abierto && <Suspense>...</Suspense>}. Es nueva cada vez que se abre. Sube el Suspense por encima del condicional (ejercicio 3).
  2. Poner key en un ancestro de la frontera. Si key cambia, todo lo de dentro se crea de nuevo, incluida la frontera. Probamos el caso final con key={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>
  );
}

Buscador con la palabra gar escrita, el aviso Buscando y la lista anterior de empleados atenuada

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

Ficha de empleado con un recuadro rojo: No se ha podido cargar la ficha, Empleado 999 no encontrado, y un botón Reintentar

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: alReiniciar invalida la caché y setState borra el error. Si no invalidases, leer devolvería la misma promesa rechazada, y el error volvería al instante. Es un olvido muy habitual.
  • Cambiar de empleado: al cambiar claveReinicio, componentDidUpdate borra el error. Pulsar Empleado 100 muestra Ana 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: usa componentDidCatch para 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. onCaughtError y onUncaughtError no existen en React 18.3. Si usas una versión anterior: registra en componentDidCatch los errores capturados, y escucha el evento error de window para 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.

A la izquierda, cinco componentes anidados de App a Salario; a la derecha, solo Salario, FichaNomina y App, enlazados por quién creó a quién

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.componentStack en componentDidCatch, 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 captureOwnerStack es undefined: llamarla sin comprobar nada provoca un TypeError. Por eso va siempre detrás de import.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, y onCaughtError también la obtiene. Dentro de un setTimeout devolvió 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: sustituye use(leer(...)) por useSuspenseQuery de 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: detecta use en try/catch y lazy dentro 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 (`). La pestaña activa debe quedar desactivada.

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