Sequential Interval React Hook
Tags: react, animation, promise, reacthooks
Published: 2021-04-29 05:05:33


Create a React hook that can simulate a pulse-like cycle between animation states through different durations per animation state.

## tl;dr

Here's the hook in an example.
{% codesandbox sequential-interval-react-hook-19qys %}

## Background

We have an animation heartbeat player that switches between four animation states, each with its own user-configurable duration.

We have a ping service that sends out a ping to our component on an interval, or heartbeat, and each ping kicks off an animation cycle. This cycle goes through the sequence: `rest` -> `tick` -> `exit` -> `enter` -> `rest`.

The original implementation was built into a class component using a local [MobX](https://mobx.js.org/) observable and nested `setTimeout`s and has worked very well for the past few years.

This is a rough idea of how the nested `setTimeout`s are set up inside of the ping.

```tsx
this.animationState = "tick";
setTimeout(
  action(() => {
    this.animationState = "exit";
    setTimeout(
      action(() => {
        this.animationState = "enter";
        setTimeout(
          action(() => {
            this.animationState = "rest";
          })
        );
      }),
      widget.tickLength
    );
  }),
  widget.tickDelay
);
```

We're at a point where we need to update the renderer housing this logic and I thought I would attempt to do it with a functional component.

## Criteria

The four animation states we need to switch between are `tick`, `exit`, `enter`, and `rest`; while each of our widgets has CSS animations that are tied to a `className` of `status_[animationState]`.

Each of these animation states needs its own duration that is user-configurable.

## Attempts

First, I tried to implement something similar to what we see above in a `useEffect` and `setState`. The downside here is that the `useEffect` is new every render so I wasn't able to track timeouts effectively.

The second thing I tried, was to leverage the [`useInterval` hook](https://overreacted.io/making-setinterval-declarative-with-react-hooks/) that Dan Abramov created. The downside here is that the callback is a `ref` so it never changes, which means I can't pass it a different callback for each step/duration.

Finally, I settled on a mix of `ref`s for persistence and a custom hook to handle the `setTimeout`s.

## Research

I thought I would be able to jam the `setTimeout`s in an array and use a `for of` loop to run them, one by one. This ended up running them "out of order."

I ended up coming across two solutions that helped me piece it together, [How to resolve a useReducer's dispatch function inside a promise in ReactJS](https://stackoverflow.com/a/60360051) and [Why Using reduce() to Sequentially Resolve Promises Works](https://css-tricks.com/why-using-reduce-to-sequentially-resolve-promises-works/).

The idea here is that each is wrapped in a `Promise` and added to an array where we can loop over them in a `reduce`, awaiting the previous `Promise` before starting the next.

This worked like a charm!

## Creating the `useStepInterval` Hook

The custom hook is where the magic lies. We start with two `useState`s, one for the animation state and another to determine if the animation cycle is running. We'll return the `status` and the ability to set `isRunning` so our component can turn it on/off.

```ts
import { useState, useEffect, SetStateAction, Dispatch } from "react";

// steps through the heartbeat animation states
export function useStepInterval<StatusType>(
  initialStatus: StatusType,
  steps: { status: StatusType; delay: number }[]
): { status: StatusType; setIsRunning: Dispatch<SetStateAction<boolean>> } {
  const [status, setStatus] = useState<StatusType>(initialStatus);
  const [isRunning, setIsRunning] = useState(false);

  return { status, setIsRunning };
}
```

Next, we set up a `useEffect` that will watch `isRunning` to see if the cycle should start. In this hook, we'll have two functions, one that sets up the `Promise`s and another that will run the `reduce` over the `Promise`s.

```ts
import { useState, useEffect, SetStateAction, Dispatch } from "react";

// steps through the heartbeat animation states
export function useStepInterval<StatusType>(
  initialStatus: StatusType,
  steps: { status: StatusType; delay: number }[]
): { status: StatusType; setIsRunning: Dispatch<SetStateAction<boolean>> } {
  const [status, setStatus] = useState<StatusType>(initialStatus);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    function setUpPromise(step, index): Promise<void> {
      // we're returning a promise that will clean up after itself
      return new Promise((resolve, reject) => {
        const wait = setTimeout(() => {
          // clear previous setTimeout
          clearTimeout(wait);

          // set the status of the step
          setStatus(step.status);

          // if it's the last item, stop the cycle
          if (index === steps.length - 1) {
            setIsRunning(false);
          }

          resolve();

          // use the duration of the previous to this step
        }, step.delay);
      });
    }

    // using a reduce allows us to wait for the previous promise to resolve
    // before starting the next more info at
    // https://css-tricks.com/why-using-reduce-to-sequentially-resolve-promises-works/
    function stepThrough() {
      return steps.reduce(async (previousPromise, nextStep, index) => {
        await previousPromise;
        return setUpPromise(nextStep, index);
      }, Promise.resolve());
    }

    if (isRunning) {
      stepThrough();
    }
  }, [isRunning]);

  return { status, setIsRunning };
}
```

## Using the `useStepInterval` Hook

In our component we can now run our hook and have a `ref` that catches the ping from our player service, which sets `isRunning` to `true`, starting the animation cycle.

```tsx
type AnimationState = "tick" | "exit" | "enter" | "rest";

// these steps can be inside the app if the values are dynamic
const ANIMATION_STEPS: { status: AnimationState; delay: number }[] = [
  { status: "tick", delay: 0 },
  { status: "exit", delay: 300 },
  { status: "enter", delay: 1500 },
  { status: "rest", delay: 300 }
];

export function MyComponent() {

  const { status, setIsRunning } = useStepInterval<AnimationState>(
    "rest",
    ANIMATION_STEPS
  );

  // this is the callback that receives the type of player status
  // that's coming in and fires a our running state
  const playerCallback = useRef((playerStatus) => {
    switch (playerStatus) {
      case "ping":
        setIsRunning(true);
        break;

      case "idle":
      default:
        break;
    }
  });
```

Now we have an animation cycler that can be started from our component, and the best part is, we can have our component be functional 😀.

I definitely learned more about `refs` and how to work with promises during this feature. Hopefully, there will some more refactors to a functional component that can challenge other areas.