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.
-
Effectrepresents a function with no return value.CallbackFnexpects anEffectfunction as an argument and does not return anything. BothaddTransitionEndListenerandaddTimeOutare of typeCallbackFnafter the first argument. - The
promisifyfunction expects aCallbackFnand returns a Promise. We are passing inresolve(null)as theEffectfn as we do not care about returning anything from our Promise for now. - Using
promisify, we turnaddTransitionEndListenerandaddTimeOutinto something that returns a Promise. - We use
Promise.raceandfinallyto perform our mock state update regardless of whether thetransitionendevent first first orsomeDurationpasses first.someDurationandsomeElementare 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;
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)