SECRET OF CSS

How to Change React Animations Behavior When Props Change | by Erik Hermansen


Change animations midway

1*uM7JzsbBBptu4izOcd3zyw
Animation! React! Exciting!

If you want to animate in React, there are many ways to get it done. CSS animation is particularly elegant, but it seems better suited to cases where you don’t need to control the animation’s behavior after it begins.

This article will describe how you can change an animation’s behavior midway in response to changes in component prop values. Also, we’ll do this while avoiding direct references to DOM elements that could interfere with React’s rendering.

The approach given here is easily reused for whatever kind of data-based animation you want to achieve.

For our example, let’s say we want a React component that renders a simple progress bar. It will take props for the time the operation began (startTime) and how long the operation is expected to take (expectedDuration).

const outerStyle = { 
width:'100%',
height:'10rem',
backgroundColor:'blue'
};
const innerStyle = {
height: '100%',
backgroundColor:'black'
};
function ProgressBarComponent({startTime, expectedDuration}) {
const elapsed = Date.now() - startTime;
const completedRatio = elapsed > expectedDuration
? '100%' : `${elapsed / expectedDuration * 100}%`;
return (
<div style={outerStyle}>
<div style={{...innerStyle, width:completedRatio}} />
</div>
);
}
export default ProgressBarComponent;

When rendered, the progress bar looks like this:

1*um8asMqf3pquYGtUY6U5ig
I’m not trying to impress you with aesthetic design.

The task of animating the progress bar remains. We can use JavaScript’s setTimeout() to update state used for rendering. Let me warn you in advance that I’m going to reject the solution below.

import { useState } from 'react';...const FRAME_INTERVAL = 1000 / 30; // 30 FPSfunction ProgressBarComponent({startTime, expectedDuration}) {
const [completedRatio, setCompletedRatio] = useState(null);
const updateCompletion = () => {
const elapsed = Date.now() - startTime;
if (elapsed >= expectedDuration) {
setCompletedRatio('100%');
return;
}
setCompletedRatio(`${elapsed / expectedDuration * 100}%`);
setTimeout(updateCompletion, FRAME_INTERVAL);
}
if (completedRatio === null) updateCompletion();
return (
<div style={outerStyle}>
<div style={{...innerStyle, width:completedRatio}} />
</div>
);
}
...

That code above will animate the progress bar going left to right. But it has a problem. In this example, we want the progress bar to change how much progress it shows based on changing estimates of how long the operation will take.

If the parent component changes the expectedDuration prop passed to ProgressBarComponent, the code that executes inside updateCompletion() for the setTimeout() callback will still get the previous value of expectedDuration, ignoring the change.

You’ll see some confusing, broken render if expectedDuration changes before the animation completes. Two separate chains of setTimeout() callbacks will render the position of the progress bar with their differing values of expectedDuration.

To fix this, we can move the state-updating logic from the setTimeout() callback into the rendering function of the component. And we’ll just use setTimeout() to trigger re-rendering. This approach is shown below.

...function ProgressBarComponent({startTime, expectedDuration}) {
const [frameNo, setFrameNo] = useState(0);
setTimeout(() => setFrameNo(frameNo + 1), FRAME_INTERVAL); const elapsed = Date.now() - startTime;
const completedRatio = elapsed > expectedDuration
? '100%' : `${elapsed / expectedDuration * 100}%`;
return (
<div style={outerStyle}>
<div style={{...innerStyle, width:completedRatio}} />
</div>
);
}
...

The code actually got simpler. A nice thing is that we don’t have to worry about updating any state other than frameNo in the callback. frameNo isn’t used for anything other than triggering a new render.

With the code above, every time the parent component changes a prop value passed to the component, it will trigger a new chain of setTimeout() callbacks. This can lead to excessive calls to the render function which degrade the web app’s performance.

We can add an effect hook to make sure we only have one chain of setTimeout() callbacks.

