Understanding React Transitions
27. May, 2023 • 6 min read • Teach
Not every update is urgent
Type into a search box that filters a long list and you can usually feel the problem. The characters lag behind your fingers, because React is busy re-rendering a few thousand rows for a query you have already moved on from. startTransition is how you tell React which of those two jobs matters more.
I mentioned transitions in passing when I wrote about React 18, but a paragraph does not do the idea justice, and the API has one sharp edge that is easy to walk into.
What a transition is
Before React 18, every state update was equally important. React took them, rendered them, committed them, and the rendering could not be interrupted once it started. If a render took 300ms, your keystroke waited 300ms.
The concurrent renderer changed that by letting React work on an update, pause, handle something more important, and come back. startTransition is the label you attach to an update to say “this one can wait”:
import { startTransition } from 'react';
// urgent: the input must reflect the keystroke immediately
setQuery(input);
// non-urgent: the filtered results can catch up
startTransition(() => {
setSearchQuery(input);
});Two state updates, two priorities. React commits setQuery straight away so the input stays responsive. The transition update renders in the background, and if another keystroke arrives before it finishes, React throws that work away and starts again with the newer value. Nothing half-rendered ever reaches the screen.
That interruptibility is the whole feature. It is not batching, and it is worth being clear about that because the two get confused. Automatic batching, also new in React 18, groups multiple setState calls into one render. Transitions decide which renders get to go first. You get automatic batching whether or not you use transitions.
The callback has to be synchronous
Here is the sharp edge. React marks every state update that happens while the callback runs as a transition. Updates scheduled after an await or inside a .then are not part of it, because by the time they fire, the callback has long since returned.
So this does not do what it looks like it does:
// this does NOT mark setData as a transition
startTransition(() => {
fetchData().then(result => {
setData(result);
});
});fetchData() is called synchronously, the promise is handed back, and startTransition returns. Whenever the response eventually arrives, React has no idea it was supposed to be related to a transition. setData is treated as a perfectly ordinary urgent update.
The fix is to wrap the update itself, on the other side of the await:
fetchData().then(result => {
startTransition(() => {
setData(result);
});
});The React team calls this a known limitation rather than a design decision, so I expect it to change eventually. Until it does, the rule is simple enough: if you can see an await, a .then or a setTimeout inside your startTransition callback, the updates after it are not transitions.
A subscription callback is fine, because the update inside it is synchronous:
import { startTransition, useEffect, useState } from 'react';
const ChatApplication = () => {
const [messages, setMessages] = useState([]);
useEffect(() => {
const subscription = subscribeToNewMessages(message => {
startTransition(() => {
setMessages(previous => [...previous, message]);
});
});
return () => subscription.unsubscribe();
}, []);
return (
<div>
{messages.map(message => (
<Message key={message.id} content={message.content} />
))}
</div>
);
};In a busy channel this keeps the message list from monopolising the main thread. If someone is typing a reply while forty messages land, the input wins.
useTransition when you want a spinner
startTransition on its own gives you no way to know whether the work is still going. The hook version does:
import { useTransition, useState } from 'react';
const Tabs = () => {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
const selectTab = next => {
startTransition(() => {
setTab(next);
});
};
return (
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<TabButtons onSelect={selectTab} />
<TabPanel tab={tab} />
</div>
);
};The tab button responds instantly, the old panel stays on screen while the new one renders, and isPending lets you dim it or show an indicator instead of a blank gap. This is the pattern I use most. Dimming the outgoing content reads far better than swapping it for a skeleton, and it is a one-line change once the transition is in place.
There is also useDeferredValue, which comes at the same problem from the other end. Rather than marking the update, you take a value and get back a copy that lags behind:
const deferredQuery = useDeferredValue(query);Use it when you do not own the setState call, typically because the value arrives as a prop from somewhere above you. Same underlying machinery, different handle on it.
Caveats worth knowing
- You can only wrap updates whose setter you have direct access to. If the value comes in as a prop,
useDeferredValueis the tool, notstartTransition. - Transitions cannot control a controlled text input. The value has to update urgently or typing breaks, which is exactly why the search example splits into two state updates rather than one.
- A transition will be interrupted by any urgent update, and the work done so far is discarded. That is the point, but it means a transition can render several times before it commits once. Keep the render function pure and free of side effects, which you should be doing anyway.
- If several transitions are in flight, React currently batches them together rather than tracking them separately.
- Errors thrown while rendering a transition are caught by the nearest error boundary, the same as any other render error. No special handling needed.
Where it does not help
Transitions reorder work. They do not make work smaller. If your list takes 300ms to render, wrapping the update in a transition keeps the input responsive, but the results still take 300ms to appear, and now they appear at an unpredictable moment.
So before reaching for this, check whether the render is expensive for a reason you can fix: virtualise the long list, memoise the row component, stop recomputing a derived array on every pass. Those give you an outright faster app. A transition redistributes the cost so the user notices it less, which is genuinely valuable, but it is a second step and not a substitute for the first.
I have found the honest test to be whether you can name the urgent update. If there is a keystroke, a click or a tab switch that must respond immediately, a transition has something to protect. If there is no urgent update in the picture, wrapping things in startTransition is just moving deck chairs.
‘Till next time!