Search code examples
javascriptreactjsreact-hooksrequestanimationframe

useAnimationOnDidUpdate react hook implementation


So I want to use requestAnimationFrame to animate something using react hooks.
I want something small so react-spring/animated/react-motion is a bad choice for me.
I implemented useAnimationOnDidUpdate but it is working incorrectly, here is reproduction with details.
What's wrong here: on second click multiplier for animation starts with 1, but should always start with 0 (simple interpolation from 0 to 1).
So I'm trying to understand why the hook saved previous value though I started a new animation loop already.
Here is a full code listing for hook:

import { useState, useEffect, useRef } from 'react';

export function useAnimationOnDidUpdate(
  easingName = 'linear',
  duration = 500,
  delay = 0,
  deps = []
) {
  const elapsed = useAnimationTimerOnDidUpdate(duration, delay, deps);
  const n = Math.min(1, elapsed / duration);

  return easing[easingName](n);
}

// https://github.com/streamich/ts-easing/blob/master/src/index.ts
const easing = {
  linear: n => n,
  elastic: n =>
    n * (33 * n * n * n * n - 106 * n * n * n + 126 * n * n - 67 * n + 15),
  inExpo: n => Math.pow(2, 10 * (n - 1)),
  inOutCubic: (t) => t <.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
};

export function useAnimationTimerOnDidUpdate(duration = 1000, delay = 0, deps = []) {
    const [elapsed, setTime] = useState(0);
    const mountedRef = useRef(false);

    useEffect(
        () => {
            let animationFrame, timerStop, start, timerDelay;

            function onFrame() {
                const newElapsed = Date.now() - start;

                setTime(newElapsed);
                if (newElapsed >= duration) {
                    console.log('>>>> end with time', newElapsed)
                    return;
                }
                loop();
            }

            function loop() {
                animationFrame = requestAnimationFrame(onFrame);
            }

            function onStart() {
                console.log('>>>> start with time', elapsed)
                start = Date.now();
                loop();
            }

            if (mountedRef.current) {
                timerDelay = delay > 0 ? setTimeout(onStart, delay) : onStart();
            } else {
                mountedRef.current = true;
            }

            return () => {
                clearTimeout(timerStop);
                clearTimeout(timerDelay);
                cancelAnimationFrame(animationFrame);
            };
        },
        [duration, delay, ...deps]
    );

    return elapsed;
}


Solution

  • This problem with this hook is that it doesn't clean up the elapsedTime upon completion.

    You can resolve this by adding setTime(0) to you onFrame function when the animation is expected to stop.

    Like this:

    function onFrame() {
      const newElapsed = Date.now() - start;
    
      if (newElapsed >= duration) {
        console.log('>>>> end with time', newElapsed)
        setTime(0)
        return;
      }
    
      setTime(newElapsed);
      loop();
    }
    

    I know it may seem weird that it doesn't reset itself. But bear in mind that your animation is making use of the same hook instance for both easing in and out. Therefore that cleanup is necessary.

    Note: I've also move the setTime(newElapsed) line so that it's after the if statement since this isn't necessary if the if statement is true.

    UPDATE:

    To further improve how this works, you could move the setTime(0) to the return cleanup.

    This would mean that you're onFrame function changes to:

    function onFrame() {
      const newElapsed = Date.now() - start;
    
      if (newElapsed >= duration) {
        console.log('>>>> end with time', newElapsed)
        setTime(0)
        return;
      }
    
      setTime(newElapsed);
      loop();
    }
    

    And then update your return cleanup for useAnimationTimerOnDidUpdate to:

    return () => {
      clearTimeout(timerStop);
      clearTimeout(timerDelay);
      cancelAnimationFrame(animationFrame);
      setTime(0);
    };
    

    I'm assuming that the reason your animation "isn't working properly" was because the component would flash. As far as my testing goes, this update fixes that.