React useTransition: performance game changer or…?

Nadia Makarevich
11 min readOct 23, 2023

Table of Contents

  • Let’s implement a slow state update
  • Concurrent Rendering and useTransition for slow state updates
  • The dark side of useTransition and re-renders
  • How to use useTransition, then?
  • What about useDeferredValue?
  • Can I use useTransition for debouncing?

Unless you’ve lived under a rock for the last two years, you’ve probably heard the magic words “concurrent rendering” here and there. React was rewritten from scratch to support it, it’s an entirely new architecture that gives us control over transitions through useTransition and useDeferredValue hooks, and it's supposed to be a game changer for the performance of our UI interactions. Even Vercel is improving their performance with transitions.

But is it truly a game changer? No caveats, for real? Can we use these transitions everywhere? What are they to begin with, and why do we need them? Let’s investigate together and find out.

Let’s implement a slow state update

First, let’s implement something real with a performance problem. In the docs, they use the “tabs” component as an example where useTransition is useful, so let's implement exactly that. No copy-pasting, let's do it from scratch!

We’ll have an App component that renders three tabs (Issues, Projects, and Reports) and conditionally renders the content of those tabs - the list of the latest issues, projects, and reports for our mini-competitor to Jira and Linear. The App will hold the state that switches between the tabs and renders the correct component. Easy-peasy so far:

export default function App() {
const [tab, setTab] = useState('issues');

return (
<div className="container">
<div className="tabs">
<TabButton
onClick={() => setTab('issues')}
name="Issues"
/>
<TabButton
onClick={() => setTab('projects')}
name="Projects"
/>
<TabButton
onClick={() => setTab('reports')}
name="Reports"
/>
</div>
<div className="content">
{tab === 'issues' && <Issues />}
{tab === 'projects' && <Projects />}
{tab === 'reports' && <Reports />}
</div>
</div>
);
}

Now, the problem that transitions should help with: what if one of the pages is very heavy and slow to render? Let’s say the Projects page renders a list of 100 recent projects, and the components in that list are very heavy and take something like 10ms to mount each. 100 items is not unreasonable, in theory. And although 10ms per component is a bit of a stretch, it could still happen, especially on a slow laptop. As a result, it will take 1 second to mount the Projects page.

Play around with the implemented code example here:

See how it takes forever to render the Projects page when you click the “projects” button? Now try to navigate quickly between those tabs. If I try to navigate from Issues to Projects and then immediately to Reports, I can’t do that: the interface is not responsive. This is not exactly the best user experience: even if the Projects page is supposed to be that heavy and I can’t optimize it now, the least I should do for the users is not to block the page from other interactions.

This is happening because a state update, despite popular belief, is not asynchronous. The triggering of it usually is: we do it asynchronously from various callbacks as a response to user interactions. But once the state update is triggered, React will very synchronously work on calculating all the necessary updates that need to be done, re-render all components that need to be rendered, commit those changes to the DOM so that they can appear on the screen, and only then let the browser go and take notice of what was happening while it was busy.

If we click a tab button during that, the state update from the click will be put in a queue of tasks and will be executed after the main task (the slow state update) is done. You can see this behavior in the console output of the slow example: all re-renders that you trigger via clicking the tabs will be logged, even if the screen was frozen at the time. Tasks and the queue of tasks is how JavaScript is processed by the browser. If you’re not exactly sure how it works, I wrote a simple overview of them in this article: Say no to “flickering” UI: useLayoutEffect, painting and browsers story with a few more links at the bottom.

Concurrent Rendering and useTransition for slow state updates

The fact that a typical state update blocks the main task is what Concurrent Rendering aims to combat. With it, we can explicitly mark some state updates and the re-rendering caused by them as “non-critical”. As a result, React will calculate these updates in the “background” instead of blocking the main task. If something “critical” happens (i.e., a normal state update), React will pause its “background” rendering, execute the critical update, and then either return to the previous task or abandon it completely and start a new one.

“Background” is just a useful mental model here, of course: JavaScript is single-threaded. React will just periodically check the main queue while it’s busy with the “background” task. If something new appears in the queue, it will take priority over the “background” work.

But enough words, let’s return to writing code. In theory, our situation with one of the tabs being very slow and blocking user interactions is exactly what concurrent rendering can help with. All we need to do is mark the rendering of the Projects page as “non-critical”.

