Preface
After Suspense, useTransition is coming
1. Isn't Suspense Enough?
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
As long as there's a Lazy component that hasn't returned in the Suspense subtree, it goes to the content specified by fallback, equivalent to loading that can be lifted to any ancestor level.
Suspense components can be placed at any position above Lazy components (in the component tree), and can have multiple Lazy components below.
From the loading scenario alone, Suspense provides two capabilities:
-
Supports loading lift
-
Supports loading aggregation
For user experience, there are two benefits:
-
Avoid layout jitter (content popping up after data returns)
-
Treat different network environments differently (loading won't appear if data returns quickly)
The former is the benefit brought by loading (or skeleton), while the latter benefits from intermittent scheduling under Concurrent Mode
P.S. For detailed information about Suspense, see React Suspense—Starting with Adding a Loading to Code Splitting...
The elegant, flexible, and humanized loading provided by Suspense seems to have already achieved ultimate development experience and user experience, however, further exploration reveals several more questions around loading:
-
Does adding a loading always mean better experience?
-
What's wrong with showing loading immediately?
-
How to resolve the conflict between interactive real-time response and loading?
-
For long loading that can't be eliminated, is there a way to make it feel faster to users?
-
Does layout jitter really not exist? What if there are multiple loadings in a list simultaneously?
Next, let's explore these questions one by one
2. Visually Weaken Loading
Does adding a loading always mean better experience?
Taking a typical paginated list as an example, the common interaction process might be like this:
1.First page content appears
2.Click next page
3.First page content disappears, or is covered by a semi-transparent overlay
4.Show loading
5.After a while loading disappears
6.Second page content appears
The biggest problem is that during loading, the first page content is unavailable (invisible, or covered). That is, loading affects the completeness of page content and the application's responsiveness
In that case, might as well remove loading:
1.First page content appears
2.Click next page
3.First page content remains as is
...No interaction feedback, after a few seconds
4.Second page content appears
Due to lack of immediate interaction feedback, user experience is even worse. So, is there a way to have the best of both worlds, ensuring responsiveness during loading while having loading-like interaction experience?
Yes. Weaken the visual effect of loading:
-
Weaken global loading (or content block loading) into local loading: Avoid loading breaking content completeness
-
Use graying out and other methods to hint that what's being displayed is old content: Avoid confusion caused by old content to users
For example, for button click scenarios, can simply add loading feedback on the button:
//...
render() {
const { isLoading } = this.state;
return (
<Page>
<Content style={{ color: isLoading ? "black" : "gray" }} />
<Button>{isLoading ? "Next" : "Loading..."}</Button>
</Page>
);
}
Not only ensures users still see complete content during loading (although some content is somewhat old, but already hinted through graying out), but also gives immediate interaction feedback
Most of the time, showing loading immediately like the above example is fine, however, in other scenarios, quickly appearing loading is not satisfactory
3. Logically Delay Loading
What's wrong with showing loading immediately?
If loading is very fast (only needs 100ms), users may only feel something flashing by... another terrible user experience
Of course, we usually don't add loading for such scenarios, because loading usually brings users a psychological expectation of "slow", and adding loading to an already very fast operation will undoubtedly lower the speed experience in user perception, so we choose not to add
However, if there's an operation that could be extremely fast or extremely slow, should loading be added or not?
At this point, loading on demand is needed, such as delaying loading timing, only show loading if new content isn't ready after 200ms
React has considered this scenario, thus came useTransition
useTransition
Transition feature is provided in Hooks API form:
const [startTransition, isPending] = React.useTransition({
timeoutMs: 3000
});
P.S. Note, Transition feature depends on Concurrent Mode, and currently (2019/11/23) hasn't been officially launched (it's an experimental feature), specific API may still change, for reference only, try it out at Transitions
The role of Transition Hook is to tell React it's okay to delay updating State:
Wrap state update into startTransition to tell React it's okay to delay it.
For example:
function App() {
const [resource, setResource] = useState(initialResource);
const [startTransition, isPending] = React.useTransition({
timeoutMs: 3000
});
return (<>
<button
disabled={isPending}
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>
Next
</button>
{isPending ? " Loading..." : null}
<ProfilePage resource={resource} />
</>);
}
function ProfilePage({ resource }) {
return (<Suspense fallback={<h1>Loading profile...</h1>} >
<ProfileDetails resource={resource} />
<Suspense fallback={<h1>Loading posts...</h1>} >
<ProfileTimeline resource={resource} />
</Suspense>
</Suspense>);
}
-
Click
Nextbutton immediately fetchProfileData, thenisPendingbecomestrue, showLoading... -
If
ProfileDatareturns within 3 seconds, then (switch from currently displaying oldProfilePageto) show newProfilePagecontent -
Otherwise enter
ProfilePage's Suspense fallback, (oldProfilePagedisappears) showLoading profile...
That is to say, startTransition delays the resource state value that should have been immediately passed to ProfilePage (not yet fetched), and delays at most 3 seconds, which is exactly the on-demand loading capability we want: no loading within timeoutMs milliseconds, only show loading after timeout
So, simply put, Transition can delay Suspense, that is, Transition can delay loading
On-Demand Loading
From the page content state perspective, Transition introduces a Pending state where old content is still available:

