learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

React 19: useOptimistic, useTransition y use()

Las APIs concurrentes de React 19 para que la interfaz responda al instante; useTransition para no bloquear con trabajo pesado, useOptimistic para la UI optimista y use() con Suspense para leer datos.

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.

tsx
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.

tsx
// 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.

tsx
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
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.