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);
};
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")
);
There's a lot to digest here so let's go through it section by section.
-
Effect
represents a function with no return value.CallbackFn
expects anEffect
function as an argument and does not return anything. BothaddTransitionEndListener
andaddTimeOut
are of typeCallbackFn
after the first argument. - The
promisify
function expects aCallbackFn
and returns a Promise. We are passing inresolve(null)
as theEffect
fn as we do not care about returning anything from our Promise for now. - Using
promisify
, we turnaddTransitionEndListener
andaddTimeOut
into something that returns a Promise. - We use
Promise.race
andfinally
to perform our mock state update regardless of whether thetransitionend
event first first orsomeDuration
passes first.someDuration
andsomeElement
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 removeEventListener
requires a reference to the same function that was passed in to addEventListener
and clearTimeout
requires 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;
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);
};
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());
});
};
Now we are free to use it like so:
promisifyRace([transitionP(), durationP()])
.finally(stateUpdate)
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;
- Convert your asynchronous side-effect functions to return a pair of a Promise and a cleanup function
- 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)