Tu Team Builder funciona, pero todavía se nota “de ordenador”: si el roster fuera enorme, escribir en el filtro daría tirones; y al añadir un héroe, esperarías a que el servidor confirmara antes de ver nada. React 19 trae tres herramientas para quitar esa sensación y que la interfaz responda al instante. Como siempre, lo que importa es cuándo usar cada una.
useTransition: lo urgente y lo que puede esperar#
Hay actualizaciones urgentes (que el input muestre lo que tecleas) y actualizaciones que pueden esperar un pelín (filtrar y repintar una lista de mil elementos). Si las tratas igual, la pesada bloquea a la urgente: tecleas y el input se queda pillado mientras React repinta la lista.
useTransition te deja marcar la actualización pesada como una transición, es decir, no urgente. React atiende primero lo urgente y hace la transición sin bloquear.
const [isPending, startTransition] = useTransition();
function alEscribir(valor: string) {
// Urgente: el input se actualiza ya.
setTexto(valor);
// No urgente: el filtrado va en una transición; React no bloquea el input por él.
startTransition(() => {
setFiltro(valor);
});
}isPending vale true mientras la transición trabaja, perfecto para mostrar un “Cargando…” discreto sin congelar nada. El porqué es esa prioridad: tú le dices a React qué puede esperar, y él reparte el trabajo para que lo importante (que la interfaz responda) nunca se resienta.
useOptimistic: la UI optimista, de serie#
Cuando una acción tarda (guardar en el servidor), tienes dos opciones. La lenta: bloquear y esperar la respuesta antes de mostrar nada. La buena: mostrar ya el resultado que esperas, y corregir solo si algo sale mal. A eso se le llama UI optimista, y useOptimistic la implementa por ti.
// Estado real + estado optimista (el real más lo que se está añadiendo).
const [equipo, setEquipo] = useState<HeroeEquipo[]>([]);
const [equipoOptimista, añadirOptimista] = useOptimistic(
equipo,
(estado, nuevo: Heroe) => [...estado, { ...nuevo, guardando: true }],
);
function añadir(hero: Heroe) {
startTransition(async () => {
// Aparece YA, marcado como "guardando".
añadirOptimista(hero);
// Se guarda de verdad por detrás.
await guardarHeroe(hero);
// Se confirma el estado real (ya sin la marca).
setEquipo((prev) => [...prev, hero]);
});
}Pintas equipoOptimista (no equipo): así el héroe se ve al instante. Y aquí está la parte honesta, que es la que de verdad importa: si la acción falla y no confirmas el estado real, React revierte el cambio optimista solo. El usuario ve aparecer el héroe y, si el servidor lo rechaza, desaparecer con un aviso. La interfaz nunca le miente diciendo que se guardó algo que no se guardó.
¿Y cómo “sabe” React que falló, si nadie se lo dice? No lo sabe: no hay detección mágica de errores. El truco es que el estado optimista solo existe mientras la acción está en marcha. Cuando la acción termina —haya ido bien o mal—, ese estado temporal se descarta y React vuelve a pintar el estado real. Si el guardado fue bien y confirmaste el real con setEquipo, el héroe ya está en el real y no se nota el relevo; si falló y no confirmaste nada, el real nunca tuvo al héroe, así que al desvanecerse el optimista, el héroe se va con él. Reviertes “sin hacer nada” precisamente porque no hiciste nada: no tocaste el estado real.
use(): leer una promesa o un context#
use() es un hook distinto a todos los demás por dos motivos. Primero, puede llamarse dentro de un if (rompe la regla de “siempre en el nivel superior”). Segundo, sirve para leer el valor de una promesa o de un context: en el primer caso el componente “suspende” hasta que la promesa resuelve; en el segundo hace exactamente lo mismo que useContext, pero al poder ir dentro de un condicional, es más flexible.
Leer una promesa (con Suspense):
import { use, Suspense } from "react";
// La promesa se crea FUERA del render (aquí, a nivel de módulo) para que sea estable.
const heroesPromesa = cargarHeroes();
function ListaHeroes() {
// use() lee la promesa: suspende el componente hasta que resuelve.
const heroes = use(heroesPromesa);
return <ul>{heroes.map((h) => <li key={h.id}>{h.nombre}</li>)}</ul>;
}
export default function App() {
return (
// Mientras ListaHeroes "suspende", se ve el fallback.
<Suspense fallback={<p className="cargando">Cargando héroes...</p>}>
<ListaHeroes />
</Suspense>
);
}Leer un context (alternativa condicional a useContext):
import { createContext, use } from "react";
// Contexto creado en otro fichero e importado aquí.
const ModoContext = createContext<"compacto" | "completo">("completo");
function FilaHero({ mostrarDetalles }: { mostrarDetalles: boolean }) {
if (mostrarDetalles) {
// use() puede ir dentro de un if; useContext no puede.
const modo = use(ModoContext);
// Usa el valor del contexto para decidir qué renderizar.
return <p>Modo: {modo}</p>;
}
// Si la condición no se cumple, no se llama a use() en absoluto.
return null;
}Estas cuatro líneas hacen lo mismo que useContext(ModoContext), pero al estar dentro de un if, el hook solo se ejecuta cuando mostrarDetalles es true. Con useContext eso era imposible (rompe las reglas de los hooks); con use() es perfectamente válido.
Dónde SÍ y dónde NO puede ir use():
use() tiene que estar en el cuerpo de render de un componente o en el cuerpo de un Hook personalizado. Eso incluye los if y los bucles que hay dentro de ese cuerpo, que es precisamente lo que lo hace especial. Pero sigue siendo un hook a efectos de dónde no puede vivir:
- No puede llamarse desde un event handler (una función como
onClickoalEscribir). - No puede llamarse dentro de una callback anidada (
setTimeout,.then()…). - No puede llamarse dentro de
useEffect,useMemo,useCallbackni de ningún otro hook: esos son callbacks, no el cuerpo de render. - No puede ir dentro de un bloque
try/catch(Suspense usa lanzamiento de valores internamente, y elcatchlo interrumpiría).
Si lo pones en cualquiera de esos sitios, React lo detecta en desarrollo y lanza un error: “use fuera de un componente o Hook”.
Comparado con cargar datos a mano con useEffect (estado de carga, de error, de datos), esto es declarativo: no escribes el “si está cargando, muestra esto”; lo hace el Suspense. Eso sí, la promesa tiene que ser estable (creada fuera del render o cacheada). ¿Y qué pasa si no lo es? Si la creas dentro del componente, en cada render nace una promesa nueva, y eso dispara un bucle de renders: el componente suspende, React reintenta, vuelve a crear otra promesa distinta, suspende otra vez… y así sin parar. No es que “tarde”: es que no converge nunca. Por eso la promesa se crea fuera del render o se cachea, para que en cada render sea exactamente la misma.
Suspense, la pieza que lo sostiene#
<Suspense fallback={...}> marca un límite en el árbol: si algo de dentro suspende (porque está leyendo una promesa que aún no resolvió), React muestra el fallback en su lugar, y cambia al contenido cuando los datos llegan. Es lo que convierte el “cargando” de un problema que resuelves a mano en algo que declaras una vez. Lo verás a fondo, aplicado a datos de verdad, en el capítulo de data-fetching.
Y si algo falla: error boundaries#
Suspense resuelve la mitad del problema: qué enseñar mientras los datos aún no están. Falta la otra mitad: qué enseñar cuando fallan. Una promesa puede rechazar (el servidor devuelve un 500, se cae la red) y, más en general, cualquier componente puede lanzar un error al renderizar.
¿Y qué pasa si nadie lo recoge? Aquí está el “¿y qué?” que conviene ver una vez: un error lanzado en el render se propaga hacia arriba, y si llega a la raíz sin que nadie lo atrape, React desmonta el árbol entero. No se rompe solo el componente que falló: desaparece la app y el usuario se queda mirando una página en blanco, sin pista de qué pasó. En desarrollo ves el error en rojo; en producción, el blanco a secas.
La herramienta para cortar esa caída es un error boundary: un componente que envuelve a un trozo del árbol, atrapa los errores que salgan de él y muestra un fallback en su lugar, dejando el resto de la app en pie.
React solo trae el error boundary como componente de clase —es el único punto donde React todavía necesita una clase—. Como en este curso trabajamos siempre con componentes de función, usamos la librería estándar para esto, react-error-boundary, que te da ese mismo boundary ya envuelto en un componente listo para usar:
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
// El fallback recibe el error capturado y una función para reintentar.
function Aviso({ error, resetErrorBoundary }: FallbackProps) {
return (
// role="alert" hace que un lector de pantalla anuncie el aviso al aparecer.
<div role="alert" className="error-msg">
<p>No se pudo cargar: {error.message}</p>
{/* resetErrorBoundary limpia el error y vuelve a montar el subárbol. */}
<button onClick={resetErrorBoundary}>Reintentar</button>
</div>
);
}
function App() {
return (
// Si ListaHeroes (o cualquier hijo) lanza al renderizar, se ve Aviso, no una página en blanco.
<ErrorBoundary FallbackComponent={Aviso}>
<ListaHeroes />
</ErrorBoundary>
);
}Y aquí se cierra el círculo con Suspense. Son dos piezas complementarias que se anidan: el error boundary por fuera (para el fallo) y el Suspense por dentro (para la espera).
// El patrón completo de carga declarativa de datos:
// ErrorBoundary cubre el "ha fallado"; Suspense, el "todavía cargando".
<ErrorBoundary FallbackComponent={Aviso}>
<Suspense fallback={<p className="cargando">Cargando héroes...</p>}>
{/* ListaHeroes lee la promesa con use(): suspende mientras carga, lanza si rechaza. */}
<ListaHeroes />
</Suspense>
</ErrorBoundary>Con esas dos envolturas cubres los tres estados —cargando, error y datos— sin escribir a mano un solo if (cargando) ni if (error). Es justo el código repetitivo de los tres useState + useEffect que tenías al cargar datos a pelo, pero declarado una vez con el árbol.
Un límite importante, y de los que más caen en una entrevista: un error boundary solo captura errores de render. Lo que lanza un componente al renderizarse, sí. Lo que lanza un onClick cuando lo pulsas, no —eso ocurre fuera del render; para un handler usas un try/catch normal—. Y tampoco atrapa errores de código asíncrono suelto (un .then o un setTimeout que pete por su cuenta). Captura el render; el resto es cosa tuya.
Pruébalo#
Demo 1: useTransition. Escribe en el filtro (prueba “42”): el input responde al instante aunque la lista sea de 800 elementos, y verás el aviso de “Filtrando…” durante la transición.
Demo 2: useOptimistic. Pulsa “Añadir”: el héroe aparece al instante como “Guardando…” y se confirma cuando termina el guardado simulado.
Comprueba lo que sabes#
Pregunta 1 de 5
¿Para qué sirve useTransition?
Tu turno#
Ejercicio · en esta página
El Team Builder, al instante
Filtra el roster con useTransition (sin trabar el input) y añade héroes al equipo de forma optimista con useOptimistic.
Paso 1: Filtro con useTransition
- El texto del input se actualiza al instante (urgente)
- El filtro se aplica dentro de una transición con startTransition
- isPending muestra un aviso de 'Filtrando...' mientras trabaja
Paso 2: Añadido optimista
- Añadir un héroe lo muestra YA en el equipo con useOptimistic, marcado como 'guardando'
- Cuando guardarHeroe() resuelve, el héroe se confirma en el estado real
- El añadido optimista corre dentro de una transición o acción
Paso 3: Revertir al fallar
- Si guardarHeroe() falla, el añadido optimista se revierte y se avisa al usuario
- El filtro y el añadido no se interfieren (transiciones separadas)
- Aguanta a 375px
Ver soluciones
import { useState, useTransition } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
}
const BASE = ["Tracer", "Mercy", "Reinhardt", "Ana", "Genji", "Sigma"];
const ROLES = ["Daño", "Apoyo", "Tanque"];
// Roster grande para que filtrar (y repintar) tenga trabajo de verdad.
const ROSTER: Heroe[] = Array.from({ length: 200 }, (_, i) => ({
id: i,
nombre: BASE[i % BASE.length] + " #" + (Math.floor(i / BASE.length) + 1),
rol: ROLES[i % ROLES.length],
}));
export default function App() {
// El texto del input: URGENTE, debe responder a cada tecla al instante.
const [texto, setTexto] = useState("");
// El texto por el que filtramos: lo actualizamos dentro de una transición.
const [filtro, setFiltro] = useState("");
// isPending es true mientras la transición (el filtrado pesado) está en curso.
const [isPending, startTransition] = useTransition();
function alEscribir(valor: string) {
// Urgente: el input se actualiza ya, sin esperar a nada.
setTexto(valor);
// No urgente: el filtrado va en una transición. React no bloquea el input por él.
startTransition(() => {
setFiltro(valor);
});
}
// DERIVADO: la lista visible sale de filtrar el roster por el filtro "diferido".
const visibles = ROSTER.filter((hero) =>
hero.nombre.toLowerCase().includes(filtro.toLowerCase()),
);
return (
<section>
<h1 className="titulo">Roster ({visibles.length})</h1>
<input
className="buscador"
type="text"
placeholder="Buscar héroe..."
value={texto}
onChange={(evento) => alEscribir(evento.target.value)}
/>
{/* Mientras la transición trabaja, avisamos sin trabar el input. */}
{isPending && <p className="cargando">Filtrando...</p>}
<div className="lista">
{visibles.map((hero) => (
<article key={hero.id} className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
</article>
))}
</div>
</section>
);
} Por qué este nivel
- La clave es separar dos actualizaciones: el texto del input (urgente, setTexto) y el filtro que dispara el repintado pesado (no urgente, dentro de startTransition). Así, por mucho que cueste filtrar y pintar la lista, el input nunca se traba. isPending te da el aviso de 'estoy trabajando' sin bloquear.
import { useOptimistic, useState, useTransition } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
}
// Un héroe del equipo puede estar a medio guardar (optimista).
type HeroeEquipo = Heroe & { guardando?: boolean };
const BASE = ["Tracer", "Mercy", "Reinhardt", "Ana", "Genji", "Sigma"];
const ROLES = ["Daño", "Apoyo", "Tanque"];
const ROSTER: Heroe[] = Array.from({ length: 200 }, (_, i) => ({
id: i,
nombre: BASE[i % BASE.length] + " #" + (Math.floor(i / BASE.length) + 1),
rol: ROLES[i % ROLES.length],
}));
// Simula guardar un héroe en el servidor.
function guardarHeroe(hero: Heroe): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 700));
}
export default function App() {
const [texto, setTexto] = useState("");
const [filtro, setFiltro] = useState("");
// Una transición para el filtro: su isPending es el aviso de "Filtrando...".
const [isFiltrando, startFiltro] = useTransition();
// Otra transición para el añadido (useOptimistic debe correr dentro de una transición).
const [, startAñadir] = useTransition();
const [equipo, setEquipo] = useState<HeroeEquipo[]>([]);
// Estado OPTIMISTA: el equipo real más lo que se está añadiendo, marcado como guardando.
const [equipoOptimista, añadirOptimista] = useOptimistic<
HeroeEquipo[],
Heroe
>(equipo, (estado, nuevo) => [...estado, { ...nuevo, guardando: true }]);
function alEscribir(valor: string) {
// Urgente: el input responde ya.
setTexto(valor);
// No urgente: el filtrado pesado va en una transición.
startFiltro(() => setFiltro(valor));
}
function añadir(hero: Heroe) {
startAñadir(async () => {
// Optimista: el héroe aparece YA en el equipo, marcado como "guardando".
añadirOptimista(hero);
// Guardado real (simulado) por detrás.
await guardarHeroe(hero);
// Estado real: confirmado, ya sin la marca de guardando.
setEquipo((prev) => [...prev, hero]);
});
}
const visibles = ROSTER.filter((hero) =>
hero.nombre.toLowerCase().includes(filtro.toLowerCase()),
);
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="seccion">
<h2 className="seccion__titulo">
Tu equipo ({equipoOptimista.length})
</h2>
{equipoOptimista.length === 0 ? (
<p className="equipo-vacio">Añade héroes desde el roster.</p>
) : (
<div className="lista">
{equipoOptimista.map((hero, i) => (
<article
key={hero.id + "-" + i}
className={hero.guardando ? "tarjeta guardando" : "tarjeta"}
>
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
{/* Si está guardando, muestra el texto provisional; si ya confirmó, el rol real. */}
<p className="tarjeta__rol">
{hero.guardando ? "Guardando..." : hero.rol}
</p>
</article>
))}
</div>
)}
</div>
<div className="seccion">
<h2 className="seccion__titulo">Roster</h2>
<input
className="buscador"
type="text"
placeholder="Buscar héroe..."
value={texto}
onChange={(evento) => alEscribir(evento.target.value)}
/>
{/* isFiltrando es true mientras la transición del filtro sigue en marcha. */}
{isFiltrando && <p className="cargando">Filtrando...</p>}
<div className="lista">
{visibles.map((hero) => (
<article key={hero.id} className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
<button className="boton" onClick={() => añadir(hero)}>
Añadir
</button>
</article>
))}
</div>
</div>
</section>
);
} Por qué es mejor que el anterior
- useOptimistic da la UI optimista de serie: el héroe aparece al instante (marcado como guardando) y el estado real se confirma cuando el guardado termina. La sensación es de inmediatez, aunque la red tarde.
- Dos transiciones separadas (una para el filtro, otra para el añadido) para que el isPending del filtro no se mezcle con el guardado.
import { useOptimistic, useState, useTransition } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
}
type HeroeEquipo = Heroe & { guardando?: boolean };
const BASE = ["Tracer", "Mercy", "Reinhardt", "Ana", "Genji", "Sigma"];
const ROLES = ["Daño", "Apoyo", "Tanque"];
const ROSTER: Heroe[] = Array.from({ length: 200 }, (_, i) => ({
id: i,
nombre: BASE[i % BASE.length] + " #" + (Math.floor(i / BASE.length) + 1),
rol: ROLES[i % ROLES.length],
}));
// Simula guardar en el servidor, y a veces FALLA: los "Sigma" simulan un rechazo,
// para poder ver cómo se revierte el añadido optimista.
function guardarHeroe(hero: Heroe): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (hero.nombre.startsWith("Sigma")) {
reject(new Error("rechazado"));
} else {
resolve();
}
}, 700);
});
}
export default function App() {
const [texto, setTexto] = useState("");
const [filtro, setFiltro] = useState("");
const [isFiltrando, startFiltro] = useTransition();
const [, startAñadir] = useTransition();
const [equipo, setEquipo] = useState<HeroeEquipo[]>([]);
const [error, setError] = useState("");
const [equipoOptimista, añadirOptimista] = useOptimistic<
HeroeEquipo[],
Heroe
>(equipo, (estado, nuevo) => [...estado, { ...nuevo, guardando: true }]);
function alEscribir(valor: string) {
setTexto(valor);
startFiltro(() => setFiltro(valor));
}
function añadir(hero: Heroe) {
setError("");
startAñadir(async () => {
// El héroe aparece YA en el equipo (optimista).
añadirOptimista(hero);
try {
// Si el guardado real va bien, confirmamos el estado.
await guardarHeroe(hero);
setEquipo((prev) => [...prev, hero]);
} catch {
// Si falla, NO tocamos el estado real: React revierte el añadido optimista
// solo, y avisamos al usuario.
setError(
"No se pudo añadir a " + hero.nombre + ". Inténtalo de nuevo.",
);
}
});
}
const visibles = ROSTER.filter((hero) =>
hero.nombre.toLowerCase().includes(filtro.toLowerCase()),
);
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="seccion">
<h2 className="seccion__titulo">
Tu equipo ({equipoOptimista.length})
</h2>
{/* Solo se pinta si hay un mensaje de error (guardado fallido). */}
{error && <p className="error">{error}</p>}
{equipoOptimista.length === 0 ? (
<p className="equipo-vacio">Añade héroes desde el roster.</p>
) : (
<div className="lista">
{equipoOptimista.map((hero, i) => (
<article
key={hero.id + "-" + i}
className={hero.guardando ? "tarjeta guardando" : "tarjeta"}
>
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
{/* Si está guardando, muestra el texto provisional; si ya confirmó, el rol real. */}
<p className="tarjeta__rol">
{hero.guardando ? "Guardando..." : hero.rol}
</p>
</article>
))}
</div>
)}
</div>
<div className="seccion">
<h2 className="seccion__titulo">Roster</h2>
<input
className="buscador"
type="text"
placeholder="Buscar héroe..."
value={texto}
onChange={(evento) => alEscribir(evento.target.value)}
/>
{/* isFiltrando es true mientras la transición del filtro sigue en marcha. */}
{isFiltrando && <p className="cargando">Filtrando...</p>}
<div className="lista">
{visibles.map((hero) => (
<article key={hero.id} className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
<button className="boton" onClick={() => añadir(hero)}>
Añadir
</button>
</article>
))}
</div>
</div>
</section>
);
} Por qué es mejor que el anterior
- Lo que hace honesta a la UI optimista es el camino de error: si el guardado falla, NO confirmamos el estado real, y React revierte el añadido optimista solo. El usuario ve aparecer al héroe y desaparecer con un aviso, en vez de creer que se guardó algo que no se guardó.
- Prueba a añadir un 'Sigma': el guardado simulado lo rechaza, y verás la reversión en directo.