
Lately, I have been looking into React's useTransition hook because I keep seeing posts about how great it is for improving the user experience in React applications. I have seen posts recommending this hook for managing state transitions more effectively, especially in scenarios involving slow-rendering components, but also just as a loading state handler.
So, naturally, I looked closely at the documentation and ended up disappointed because the example doesn't look like a user experience improvement at all, it looks like a terrible user experience, to be honest.
Don't get me wrong here. I believe useTransition is an amazing improvement, especially if you are building libraries affecting routing, etc. But to recommend using this everywhere? It looks like misuse is easy unless there are nuances I've missed. If so, please comment!
To understand why, let's look at some examples step by step.
useTransitionA basic example, extracted on React Docs, where the usage of useTransition has been removed to understand why we would need it (based from React Docs):
In this example, we can see how a simple tabbed interface can lead to a poor user experience when switching between tabs that contain slow-rendering content:
Posts (slow) because the slow-rendering content blocks the whole thread.useTransitionIn this example, again extracted from React Docs, we can see how useTransition was used in a tabbed interface.
Because the parent component updates its state inside the
action, that state update is marked as a transition. This means you can click on “Posts” and then immediately click “Contact” and it does not block user interactions.
You can use the
isPendingboolean value returned byuseTransitionto indicate to the user that a Transition is in progress. For example, the tab button can have a special “pending” visual state.
Notice how clicking “Posts” now feels more responsive because the tab button itself updates right away.
I agree that using useTransition allows us to bail out of a render (which is super useful), like in this case, someone could click on Posts tab by accident, but then the app is still responsive and we can navigate somewhere else.
I believe it looks terrible because using isPending doesn't really provide a great experience. It indicates that the tab we just clicked is pending, something is happening, but then, the previous tab is still considered active and we can still see the previous tab content, which doesn't feel like a great user experience to me.
What I would truly prefer here, is to prevent blocking the tabs component so we can update this one as a high priority, then follow up with rendering the content.
Another point: if this is part of the navigation, the user experience is still not great because navigating back to Posts tab will still be slow, every single time. It could have been rendered in the background during browser idle time, or just cached or kept rendered but hidden (think <Activity>, we will cover this below).
I don't see why we should use this everywhere like I keep seeing recommended in blog posts, unless you are building a routing library or something that requires it. But even there, I think we should be careful about how we use it.
When you run a transition, it schedules two renders:
isPending = true) with old state valueisPending = false and new state value
That's why it's super important to memoize expensive tabs in our context!
See facebook/react#24269.
Did you know that you can't wrap input updates in a Transition because typing must update state synchronously?
Transitions are non-blocking, so they're unsuitable for controlled inputs that must reflect user input immediately.
Wrapping all state updates can delay even urgent UI feedback (e.g., button clicks, input updates), therefore, degrading the experience. Only non-critical, expensive updates should use transitions.
If we focus solely on the tabs state, one way to achieve a better user experience is by handling the tab state as a critical update, by ensuring that rendering the tab content yield back to React.
If we pick back the basic example without useTransition, we can rely on a <Delay> component that will delay the render of the tab content until the next event loop—mostly just skipping the first render:
With <Delay>, we yielded back and allowed the tabs to update first before rendering the content, which achieved, in my opinion, a way better user experience.
But, there's a catch again. We lose the ability to bail out of this render! When clicking the Posts tab, the app freezes, and that's also not a great user experience. See, that's a case where we evaluated our options and can finally opt-in for a useTransition, but this time, in better control.
So, let's see an example on how we can combine both <Delay> and useTransition to achieve the best of both worlds. In this example, <Delay> initiates the transition, allowing the tabs to update first before rendering the content.
As a bonus, since <Delay> initiates the transition, we can freely use isPending (in the right context this time) to render a loading state, directly within the content!
As a reminder on priorities and what we are trying to achieve:
So, this is a way better user experience than the example from React Docs, isn't it? 🙂
<Activity>There's also another way of making it better, by using <Activity>, which lets you hide and restore the UI and internal state of its children.
We could use this to either pre-load the content of Posts that, or even use it for a just-in-time render, but we keep the tab mounted but hidden via <Activity> since it's expensive to render.
I won't go into more details because this blog post is already long enough and <Activity> could be its own blog post, so I will refer to the React Docs about <Activity> for now and will follow up later.