DEV Community

🈚️うひょ🤪
🈚️うひょ🤪

Posted on

Implementing CSS Animations with New Experimental useTransition Hook

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.

Illustration

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));
      });

Illustration

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)