This is a summarized version of a Japanese article by the same author:
ワイ「アニメーションするにはこのuseTransitionってのを使えばええんか?」
Introduction
In October 2019, the React team introduced an experimental preview of React's new Concurrent Mode. Along with other innovative (but a little bit opinionated) features in it, the new useTransition hook has power to completely change how we develop React applications.
Here is a brief introduction of the useTransition
hook (see the official docs for more details): the useTransition
hook allows us to maintain two states at the same time, primarily designed for page transitions.
Consider a situation where you update some state in order to transition to a new page. In Concurrent Mode, the rendering of the new state may “suspend” (typically due to data fetching). Without useTransition
, we have to render fallback contents (often a loading icon) during the suspension. What is important here is that the suspension can only be detected after state updates, since suspension occurs as a result of rendering based on the new state.
By utilizing useTransition
, we can tell React to keep reflecting the old state to the DOM while suspension. As soon as the rendering of the new state completes, React switches the DOM to the new result. Furthermore, useTransition
provides a flag of whether it is pending (waiting for the rendering of the new state) to the world of the old state. Here React is maintaining two worlds at the same time: one for the old pending state and one for the new state.
You can find nice examples of useTransition
at the official documents.
Using useTransition for Animations
As is obvious from its name, the useTransition
hook is fairly useful for implementing CSS animations based on the transition
CSS property. This article shows how to use useTransiton
for this purpose and gives a brief explanation.
The well-known problem regarding CSS animations in React is how to handle mounting and unmounting of components. If we want to utilize CSS transitions, we cannot mount a new DOM element and start its animation in one action; we have to mount an element in the before-animation state first, and then immediately alter its style to after-animation one to trigger the animation.
Previously, people used libraries like react-transition-group or react-spring to handle this situation. These libraries automates the above two-step state changes.
In this article, an alternative approach which utilizes useTransition
is introduced. Below is an example though it is still a rough PoC:
In the example, you can click the toggle
button to show and hide a blue box. The box animates its opacity
on every state change and is actually mounted/unmounted every time. As usual, unmounting is delayed until the animation completes. In what follows, the trick used in this example is explained.
Preparation
We start from looking at some utilities defined in the example. The first is the Timer
class:
// src/Timer.js
export class Timer {
constructor(duration) {
const timer = new Promise(resolve => setTimeout(resolve, duration));
this.done = false;
this.promise = timer.then(() => {
this.done = true;
});
}
throwIfNotDone() {
if (!this.done) {
throw this.promise;
}
}
}
new Timer(duration)
creates a Promise which is fulfilled after duration
milliseconds. The throwIfNotDone
method, when called, throws that Promise if it is not fulfilled yet. We don't step into details, but throwing Promises is a significant characteristic of React's Concurrent Mode. In short, throwing a Promise means that the current rendering should be suspended until that Promise is fulfilled.
So we need a component that actually throws this Promise. It's called Waiter
in the example. It can't be simpler; it receives a Timer from props and calls its throwIfNotDone
method. It does not produce actual contents.
function Waiter({ timer }) {
if (timer) timer.throwIfNotDone();
return null;
}
React has a rule that, if a component may throw a Promise it must be enclosed in React's Suspense
component. That's why Waiter
is used as follows in the example.
<Suspense fallback={null}>
<Waiter timer={timer} />
</Suspense>
Thanks to Timer
and Waiter
, we now have ability to cause suspensions for a certain period of time while rendering. In the example we prepare the timer
state which is passed to Waiter
. If you create a Timer
and set the timer
state to it, the next rendering would be suspended for the specified time.
Two-Step Rendering Using useTransition
Now, let's see how the animation is implemented in the example. First of all, the blue box is rendered by the following code:
{show ? <Box show={show && !isPending} /> : null}
where show
is a boolean state and isPending
is a flag provided by useTransition
indicating whether some rendering is suspended now. Normally isPending
is false
and it becomes true
only while suspension.
The Box
component renders a blue box; if show={false}
its opacity
is 0
and if show={true}
its opacity
is 1
. It is worth noting that the Box
component is actually unmounted while show
is false
.
Finally we see what happens when we click the toggle
button:
const toggle = () => {
if (show) {
startTransition(() => {
setShow(false);
setTimer(new Timer(500));
});
} else {
setShow(true);
startTransition(() => {
setTimer(new Timer(10));
});
}
};
If show
is false
, we call setShow(true)
, which will update state
to true
. The point is the startTransition
call following it; it takes a callback function which is immediately called. The useTransition
hook works for the states updated inside the callback; if these state updates caused a suspension, then React renders the old state while setting true
to isPending
during the suspension.
Here is an illustration of what happens here. In the initial state (the left box in the above image) both show
and isPending
are false. When toggle()
is called, show
is set to true
as usual. Also, timer
is set to new Timer(10)
inside startTransition
. As explained above, this will trigger a suspension which leads to the middle state (where show
is true
and isPending
is true
) being rendered to the DOM. After 10ms the suspension finishes and the last state (show
is true
and isPending
is false
) is rendered.
Here we achieved the two-step rendering with one set of state updates by cleverly utilizing suspension and isPending
provided by useTransition
.
Next we see what happens when trigger()
is called while show
is true
.
startTransition(() => {
setShow(false);
setTimer(new Timer(500));
});
In the initial state show
is true
and isPending
is false
. Basically we are doing the same: set show
to false
and set a Timer
. This time the duration of timer is 500 ms; this is the duration of the animation of opacity
.
The point is that, this time the update for show
is also put inside startTransition
. Thanks to this, the value of show
keeps the old one while the suspension caused by timer
. This is why in the middle state show
is still true
while isPending
is updated to true
. After 500 ms it transitions to the last state where show
is updated to false
and isPending
is set back to false
.
Conclusion
This article explained how to utilize useTransition
from React's Concurrent Mode to implement CSS animations. Thanks to the isPending
flag provided by it, we can pack a two-step rendering into one set of state updates.
Top comments (0)