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 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.
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 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 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 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.
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.
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.
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.
Top comments (0)