DEV Community

Derp
Derp

Posted on

Promisifying CSS animation with timeout fallback

So I came across this interesting problem dealing with state updates and animations. I wanted to create a utility function that would execute a state update after an animation had finished but with a timeout fallback in case a developer forgot to animate the element.

This is useful for React applications where you want to wait for a slide-out animation for your popup to complete before updating your state, causing your popup to be removed from the DOM.

Let's start with the first two problems
1) We need an event listener to listen for the CSS tranistionend event that is fired from the animated element. When it is triggered, call some function fn.
2) We need a timeout function as a fallback. When the timeout triggers, call some function fn

// 1
const addTransitionEndListener = (element: Element) => (fn: () => void) => {
  element.addEventListener("transitionend", fn);
};

// 2
const addTimeOut = (duration: number) => (fn: () => void) => {
  setTimeout(fn, duration);
};
Enter fullscreen mode Exit fullscreen mode

Next, we want to promisify these two functions inside Promise.race and apply our state update when either one of these two promises is settled. We will also add some convenience types.

// 1
type Effect = () => void;
type CallbackFn = (f: Effect) => void;

// 2
const promisify = (fn: CallbackFn) =>
  new Promise((resolve, reject) => {
    fn(() => {
      resolve(null);
    });
  });

// 3
const addTransitionEndListenerP = promisify(
  addTransitionEndListener(someElement)
);
const addTimeOutP = promisify(addTimeOut(someDuration));

// 4
Promise.race([addTransitionEndListenerP, addTimeOutP]).finally(() =>
  console.log("state update goes here")
);
Enter fullscreen mode Exit fullscreen mode

There's a lot to digest here so let's go through it section by section.

  1. Effect represents a function with no return value. CallbackFn expects an Effect function as an argument and does not return anything. Both addTransitionEndListener and addTimeOut are of type CallbackFn after the first argument.
  2. The promisify function expects a CallbackFn and returns a Promise. We are passing in resolve(null) as the Effect fn as we do not care about returning anything from our Promise for now.
  3. Using promisify, we turn addTransitionEndListener and addTimeOut into something that returns a Promise.
  4. We use Promise.race and finally to perform our mock state update regardless of whether the transitionend event first first or someDuration passes first. someDuration and someElement are defined outside of here.

After wiring all this up, we now have another problem. How do we remove the event listener or clear the timeout once either event happens? Keep in mind that removeEventListenerrequires a reference to the same function that was passed in to addEventListener and clearTimeoutrequires the interval id which is only returned when setTimeout is called.

To solve this, let's change the type of CallbackFn to return a CleanupFn which is a type alias for Effect.

type CleanupFn = Effect;
type CallbackFn = (f: Effect) => CleanupFn;
Enter fullscreen mode Exit fullscreen mode

Next we will need to change addTransitionEndListener and addTimeOut to return a CleanupFn.

const addTransitionEndListener = (element: Element) => (
  fn: () => void
): CleanupFn => {
  element.addEventListener("transitionend", fn);
  return () => {
    element.removeEventListener("transitionend", fn);
  };
};

const addTimeOut = (duration: number) => (fn: () => void): CleanupFn => {
  const timeoutID = setTimeout(fn, duration);
  return () => clearTimeout(timeoutID);
};
Enter fullscreen mode Exit fullscreen mode

We will also need to change promisify to return a pair of a Promise and a Cleanup function. We will then need to provide another function that wraps Promise.race so that it calls all the cleanup functions in the finally block.

type PromiseCleanup = [Promise<unknown>, CleanupFn];

const promisify = (fn: CallbackFn) => {
  let cleanupFn: Effect | undefined;
  return [new Promise((resolve, reject) => {
    cleanupFn = fn(() => {
      resolve(null);
    });
  }), cleanupFn as Effect]
};

export const promisifyRace = (
  promiseCleanups: PromiseCleanup[]
): Promise<unknown> => {
  const promises = promiseCleanups.map(([promise, _]) => promise);
  const cleanups = promiseCleanups.map(([, cleanup]) => cleanup);

  return Promise.race(promises).finally(() => {
    cleanups.forEach((cleanup) => cleanup());
  });
};
Enter fullscreen mode Exit fullscreen mode

Now we are free to use it like so:

 promisifyRace([transitionP(), durationP()])
    .finally(stateUpdate)
Enter fullscreen mode Exit fullscreen mode

Github link: https://github.com/wibily/promisifyCleanup

Codesandbox link:
https://codesandbox.io/s/animationend-fiddle-o2scuh?file=/src/index.ts

Note: codesandbox link builds on top of the idea above by passing in both the resolve, reject Promise executor to the promisify function allowing the caller to choose whether they want to resolve or reject the Promise.

TLDR;

  1. Convert your asynchronous side-effect functions to return a pair of a Promise and a cleanup function
  2. As we don't have Promise.cancel, create your own Promise.race/any/all implementation that runs all the cleanup functions in the finally block

Aside:
TIL that there is a difference between animationend and transitionend. Thank you Jessica

Top comments (0)