Demystifying React Transitions: Why Async Updates Cause Unexpected Renders with useOptimistic
Understanding the Unexpected Render Cycles in React Transitions
React's startTransition and useOptimistic hooks are powerful tools designed to keep user interfaces snappy during heavy computations or network requests. However, when combined with async/await syntax, developer expectations can sometimes clash with React's internal state scheduling.
If you have ever encountered an unexpected extra render where your optimistic state doubles unexpectedly, you are not alone. Let's break down why this happens and how React manages async transition contexts under the hood.
The Scenario: An Async Counter
Consider the following implementation of an optimistic counter component:
export default function App() {
const [count, setCount] = useState(0);
const [optimisticCount, setOptimisticCount] = useOptimistic(count);
const handleClick = async () => {
startTransition(async () => {
setOptimisticCount((prev) => prev + 1);
await new Promise((resolve) => setTimeout(resolve, 1000));
setCount((prev) => prev + 1); // Note: Not wrapped in startTransition
});
};
console.log(
"Rendered with count",
count,
"and optimistic count =",
optimisticCount
);
return (
<div>
<button onClick={handleClick}>Increment</button>
<p>Count is {count}</p>
<p>Optimistic Count is {optimisticCount}</p>
</div>
);
}Most developers expect three render logs when clicking the button:
- Initial state:
count: 0, optimisticCount: 0 - Optimistic render:
count: 0, optimisticCount: 1 - Final state:
count: 1, optimisticCount: 1
However, the actual console logs reveal a strange middle step:
Rendered with count 0 and optimistic count = 0
Rendered with count 0 and optimistic count = 1
Rendered with count 1 and optimistic count = 2 <-- Unexpected!
Rendered with count 1 and optimistic count = 1Why Does Optimistic Count Jump to 2?
To understand why optimisticCount briefly reaches 2, we need to inspect how React handles two key concepts: Optimistic State Calculation and Async Contexts.
1. How useOptimistic Works
The useOptimistic hook takes a base state (in this case, count) and applies temporary optimistic updates on top of it. While the underlying Transition is pending, React calculates the optimistic value as:
Optimistic State = Current Base State + Pending Optimistic Updates
2. The Lifecycle of an Async Transition
When you invoke startTransition(async () => { ... }), React performs the following sequence:
- Start of Transition: React marks the transition as active/pending and immediately runs
setOptimisticCount((prev) => prev + 1). - Render 1: React renders with base
count = 0andoptimisticCount = 1(0 + 1). The transition is still marked as pending because the async function hasn't resolved. - The
awaitBoundary: Code execution pauses for 1000ms. In standard JavaScript, context across anawaitboundary is lost. Unless wrapped explicitly, state setters invoked after anawaitrun as urgent, standard state updates outside the transition context. - The Execution of
setCount: Execution resumes and callssetCount((prev) => prev + 1). Because this update is urgent, React updates the base state immediately. Basecountbecomes1. - Render 2 (The Ghost Render): React triggers a re-render because base state changed. However, the overall async transition function has not finished returning yet. Because the transition is still active, React takes the new base state (
1) and applies the pending optimistic update (+1) on top of it! This results inoptimisticCount = 1 + 1 = 2. - Transition Completion: The async transition function finishes completely. React marks the transition as completed and removes the temporary optimistic update layer.
- Render 3: React re-renders with base
count = 1and no active optimistic layer, givingoptimisticCount = 1.
The Solution: Wrapping Async State Updates Properly
React recommends wrapping any state setters called after an await inside another startTransition call (or using modern React 19 Action abstractions like useActionState).
const handleClick = async () => {
startTransition(async () => {
setOptimisticCount((prev) => prev + 1);
await new Promise((resolve) => setTimeout(resolve, 1000));
// Wrap state updates after await boundaries
startTransition(() => {
setCount((prev) => prev + 1);
});
});
};By wrapping setCount in startTransition, React ties the base state update to a non-urgent transition context, avoiding the intermediate urgent render where optimistic updates stack unexpectedly on updated base states.
Summary
useOptimisticstate is calculated dynamically asBase State + Optimistic Deltswhile a Transition is pending.- Async boundaries (
await) detach subsequent operations from the original synchronous call batch. - Calling state setters directly after an
awaitcauses an urgent base state update while the transition remains pending, leading to temporary duplicate calculations. - Always wrap post-
awaitstate updates instartTransitionor leverage React 19 Action forms to keep transitions synchronized.