Each state meaning is as follows:
-
Receded: Current page content disappears, degrades to Suspense fallback
-
Skeleton: New page has appeared, some new content may still be loading
-
Pending: New content is on the way, current page content is complete, still interactive
The starting point for proposing Pending is to avoid going backwards (hiding already existing content):
However, the Receded state is not very pleasant because it "hides" existing information. This is why React lets us opt into a different sequence (Pending → Skeleton → Complete) with useTransition.
Simply adding a loading corresponds to state change Receded → Skeleton → Complete (whether fast or slow, show loading), while with Transition, the optimal experience is Pending → Skeleton → Complete (very fast, no loading needed), slightly worse is Pending → Receded → Skeleton → Complete (very slow, can't avoid loading)
So, for optimal experience, should shorten Pending time to enter Skeleton state as soon as possible, a small trick is wrap slow and unimportant components with Suspense:
Instead of making the transition shorter, we can "disconnect" the slow component from the transition by wrapping it into
Best Practices
At the same time, thanks to Hooks' advantages in fine-grained logic reuse, it's easy to encapsulate Transition's on-demand loading effect into basic components, for example Button:
function Button({ children, onClick }) {
const [startTransition, isPending] = useTransition({
timeoutMs: 10000
});
function handleClick() {
startTransition(() => {
onClick();
});
}
const spinner = (
// ...
);
return (<>
<button onClick={handleClick} disabled={isPending}>
{children}
</button>
{isPending ? spinner : null}
</>);
}
This is also the officially recommended approach, let UI component libraries consider scenarios needing useTransition, to reduce redundant code:
Pretty much any button click or interaction that can lead to a component suspending needs to be wrapped in useTransition to avoid accidentally hiding something the user is interacting with.
This can lead to a lot of repetitive code across components. This is why we generally recommend to bake useTransition into the design system components of your app.
4. Resolve Conflict Between Interactive Real-Time Response and Loading
How to resolve the conflict between interactive real-time response and loading?
The reason Transition can delay loading display is because it delays State updates. So what about State updates that can't be delayed, such as input values:
function App() {
const [query, setQuery] = useState(initialQuery);
function handleChange(e) {
const value = e.target.value;
setQuery(value);
}
return (<>
<input value={query} onChange={handleChange} />
<Suspense fallback={<p>Loading...</p>}>
<Translation input={query} />
</Suspense>
</>);
}
Here input is used as a [controlled component](/articles/从 componentwillreceiveprops 说起/#articleHeader5) (handling user input through onChange), therefore must immediately update new value to State, otherwise input delay or even chaos will occur
Thus, a conflict appears, this requirement for real-time response to input seems incompatible with Transition delaying State updates
The solution provided by officials is to duplicate this state value, since there's a conflict, might as well separate and use each independently:
function App() {
const [query, setQuery] = useState(initialQuery);
const [resource, setResource] = useState(initialResource);
const [startTransition, isPending] = useTransition({
timeoutMs: 5000
});
function handleChange(e) {
const value = e.target.value;
// Outside the transition (urgent)
setQuery(value);
startTransition(() => {
// Inside the transition (may be delayed)
setResource(fetchTranslation(value));
});
}
return (<>
<input value={query} onChange={handleChange} />
<Suspense fallback={<p>Loading...</p>}>
<Translation resource={resource} />
</Suspense>
</>);
}
Although React's practical experience tells us to calculate when possible, share when possible, don't duplicate state values, the benefit is avoiding possible omissions during state updates:
This lets us avoid mistakes where we update one state but forget the other state.
And we indeed just duplicated a state value (query and resource), not to overturn practical principles, but to say we can prioritize State:
-
High Priority State: State whose updates we don't want delayed, such as input values
-
Low Priority State: State that needs delaying, such as Transition related
That is to say, after having Transition, State has priorities
5. Consider Sacrificing UI Consistency
For long loading that can't be eliminated, is there a way to make it feel faster to users?
Yes. If willing to sacrifice UI consistency
You heard right, UI consistency is also not unshakable, when necessary can consider sacrificing UI consistency in exchange for perceptually better experience effects. Although "content doesn't match topic" situations may occur, but may also be friendlier than showing loading for 10 seconds or even longer. Similarly, we can assist with graying out hints and other means to let users realize the fact of UI inconsistency
For this, React provides DeferredValue Hook
useDeferredValue
const deferredResource = React.useDeferredValue(resource, {
timeoutMs: 1000
});
// Usage
<ProfileTimeline
resource={deferredResource}
isStale={deferredResource !== resource} />
P.S. Note, currently (2019/11/23) useDeferredValue hasn't been officially launched, specific API may still change, for reference only, try it out at Deferring a Value
Similar to Transition mechanism, equivalent to delaying state updates, before new data is ready, can continue using old data, if new data arrives within 1 second, (switch from old content to) show new content, otherwise immediately update state, show loading if needed
The difference from Transition is, useDeferredValue is面向 state values, while Transition is面向 state update operations, considered an API and semantic difference, mechanistically the two are very similar
6. Completely Eliminate Layout Jitter
Does layout jitter really not exist? What if there are multiple loadings in a list simultaneously?
In scenarios with multiple loadings coexisting, layout jitter caused by different loading sequences is inevitable. Visually, we usually don't want an existing thing to be pushed aside (visually should all be append, not insert). To completely eliminate layout jitter, there are two approaches:
-
All list items display simultaneously: Wait until all items are ready before displaying, but waiting time increases
-
Control list items to appear in their relative order: Can eliminate insert, waiting time is not always worst case
So, how to control the appearance order of asynchronous content (loading disappearance)?
React has considered this, so provides SuspenseList to control the rendering order of Suspense content, ensuring elements in the list display in relative position order, avoiding content being pushed down:
<SuspenseList> coordinates the "reveal order" of the closest <Suspense> nodes below it
SuspenseList
import { SuspenseList } from 'react';
function ProfilePage({ resource }) {
return (
<SuspenseList revealOrder="forwards">
<ProfileDetails resource={resource} />
<Suspense fallback={<h2>Loading posts...</h2>}>
<ProfileTimeline resource={resource} />
</Suspense>
<Suspense fallback={<h2>Loading fun facts...</h2>}>
<ProfileTrivia resource={resource} />
</Suspense>
</SuspenseList>
);
}
revealOrder="forwards" means child Suspense under SuspenseList must appear in top-to-bottom order, regardless of whose data is ready first, similar values include backwards (reverse order appearance) and together (simultaneous appearance)
Additionally, to avoid user experience confusion caused by multiple loadings appearing simultaneously, also provides tail option, see SuspenseList for details
P.S. Note, currently (2019/11/23) SuspenseList hasn't been officially launched, specific API may still change, for reference only, try it out at SuspenseList
7. Summary
As we can see, on the broad road to pursuing ultimate experience, React is going further and further:
-
Suspense: Supports elegant, flexible, humanized content degradation
-
useTransition: Supports on-demand degradation, only degrade when truly slow
-
useDeferredValue: Supports sacrificing UI consistency in exchange for perceptually better experience effects
-
SuspenseList: Supports controlling the appearance order and coexistence quantity of a group of degradation effects
P.S. The simplest degradation strategy is loading, others like using cached values, or even showing an advertisement, starting a mini-game etc. all count as degradation
No comments yet. Be the first to share your thoughts.