import { useState, useEffect } from 'react';...function ProgressBarComponent({startTime, expectedDuration}) {
const [frameNo, setFrameNo] = useState(0);
useEffect(() => {
const timer = setTimeout(
() => setFrameNo(frameNo + 1), FRAME_INTERVAL);
return () => clearTimeout(timer);
}, [frameNo]);
const elapsed = Date.now() - startTime;
const completedRatio = elapsed > expectedDuration
? '100%' : `${elapsed / expectedDuration * 100}%`;
return (
<div style={outerStyle}>
<div style={{...innerStyle, width:completedRatio}} />
</div>
);
}
...

Let me explain what useEffect does above. It will execute this line…

setTimeout(() => setFrameNo(frameNo + 1), FRAME_INTERVAL);

…the first time the component is mounted. That will cause a call to setFrameNo() to happen a little bit in the future, which updates the frameNo property. That, in turn, causes the “setTimeout” line to execute again.

Finally, it returns a function that calls clearTimeout() when the component unmounts. This prevents the timeout callback from referencing frameNo and setFrameNo after they no longer exist.

This is an improvement because we’ve limited the number of triggers of the setTimeout line to just the two that we need — component mount and update of the frameNo prop.

So now the code is much less of a CPU hog. But what about times when there is no animation to perform? If the progress bar reaches 100%, we could stop updating the animation.

...function ProgressBarComponent({startTime, expectedDuration}) {
const [frameNo, setFrameNo] = useState(0);
const [isAnimating, setIsAnimating] = useState(true);
useEffect(() => {
if (isAnimating) {
const timer = setTimeout(
() => setFrameNo(frameNo + 1), FRAME_INTERVAL);
return () => clearTimeout(timer);
}
}, [frameNo, isAnimating]);
const elapsed = Date.now() - startTime;
const completedRatio = elapsed > expectedDuration
? '100%' : `${elapsed / expectedDuration * 100}%`;
if (completedRatio === '100%' && isAnimating)
setIsAnimating(false);
return (
<div style={outerStyle}>
<div style={{...innerStyle, width:completedRatio}} />
</div>
);
}
...

This progress bar isn’t gorgeous. And it does unfriendly things like go backwards when the wait ahead of you increases. (Honest, but a little harsh!) The point of using it in the article was just to give a simple and concrete example of animation affected by changing props.

If you don’t have this specific need of prop-controlled animations, I recommend using other more CPU-efficient approaches such as CSS or Canvas animation.

Here’s the full source of the component. I hereby place it in the public domain.

import { useState, useEffect } from 'react';const outerStyle = { 
width:'100%',
height:'10rem',
backgroundColor:'blue'
};
const innerStyle = {
height: '100%',
backgroundColor:'black'
};
const FRAME_INTERVAL = 1000 / 30; // 30 FPSfunction ProgressBarComponent({startTime, expectedDuration}) {
const [frameNo, setFrameNo] = useState(0);
const [isAnimating, setIsAnimating] = useState(true);
useEffect(() => {
if (isAnimating) {
const timer = setTimeout(
() => setFrameNo(frameNo + 1), FRAME_INTERVAL);
return () => clearTimeout(timer);
}
}, [frameNo, isAnimating]);
const elapsed = Date.now() - startTime;
const completedRatio = elapsed > expectedDuration
? '100%' : `${elapsed / expectedDuration * 100}%`;
if (completedRatio === '100%' && isAnimating)
setIsAnimating(false);
return (
<div style={outerStyle}>
<div style={{...innerStyle, width:completedRatio}} />
</div>
);
}
export default ProgressBarComponent;

If you enjoyed this article or found it useful, then… oh, I don’t really care what you do. Have a fine life, my buddy!

1*84PV1EnS626HIR3snbWK4w
* * * media credits * * * want a job? * * * LinkedIn profile * * *



News Credit

%d bloggers like this: