Ever been frustrated when a UI update in your React app suddenly freezes or flickers just as you’re trying to keep things smooth? I recently hit that snag while working with Suspense and transitions, and it took me a while to untangle why some updates felt janky or didn’t behave as expected.
At the heart of this puzzle are two closely related but different React APIs: startTransition and useTransition. They sound similar, but mixing them up can lead to unexpected UI behavior. Here’s what I learned digging under the hood.
The moment you want transitions
Imagine you have a search input that triggers a big, slow list update. You want the input to feel instant, typing shouldn’t lag or block the UI, while the list updates in the background with a loading spinner.
React’s Concurrent Mode helps here by letting you mark some state updates as "low priority" or "transitions" so urgent updates like typing or clicks don’t get blocked by heavy renders.
Two tools React gives you:
startTransition: a function you wrap updates in to mark them as transitions.
useTransition: a hook that gives you a boolean isPending and a startTransition function tied to that component.
They work together but serve slightly different roles.
What does startTransition do, really?
Under the hood, React’s scheduler has lanes with priority levels. When you call startTransition(() => setState(...)), React schedules that update on a low priority "transition" lane instead of a high priority one.
That means React can keep the UI responsive by allowing urgent updates (like typing) to interrupt or jump ahead of these transition updates.
Here’s a quick example:
function Search() {
const [query, setQuery] = React.useState('');
const [list, setList] = React.useState([]);
function handleChange(e) {
setQuery(e.target.value); // urgent update
React.startTransition(() => {
// transition update
const filtered = expensiveFilter(e.target.value);
setList(filtered);
});
}
return (
<>
<input value={query} onChange={handleChange} />
<List items={list} />
</>
);
}
Typing updates query immediately (high priority), so the input keeps up with your keystrokes.
The heavy setList update happens inside startTransition, so React can interrupt or delay it to keep the UI fluid.
What about useTransition?
useTransition is a React hook that creates a transition context.
It gives you two things:
isPending: a boolean that’s true while a transition update is in progress.
startTransition: a function that schedules updates on the transition lane.
Why is this useful? Because you can use isPending to show a loading spinner or fallback UI while your transition updates are still rendering.
Here’s the same example rewritten:
function Search() {
const [query, setQuery] = React.useState('');
const [list, setList] = React.useState([]);
const [isPending, startTransition] = React.useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent
startTransition(() => {
const filtered = expensiveFilter(e.target.value);
setList(filtered); // transition
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <List items={list} />}
</>
);
}
isPending here lets you toggle UI that reflects the transition state.
When to use which?
- Use
startTransition when you just want to mark some updates as low priority and don’t care about showing pending status.
- Use
useTransition when you want to track the pending state and show a loading indicator or fallback UI.
They both mark updates as transitions internally, but useTransition bundles nicer UX handling.
Common pitfalls I ran into
1. Forgetting to wrap all related updates
In some places, I wrapped only one setState with startTransition but not others. React treats each update independently, so some UI parts updated urgently while others lagged behind.
Fix: Wrap all state updates related to the transition inside the same startTransition call or use useTransition’s function consistently.
2. Using startTransition inside effects or async code
I tried calling startTransition inside useEffect or after async calls thinking it’d defer updates. But React expects startTransition to wrap the update synchronously at the time you want to schedule it.
Calling it in async callbacks can cause updates to be scheduled outside React’s normal render lifecycle, leading to weird flickers.
Fix: Schedule transitions as close to the event or synchronous logic as possible.
3. Misunderstanding isPending timing
isPending is true during the render phase when React is working on transition updates. It flips back to false once React commits those updates.
If you do heavy work outside React or in effects, isPending won’t reflect that load.
So showing spinners based only on isPending can sometimes miss or delay showing load states if you’re relying on external async logic.
Fix: Combine isPending with your own loading flags if you fetch data or do async work.
Under the hood: React’s scheduler lanes
React’s scheduler assigns updates to lanes with different priority:
- Urgent lanes: For user input, animations.
- Transition lanes: For updates wrapped in
startTransition or from useTransition.
- Idle lanes: For really low priority work.
When you call startTransition, React pushes your update into a transition lane.
This means React can interrupt or delay this work if something urgent comes in.
useTransition hooks into this by tracking when there’s pending work on those lanes and exposing isPending.
This explicit scheduling is why React apps don’t freeze typing or clicks even if big renders are happening.
Debugging tips
If your transitions don’t feel smooth:
- Use React DevTools Profiler with "Show React updates" enabled. Look for whether your transition updates are batched and delayed properly.
- Log when you call
startTransition and what state updates are inside it.
- Check if you accidentally cause extra urgent updates that block transitions.
- Confirm your
isPending usage matches the actual loading state.
- Try wrapping all related state updates in a single
startTransition call to avoid partial updates.
Wrapping up
startTransition and useTransition are powerful but subtle. They let you tell React: "this update can wait, keep the UI responsive."
But they’re not magic switches , how and when you use them really matters.
Next time you want to smooth out big UI updates or data fetching with Suspense, remember:
- Use
startTransition to mark low priority updates.
- Use
useTransition to get isPending and improve UX with spinners or fallback UI.
- Keep related updates together inside transitions.
- Debug with React DevTools Profiler to see what’s really happening.
I hope this clears up the fog around React Suspense transitions and helps you build smoother, more responsive apps.