'Applying CSS classes to a dynamic collection of React nodes on a consistent delay

I have a dynamically sized collection of objects being passed into a Nav component that are being mapped and rendered as buttons. I want to apply a CSS animation to each button so that they slide in from off screen one at a time when the Nav component mounts. I figured that I would set up a loop through each one that updates a boolean value inside of a corresponding state object which applies the CSS class to the button to animate it, but each time that state object is updated, all of the buttons rerender which in turn starts all of the animations over. How can I prevent these rerenders?

// Nav.jsx

import React, { useState, useEffect } from 'react';
import { Button } from '../../../components';
import './Nav.scss';

const Nav = ({ actions }) => {
  const [renderStates, setRenderStates] = useState(actions.reduce((accum, val) => {
    return {...accum, [val.id]: false};
  }, {}));

  useEffect(() => {
    const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
    const updateStates = async () => {
      for (let i = 0; i < actions.length; i++) {
        if (i > 0) {
          await delay(75);
        }
        setRenderStates((prev) => ({
          ...prev,
          [i]: true,
        })); 
      };
    };
    updateStates();
  }, [actions.length]);

  return (
    <div className='Nav'>
      {actions.map((act) => (
        <div className={`Nav__Button ${renderStates[act.id] ? 'Animate' : ''}`} key={act.id}>
          <Button icon={act.icon} onClick={act.onClick} />
        </div>
      ))}
    </div>
  );
};

export default Nav;
/* Nav.scss */

.Nav {
  height: 100%;
  width: fit-content;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  align-self: center;
  padding: 1rem;
}

.Nav > * {
  margin: 20% 0,
}

.Nav__Button {
  margin-left: -5rem;
}

.Animate {
  animation: slideInFromLeft .4s ease;
}

@keyframes slideInFromLeft {
  0% {
    margin-left: -5rem;
  }

  75% {
    margin-left: .5rem;
  }

  100% {
    margin-left: 0;
  }
}

Here's a codesandbox that illustrates the problem (refresh the embedded browser to see the issue):

https://codesandbox.io/s/react-css-animations-on-timer-8mxnsz

Any help would be appreciated. Thanks.



Solution 1:[1]

You will need to create a from the elements inside actions.map and render a memoized version of it so that if the props do not change it will not re-render.

import { useState, useEffect, memo } from "react";
import "./styles.css";

const Test = ({ animate, label }) => {
  return (
    <div className={`Nav__Button ${animate ? "Animate" : ""}`}>
      <button>{label}</button>
    </div>
  );
};

const TestMemo = memo(Test);

export default function App() {
  const actions = [
    {
      id: 0,
      label: "button 0"
    },
    {
      id: 1,
      label: "button 1"
    },
    {
      id: 2,
      label: "button 2"
    },
    {
      id: 3,
      label: "button 3"
    }
  ];

  const [renderStates, setRenderStates] = useState(
    actions.reduce((accum, val) => {
      return { ...accum, [val.id]: false };
    }, {})
  );

  useEffect(() => {
    const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const updateStates = async () => {
      for (let i = 0; i < actions.length; i++) {
        if (i > 0) {
          await delay(2000);
        }
        setRenderStates((prev) => ({
          ...prev,
          [i]: true
        }));
      }
    };
    updateStates();
  }, [actions.length]);

  return (
    <div className="App">
      {actions.map((act) => (
        <TestMemo animate={renderStates[act.id]} label={act.label} />
      ))}
    </div>
  );
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Shahriar Shojib