The useTransition hook was introduced in React 18 and allows you to mark state updates as non-urgent, helping React prioritize more important interactions and keep the interface more responsive.
By default, all state updates in React (setState) are treated as urgent and executed as soon as they are called. But there are situations where a setState depends on user interaction or doesn't need to be executed immediately, such as:
- Redirecting.
- Switching tabs.
- Fetching and listing items.
- Performing expensive computations.
In many cases, multiple consecutive interactions can generate unnecessary renders, unresponsive interfaces, and even race condition problems if asynchronous responses arrive out of order.
Strategies like temporarily disabling user interactions or adding delays—waiting a few milliseconds after user interaction—are used but end up hurting the experience for the user who has to wait for the action to happen before performing a new one.
In these cases, we can use the useTransition hook to mark state updates as non-urgent, including in asynchronous flows, so that React can prioritize more important user interactions and keep the interface more responsive.
Usage
const [isPending, startTransition] = useTransition();
Documentation: https://react.dev/reference/react/useTransition
Example
As an example, let's use a component to simulate a shopping cart calculation.
To keep the code looking cleaner, I've removed classes and accessibility attributes. The fetchTotalPrice function is just a promise that resolves 2 seconds later. You can see the full code here.
Without UseTransition
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>Quantity:</p>
<div>
<button onClick={onDecreaseQuantity}>-</button>
<span>{quantity}</span>
<button onClick={onIncreaseQuantity}>+</button>
</div>
<p>Total Price:</p>
<p>{isPending ? 'Calculating...' : totalPrice}</p>
</>
);
}
When changing the quantity several times in a row, notice that the total value is updated after the loading period, causing an inconsistency in the interface, as well as unnecessary excessive rendering.
We could use the isPending flag to block the buttons, but that doesn't offer very good usability for the user.
Without useTransition & Blocking Action
Using the useTransition hook
Now let's see the same example using the useTransition hook.
Using useTransition
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 = (value) => {
startTransition(async () => {
const newPrice = await fetchTotalPrice(value);
startTransition(() => {
setTotalPrice(newPrice);
});
});
};
const onIncreaseQuantity = () => {
setQuantity((prev) => prev + 1);
onChangeQuantityHandler(quantity + 1);
};
const onDecreaseQuantity = () => {
setQuantity((prev) => prev - 1);
onChangeQuantityHandler(quantity - 1);
};
return (
<>
<p>Quantity:</p>
<div>
<button onClick={onDecreaseQuantity}>-</button>
<span>{quantity}</span>
<button onClick={onIncreaseQuantity}>+</button>
</div>
<p>Total:</p>
<p>{isPending ? 'Calculating...' : totalPrice}</p>
</>
);
}
Now the interface feels more responsive because React prioritizes updating the quantity while the calculation and display of the total price are treated as non-urgent updates.
Furthermore, the buttons remain available to the user and we don't need an extra setState (setIsPending) to control when an action is occurring.
Asynchronous Transitions
You may also have noticed that inside the onChangeQuantityHandler function there are two startTransition's calls. One with the asynchronous request to fetchTotalPrice and another for the setTotalPrice setState.
const onChangeQuantityHandler = (quantity) => {
startTransition(async () => {
const newPrice = await fetchTotalPrice(quantity);
startTransition(() => {
setTotalPrice(newPrice);
});
});
};
This is necessary because React currently loses the transition context after an await. This way we ensure that the transition is still happening until setTotalPrice is called.
For synchronous calls, only one startTransition is necessary:
startTransition(() => {
setUser(null);
setSession(null);
});
Handling Errors
As with any asynchronous request, we can handle an error within a startTransition using a try/catch to treat the error and show a message to the user.
Using useTransition & Error Handler
export default function StartTransitionUsage() {
const [hasError, setHasError] = useState(false);
//...
const onChangeQuantityHandler = (value) => {
setHasError(false);
startTransition(async () => {
try {
const newPrice = await fetchTotalPrice(value);
startTransition(() => {
setTotalPrice(newPrice);
});
} catch {
setHasError(true);
}
});
};
}
In this case, the error state was intentionally kept outside the transition, as error feedback should normally be an urgent update for the user.
Conclusion
The useTransition hook was created to solve the small problem of excessive and unnecessary rendering which, in an application, ends up harming page performance and user experience. With it, it is possible to make the interface more responsive without the need to add extra complexity to the code.
The next time you notice a performance bottleneck on the page or are creating a state to indicate that an action is occurring, consider using the useTransition hook; this should significantly improve the user experience.