We can do this with the useTransition hook. It returns a “loading” boolean as the first argument and a function as the second. Inside that function, we’ll call our setTab("projects"), and from that point on, that state update will be calculated in the "background" without blocking the page. Additionally, we can use the isPending boolean to add a loading state while we're waiting for that update to finish. To indicate to the user that something is happening.

Just three simple additions to the previous code, as simple as this:

export default function App() {
const [tab, setTab] = useState('issues');

// add the useTransition hook
const [isPending, startTransition] = useTransition();

return (
<div className="container">
<div className="tabs">
...
<TabButton
// indicate that the content is loading
isLoading={isPending}
onClick={() => {
// call setTab inside a function
// that is passed to startTransition
startTransition(() => {
setTab('projects');
});
}}
name="Projects"
/>
...
</div>
...
</div>
);
}

And look how cool this is:

When I click on the “Projects” tab button, the loading indicator shows up, and if I click on “Reports,” I’m navigated there immediately. No more frozen interface, total magic!

The dark side of useTransition and re-renders

Okay, now that the navigation to the Projects page and back is fixed, let’s take it a bit further. In real life, any of these tabs could potentially be heavy. Especially the Reports page. I imagine it will have a bunch of very heavy charts there. Why not anticipate this in advance, mark transitions between all of these tabs as non-critical, and update the state inside startTransition for all of them?

All I need to do is abstract that transition into a function:

const onTabClick = (tab) => {
startTransition(() => {
setTab(tab);
});
};

And then use this function on all the buttons instead of setting the state directly:

<div className="tabs">
<TabButton
onClick={() => onTabClick('issues')}
name="Issues"
/>
<TabButton
onClick={() => onTabClick('projects')}
name="Projects"
/>
<TabButton
onClick={() => onTabClick('reports')}
name="Reports"
/>
</div>

That’s it, all state updates originating from these buttons will now be marked as “non-critical,” and if both the Reports and Projects pages happen to be heavy, their rendering won’t block the UI.

If we play around with the live example below, we’ll see that…

I just made the page worse

If I navigate to the Projects page and then try to navigate away from it either to Issues or Reports, it doesn’t happen instantaneously anymore! I haven’t changed anything on those pages, they both render just a string at the moment, but both of them behave as if they are heavy. What’s going on?

The problem here is that if I wrap the state update in a transition, React doesn’t just trigger the state update on the “background”. It’s actually a two-step process. First, an immediate “critical” re-render with the old state is triggered, and the boolean isPending that we extract from useTransition hook moves from false to true. The fact that I'm able to use it in the render output should've been a big clue. Only after that critical "traditional" re-render has finished, will React start with the non-critical state update.

In short, useTransition causes two re-renders instead of one. As a result, we see the behavior as in the example above. If I'm on the Projects page and click on the Issues tab, first, the initial re-render is triggered with tab state still being "projects". The very heavy Projects component blocks the main task for 1 second while it re-renders. Only after this is done will the non-critical state update from "projects" to "issues" be scheduled and executed.

How to use useTransition, then?

Memoization to everything

In order to fix the performance degradation from the above, we need to make sure that the additional first re-render is as lightweight as possible. Typically, it would mean that we need to memoize everything that can slow it down:

  • all heavy components should be wrapped in React.memo, with their props memoized with useMemo and useCallback
  • all heavy operations are memoized with useMemo
  • isPending is not passed as a prop or dependency to anything from the above

In our case, just simply wrapping our page components should do it:

const IssuesMemo = React.memo(Issues);
const ProjectsMemo = React.memo(Projects);
const ReportsMemo = React.memo(Reports);

They don’t have any props, so we can just render them:

<div className="content">
{tab === 'issues' && <IssuesMemo />}
{tab === 'projects' && <ProjectsMemo />}
{tab === 'reports' && <ReportsMemo />}
</div>

And voila, the problem is fixed, the heavy Projects page re-render doesn’t block clicking on tabs anymore:

But this just shows that useTransition is definitely not a tool for everyday use: one small mistake in memoization, and you're making your app visibly worse than it was before useTransition. And doing memoization properly is actually quite hard. For example, can you say, off the top of your head, whether IssuesMemo will re-render here if the App re-renders because of the initial transition?

const ListMemo = React.memo(List);
const IssuesMemo = React.memo(Issues);

