Sequential Interval React Hook

joelmturner

Joel Turner

Posted on April 29, 2021

Sequential Interval React Hook

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.

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 observable and nested setTimeouts and has worked very well for the past few years.

This is a rough idea of how the nested setTimeouts are set up inside of the ping.

this.animationState = "tick";
setTimeout(
  action(() => {
    this.animationState = "exit";
    setTimeout(
      action(() => {
        this.animationState = "enter";
        setTimeout(
          action(() => {
            this.animationState = "rest";
          })
        );
      }),
      widget.tickLength
    );
  }),
  widget.tickDelay
);
Enter fullscreen mode Exit fullscreen mode

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 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 refs for persistence and a custom hook to handle the setTimeouts.

Research

I thought I would be able to jam the setTimeouts 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 and 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 useStates, 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.

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 Promises and another that will run the reduce over the Promises.

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 };
}
Enter fullscreen mode Exit fullscreen mode

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.

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;
    }
  });
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
joelmturner
Joel Turner

Posted on April 29, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Sequential Interval React Hook
react Sequential Interval React Hook

April 29, 2021