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ó.
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: el componente “suspende” hasta que la promesa resuelve.
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>
);
}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): si la creas dentro del componente, nace una nueva en cada render y nunca termina.
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.
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 4
¿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>
<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 && <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>
{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>
<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 && <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.