Cancelling requestAnimationRequest in a React component using hooks doesn't work
Asked Answered
K

1

4

I am working on a progress bar (Eventually..) and I want to stop the animation (calling cancelAnimationRequest) when reaching a certain value (10, 100, ..., N) and reset it to 0.

However, with my current code, it resets to 0 but keeps running indefinitely. I think I might have something wrong in this part of the code:

setCount((prevCount) => {
    console.log('requestRef.current', requestRef.current, prevCount);
    
    if (prevCount < 10) return prevCount + deltaTime * 0.001;
    
    // Trying to cancel the animation here and reset to 0:
    cancelAnimationFrame(requestRef.current);

    return 0;
});

This is the whole example:

const Counter = () => {
  const [count, setCount] = React.useState(0);

  // Use useRef for mutable variables that we want to persist
  // without triggering a re-render on their change:
  const requestRef = React.useRef();
  const previousTimeRef = React.useRef();

  const animate = (time) => {
    if (previousTimeRef.current != undefined) {
      const deltaTime = time - previousTimeRef.current;

      // Pass on a function to the setter of the state
      // to make sure we always have the latest state:
      setCount((prevCount) => {
        console.log('requestRef.current', requestRef.current, prevCount);
        
        if (prevCount < 10) return prevCount + deltaTime * 0.001;
        
        // Trying to cancel the animation here and reset to 0:
        cancelAnimationFrame(requestRef.current);

        return 0;
      });
    }
    
    previousTimeRef.current = time;
    requestRef.current = requestAnimationFrame(animate);
  }

  React.useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current);
  }, []);

  return <div>{ Math.round(count) }</div>;
}

ReactDOM.render(<Counter />, document.getElementById('app'));
html {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

body {
  font-size: 60px;
  font-weight: 700;
  font-family: 'Roboto Mono', monospace;
  color: #5D9199;
  background-color: #A3E3ED;
}

.as-console-wrapper {
  max-height: 66px !important;
}
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

Code pen: https://codepen.io/fr-nevin/pen/RwrLmPd

Ketch answered 30/6, 2020 at 8:17 Comment(0)
S
6

The main problem with your code is that you are trying to cancel an update that has already been executed. Instead, you can just avoid requesting that last update that you don't need. You can see the problem and a simple solution for that below:

const Counter = () => {
  const [count, setCount] = React.useState(0);
  const requestRef = React.useRef();
  const previousTimeRef = React.useRef(0);

  const animate = React.useCallback((time) => {
    console.log('       RUN:', requestRef.current);

    setCount((prevCount) => {  
      const deltaTime = time - previousTimeRef.current;   
      const nextCount = prevCount + deltaTime * 0.001;
      
      // We add 1 to the limit value to make sure the last valid value is
      // also displayed for one whole "frame":
      if (nextCount >= 11) {
        console.log('    CANCEL:', requestRef.current, '(this won\'t work as inteneded)');
        
        // This won't work:
        // cancelAnimationFrame(requestRef.current);
        
        // Instead, let's use this Ref to avoid calling `requestAnimationFrame` again:
        requestRef.current = null;
      }
      
      return nextCount >= 11 ? 0 : nextCount;
    });
    
    // If we have already reached the limit value, don't call `requestAnimationFrame` again:
    if (requestRef.current !== null) {
      previousTimeRef.current = time;
      requestRef.current = requestAnimationFrame(animate);
      console.log('- SCHEDULE:', requestRef.current);
    }     
  }, []);

  React.useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(requestRef.current);
  }, []);
  
  // This floors the value:
  // See https://mcmap.net/q/21170/-using-bitwise-or-0-to-floor-a-number.
  return (<div>{ count | 0 } / 10</div>);
};

