O hook useTransition foi introduzido no React 18 e permite marcar atualizações de estado como não urgentes, ajudando o React a priorizar interações mais importantes e manter a interface mais responsiva.

Por padrão, todas as atualizações de estado no React (setState) são tratadas como urgentes e executadas assim que são chamadas. Mas existem situações onde um setState depende da interação do usuário ou não precisa ser executado imediatamente, como:

  • Redirecionamento.
  • Mudança de abas.
  • Busca e listagem de itens.
  • Realização de cálculos caros p/ memória.

Em muitos casos, múltiplas interações consecutivas podem gerar renderizações desnecessárias, interfaces pouco responsivas e até problemas de race condition caso respostas assíncronas cheguem fora de ordem.

Estratégias como desabilitar interações do usuário temporariamente ou adicionar delays, aguardar alguns milissegundos depois da interação do usuário, são usados mas acabam prejudicando a experiência do usuário que precisa esperar a ação acontecer para realizar uma nova.

Nesses casos podemos usar o hook useTransition para marcar atualizações de estado como não urgentes, inclusive em fluxos assíncronos, para que o React possa priorizar interações mais importantes do usuário e manter a interface mais responsiva.

Uso

const [isPending, startTransition] = useTransition();

Documentação: https://react.dev/reference/react/useTransition

Exemplo

Como exemplo vamos usar um componente para simular o cálculo de um carrinho de compras.

Para o código parecer mais limpo eu removi as classes e atributos de acessibilidade. A função fetchTotalPrice é apenas uma promise que resolve 2 segundos depois. Você pode ver o código completo aqui.

Sem useTransition

Quantidade:
1
Preço Total:100
import { useState } from 'react';

export default function WithoutStartTransitionUsage() {
  const [isPending, setIsPending] = useState(false);
  const [quantity, setQuantity] = useState(1);
  const [totalPrice, setTotalPrice] = useState(quantity * price);

  const onChangeQuantityHandler = async (quantity) => {
    setIsPending(true);
    const newPrice = await fetchTotalPrice(quantity);
    setTotalPrice(newPrice);
    setIsPending(false);
  };

  const onIncreaseQuantity = () => {
    setQuantity((prev) => prev + 1);
    onChangeQuantityHandler(quantity + 1);
  };

  const onDecreaseQuantity = () => {
    setQuantity((prev) => prev - 1);
    onChangeQuantityHandler(quantity - 1);
  };

  return (
    <>
      <p>Quantidade:</p>
      <div>
        <button onClick={onDecreaseQuantity}>-</button>
        <span>{quantity}</span>
        <button onClick={onIncreaseQuantity}>+</button>
      </div>

      <p>Preço Total:</p>
      <p>{isPending ? 'Calculando...' : totalPrice}</p>
    </>
  );
}

Ao mudar a quantidade várias vezes seguidas, perceba que o valor total é atualizado após o período de loading, causando uma inconsistência na interface, além de renderizações excessivas desnecessárias.

Poderíamos usar a flag isPending para bloquear os botões mas isso não oferece uma usabilidade muito boa para o usuário.

Sem useTransition & Bloqueando Ação

Quantidade:
1
Preço Total:100

Usando o hook useTransition

Agora vamos ver o mesmo exemplo usando o hook useTransition.

Com useTransition

Quantidade:
1
Preço Total:100
import { useTransition, useState } from 'react';

export default function StartTransitionUsage() {
  const [quantity, setQuantity] = useState(1);
  const [totalPrice, setTotalPrice] = useState(quantity * price);
  const [isPending, startTransition] = useTransition();

  const onChangeQuantityHandler = (quantity) => {
    startTransition(async () => {
      const newPrice = await fetchTotalPrice(quantity);
      startTransition(() => {
        setTotalPrice(newPrice);
      });
    });
  };

  const onIncreaseQuantity = () => {
    setQuantity((prev) => prev + 1);
    onChangeQuantityHandler(quantity + 1);
  };

  const onDecreaseQuantity = () => {
    setQuantity((prev) => prev - 1);
    onChangeQuantityHandler(quantity - 1);
  };

  return (
    <>
      <p>Quantidade:</p>
      <div>
        <button onClick={onDecreaseQuantity}>-</button>
        <span>{quantity}</span>
        <button onClick={onIncreaseQuantity}>+</button>
      </div>

      <p>Total:</p>
      <p>{isPending ? 'Calculando...' : totalPrice}</p>
    </>
  );
}

Agora a interface parece mais responsiva porque o React prioriza a atualização da quantidade enquanto o cálculo e exibição do preço total são tratados como atualizações não urgentes.

Além disso os botões continuam disponíveis para o usuário e não precisamos de mais um setState (setIsPending) para controlar quando uma ação está ocorrendo.

Transições Assíncronas

Você também deve ter notado que dentro da função onChangeQuantityHandler há dois startTransition. Um com a requisição assíncrona para fetchTotalPrice e outro para o setState setTotalPrice.

const onChangeQuantityHandler = (price) => {
  startTransition(async () => {
    const newPrice = await fetchTotalPrice(price);
    startTransition(() => {
      setTotalPrice(newPrice);
    });
  });
};

Isso é necessário porque o React atualmente perde o contexto da transição após um await. Dessa forma garantimos que a transição ainda esteja acontecendo até setTotalPrice ser chamado.

Para chamadas síncronas só é necessário um startTransition:

startTransition(() => {
  setUser(null);
  setSession(null);
});

Tratando Erros

Como em qualquer requisição assíncrona podemos lidar com um erro dentro de um startTransition usando um try/catch para tratar o erro e mostrar uma mensagem para o usuário.

Com useTransition & Error Handler

Quantidade:
1
Preço Total:100
export default function StartTransitionUsage() {
  const [hasError, setHasError] = useState(false);
  //...
  const onChangeQuantityHandler = (price) => {
    setHasError(false);
    startTransition(async () => {
      try {
        const newPrice = await fetchTotalPrice(price);
        startTransition(() => {
          setTotalPrice(newPrice);
        });
      } catch {
        setHasError(true);
      }
    });
  };
}

Nesse caso o estado de erro foi mantido fora da transição propositalmente, já que feedbacks de erro normalmente devem ser atualizações urgentes para o usuário.

Conclusão

O hook useTransition foi criado para resolver o pequeno problema de renderizações excessivas e desnecessárias mas que numa aplicação acaba prejudicando a performance da página e experiência do usuário. Com ele é possível tornar a interface mais responsiva sem a necessidade de adicionar complexidade extra ao código.

Na próxima vez que perceber um gargalo na performance da página ou estiver criando um estado para indicar que uma ação está ocorrendo, considere usar o hook useTransition, isso deve melhorar bastante a experiência do usuário.