Saltar al contenido

useTransition: marcar lo que puede esperar

¿Cómo hago que la interfaz responda mientras React hace algo pesado?

Al terminar vas a poder explicar

  • Explicar qué hace exactamente startTransition con la prioridad de un update
  • Describir por qué isPending se apaga justo cuando termina la transición
  • Reconocer los casos donde una transición no sirve para nada

Un campo de búsqueda que filtra tres mil elementos. Cada tecla dispara un render que tarda cincuenta milisegundos. El usuario escribe rápido y la interfaz se traba: las letras aparecen tarde, con saltos.

La respuesta instintiva es un debounce. Pero un debounce hace que el resultado siempre llegue tarde, incluso cuando la máquina tenía tiempo de sobra. useTransition ataca el problema de otra forma.

No acelera nada: prioriza

startTransition no hace el trabajo más rápido. Le dice a React: este update puede quedarse atrás si llega algo más urgente.

1function alEscribir(e) {2  const valor = e.target.value;3 4  setTexto(valor);                     // urgente: la tecla se ve YA5 6  iniciarTransicion(() => {            // puede esperar7    setResultados(filtrar(valor));8  });9}

Ya sabés cómo funciona esto por dentro: setTexto entra por una lane urgente y setResultados por TransitionLane. React renderiza y commitea la pasada urgente primero. El usuario ve la letra. Después arranca la pasada de transición.

Dos prioridades desde un solo click

AGENDADOpaso 1 / 223
Traza
Cadena de hooks · <>

Sin hooks en este paso.

Árbol de fibers

  • <HostRoot>

Salida

Nada en el host todavía. React no commiteó ningún cambio.

createRoot().render()

Montaje inicial. No hay árbol previo, así que todo se va a crear desde cero.

VER EL CÓDIGO QUE SE ESTÁ TRAZANDO
1function Buscador() {2  const [texto, setTexto] = useState("");3  const [resultados, setResultados] = useState("nada");4  const [pendiente, iniciarTransicion] = useTransition();5 6  function alEscribir(v) {7    setTexto(v);                     // urgente: la tecla se ve YA8 9    iniciarTransicion(() => {        // puede esperar10      setResultados(filtrar(v));11    });12  }13 14  return (15    <div>16      <p>{texto} · {resultados} · {pendiente ? "filtrando…" : "al día"}</p>17    </div>18  );19}

Con el foco acá: ← → un paso · espacio reproduce · Inicio/Fin van a los extremos.

Mirá el panel de salida en cada pintado. En el segundo, el campo ya dice re pero la lista todavía dice nada y el indicador dice filtrando…. Ese frame es todo el valor de la transición.

Por qué isPending se apaga en el momento justo

useTransition son dos hooks: un booleano y una función memoizada. Y el truco está en las prioridades de los dos setState internos.

lo esencial de la implementación
1function iniciarTransicion(fn) {2  setPendiente(true);        // prioridad normal → se ve al instante3 4  enTransicion = true;5  try {6    fn();                    // todo lo de adentro → TransitionLane7    setPendiente(false);     // TAMBIÉN TransitionLane8  } finally {9    enTransicion = false;10  }11}

setPendiente(false) viaja en la misma lane que el trabajo pesado. Por eso baja exactamente cuando la transición termina: llegan juntos en el mismo commit. No hay temporizador ni heurística — es la prioridad la que sincroniza el indicador.

Con trabajo pesado de verdad

React real

useTransition con trabajo pesado de verdad

Escribí rápido en el campo. Sin la transición, cada tecla bloquearía la interfaz durante el filtrado.

Código

1const { useTransition } = React;2 3const TODOS = Array.from({ length: 3000 }, (_, i) => "elemento " + i);4 5function App() {6  const [texto, setTexto] = useState("");7  const [lista, setLista] = useState(TODOS.slice(0, 20));8  const [pendiente, iniciarTransicion] = useTransition();9 10  function alEscribir(e) {11    const v = e.target.value;12    setTexto(v);                                  // urgente: la tecla se ve ya13 14    iniciarTransicion(() => {                     // puede esperar15      const filtrados = TODOS.filter(x => x.includes(v));16      // Trabajo caro a propósito.17      for (let i = 0; i < 2_000_000; i++) {}18      setLista(filtrados.slice(0, 20));19    });20  }21 22  return (23    <div>24      <input value={texto} onChange={alEscribir} placeholder="filtrar…" />25      <span style={{ opacity: pendiente ? 1 : 0.25 }}> filtrando</span>26      <ul>{lista.map(x => <li key={x}>{x}</li>)}</ul>27    </div>28  );29}

Corriendo de verdad

bajando React 19.2.0 de esm.sh…

Registro0 commits

Todo lo que imprimas con console.log aparece acá. Los commits los reporta el Profiler de React de verdad.

Escribí rápido en ese campo. El texto responde tecla a tecla aunque el filtrado de tres mil elementos con un bucle de dos millones de iteraciones esté ocupando el hilo. Sacá el iniciarTransicion y probá de nuevo: se traba.

Cuándo NO sirve

  • No sirve para esperar datos de la red. Para eso está Suspense.
  • No sirve en inputs controlados: el valor del campo tiene que ser urgente siempre, o se siente roto.
  • No sirve si el trabajo pesado está en un efecto: las transiciones sólo afectan la fase render.

Antes de seguir · predecí

Envolvés setBusqueda del value de un input controlado dentro de startTransition. ¿Qué pasa?

1<input2  value={busqueda}3  onChange={e => iniciarTransicion(() => setBusqueda(e.target.value))}4/>

Lo que te llevás

startTransition marca un update como postergable, no lo acelera. isPending baja junto con la transición porque viaja en la misma lane. Sirve cuando hay algo urgente compitiendo, y sólo entonces. Lo que se ve tiene que ir urgente siempre.

Antes de marcarla, comprobá

  • ¿Podés explicar explicar qué hace exactamente startTransition con la prioridad de un update?
  • ¿Podés explicar describir por qué isPending se apaga justo cuando termina la transición?
  • ¿Podés explicar reconocer los casos donde una transición no sirve para nada?