ReactDOM.render(<Counter />, document.getElementById('app'));
html {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

body {
  font-size: 60px;
  font-weight: 700;
  font-family: 'Roboto Mono', monospace;
  color: #5D9199;
  background-color: #A3E3ED;
}

.as-console-wrapper {
  max-height: 66px !important;
}
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

In any case, you are also updating the state many more times than actually needed, which you can avoid by using refs and the timestamp (time) provided by requestAnimationFrame to keep track of the current and next/target counter values. You are still going to call the requestAnimationFrame update function the same number of times, but you will only update the state (setCount(...)) once you know the change is going to be reflected in the UI.

const Counter = ({ max = 10, rate = 0.001, location }) => {
  const limit = max + 1;
  const [count, setCount] = React.useState(0);
  const t0Ref = React.useRef(Date.now());
  const requestRef = React.useRef();
  const targetValueRef = React.useRef(1);

  const animate = React.useCallback(() => {
    // No need to keep track of the previous time, store initial time instead. Note we can't 
    // use the time param provided by requestAnimationFrame to the callback, as that one won't
    // be reset when the `location` changes:
    const time = Date.now() - t0Ref.current;    
    const nextValue = time * rate;
    
    if (nextValue >= limit) {
      console.log('Reset to 0');
      setCount(0);
      return;
    }
    
    const targetValue = targetValueRef.current;      
    
    if (nextValue >= targetValue) {
      console.log(`Update ${ targetValue - 1 } -> ${ nextValue | 0 }`);      
      setCount(targetValue);
      targetValueRef.current = targetValue + 1;
    }
    
    requestRef.current = requestAnimationFrame(animate);
  }, []);
  
  React.useEffect(() => {
    requestRef.current = requestAnimationFrame(animate);
    
    return () => cancelAnimationFrame(requestRef.current);
  }, []);

  React.useEffect(() => {
    // Reset counter if `location` changes, but there's no need to call `cancelAnimationFrame` .
    setCount(0);
    t0Ref.current = Date.now();
    targetValueRef.current = 1;    
  }, [location]);
  
  return (<div className="counter">{ count } / { max }</div>);
};

const App = () => {
  const [fakeLocation, setFakeLocation] = React.useState('/');
  
  const handleButtonClicked = React.useCallback(() => {
    setFakeLocation(`/${ Math.random().toString(36).slice(2) }`);
  }, []);

  return (<div>  
    <span className="location">Fake Location: { fakeLocation }</span>
    <Counter max={ 10 } location={ fakeLocation } />
    <button className="button" onClick={ handleButtonClicked }>Update Parent</button>
  </div>);
};

ReactDOM.render(<App />, document.getElementById('app'));
html {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

body {
  font-family: 'Roboto Mono', monospace;
  color: #5D9199;
  background-color: #A3E3ED;
}

.location {
  font-size: 16px;
}

.counter {
  font-size: 60px;
  font-weight: 700;
}

.button {
  border: 2px solid #5D9199;
  padding: 8px;
  margin: 0;
  font-family: 'Roboto Mono', monospace;
  color: #5D9199;
  background: transparent;
  outline: none;
}

.as-console-wrapper {
  max-height: 66px !important;
}
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>
Sackett answered 1/7, 2020 at 0:4 Comment(4)
Hey thanks! I think I am almost there. @Danziger. Can you add a reset button to the animation? (Somewhere outside the counter component where it accepts a prop to reset.) My application is basically a progress bar(Here counter) ranging from 0-> 100, where it needs to reset to 0 and back to 100 on router changes. Am passing in the router change flag to the progress bar(Here counter). A button reset should be similar I guess. This is the reason why I added the cancelAnimationFrame in the first place.Ketch
If am right, I need to maybe targetValueRef.current = 0 cancelAnimationFrame(requestRef.current), on the return logic.Ketch
codesandbox.io/s/styled-components-react-playground-e3osc If you see here, I got the progress bar working. Although, not sure how to get the router involved in this playground.Ketch
@NevinMadhukarK I have updated the code. You just need to use useEffect to reset the counter whenever some relevant property changes. In this case, I just pass the location (a fake one) down to the Counter component.Sackett

© 2022 - 2024 — McMap. All rights reserved.