const App = () => {
// if startTransition is triggered, will IssuesMemo re-render?
const [isPending, startTransition] = useTransition();

return (
...
<IssuesMemo>
<ListMemo />
</IssuesMemo>
)
}

The answer here is — yep, it will. Re-renders all the way! Issues are not memoized properly. If you’re not sure why, here is the video for you: https://youtu.be/G7RNVYaRS3E. It explains what is happening, why, and how to fix it.

Transition from nothing to heavy

Another way to make sure that this additional initial re-render is as lightweight as possible is to use useTransition only when transitioning from "nothing" to "very heavy stuff". A typical example of that might be data fetching and putting that data into the state afterward. I.e. this:

const App = () => {
const [data, setData] = useState();

useEffect(() => {
fetch('/some-url').then((result) => {
// lots of data
setData(result);
})
}, [])

if (!data) return 'loading'

return ... // render that lots of data when available
}

In this case, if there is no data, we just return a loading state, which is unlikely to be heavy. So if we wrap that setData in startTransition, the initial re-render caused by this won't be bad: it will re-render it with the empty state and loading indicator.

What about useDeferredValue?

There is another hook that allows us to tap into the power of concurrent rendering: useDeferredValue. It works similarly to useTransition, allowing us to mark some updates as non-critical and move them to the "background". It's usually recommended for use when you don't have access to the state update function. When the value is coming from props, for example.

In our case, we would use it if we extract our tabs’ content components into their own component and pass the active tab as a prop:

const TabContent = ({ tab }) => {
// mark the "tab" value as non-critical
const tabDeffered = useDeferredValue(tab);

return (
<>
{tabDeffered === 'issues' && <Issues />}
{tabDeffered === 'projects' && <Projects />}
{tabDeffered === 'reports' && <Reports />}
</>
);
};

However, the problem of double rendering is present here as well. Check it out in the implementation below.

So the solution here will be exactly the same as for useTransition. Mark updates as non-critical only if:

  • everything that is affected is memoized;
  • or if we’re transitioning from “nothing” to “heavy”, and never in the opposite direction;

Can I use useTransition for debouncing?

Another use case that sometimes shows up here and there for useTransition is debouncing. When we're typing something fast in an input field, we don't want to send requests to the backend on every keystroke - it might crash our server. Instead, we want to introduce a bit of delay, so that only the full text is sent.

Normally, we’d do it with something like the debounce function from lodash (or equivalent):

function App() {
const [valueDebounced, setValueDebounced] = useState('');

const onChangeDebounced = debounce((e) => {
setValueDebounced(e.target.value);
}, 300);

useEffect(() => {
console.log('Value debounced: ', valueDebounced);
}, [valueDebounced]);

return (
<>
<input type="text" onChange={onChangeDebounced} />
</>
);
}

The onChange callback is debounced here, so setValueDebounced is only triggered 300ms after I stop typing in the input field.

What if instead of the external library, I use useTransition? Seems reasonable enough: setting state inside transitions is interruptible by definition, that's the whole point of it. Will something like this work?

function App() {
const [value, setValue] = useState('');
const [isPending, startTransition] = useTransition();

const onChange = (e) => {
startTransition(() => {
setValue(e.target.value);
});
};

useEffect(() => {
console.log('Value: ', value);
}, [value]);

return (
<>
<input type="text" onChange={onChange} />
</>
);
}

The answer is: nope, it won’t. Or, to be precise, the debouncing effect won’t happen in this example. React is just too fast, it’s able to calculate and commit the “background” value between keystrokes. We’ll see every value change in the console output in this example.

You can compare actual debouncing and the useTransition attempt here:

That is all for today. Hope it’s a bit more clear now what Concurrent Rendering is, what hooks related to it are, and how to use them.

If you are to remember just one single thing from the article, then it’s this: concurrent rendering hooks cause double re-renders. So, never use them for all state updates. Their use case is very specific and requires a very deep understanding of the React lifecycle, re-renders, and memoization.

As for me, I probably am not going to use useTransition or useDeferredValue any time soon. There are too many things to remember to get it right when we're talking about performance. The cost of mistakes is too high: the last thing that I need is to accidentally make performance worse. And for debouncing, it's just too unpredictable. I think I prefer the "old-school" way of doing it.

👉🏼 By the way, did you know that I wrote a book about advanced React patterns? It’s called Advanced React, and it’s a must-read for any React developer 😉.

Originally published at https://www.developerway.com. The website has more articles like this.

Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.

--

--