re-triggering css animations with react

Viewed 301

I have a list of items in a table. I would like an item's row to briefly flash with a highlight due to some external events.

I have a CSS animation fadingHighlight to accomplish the desired visual effect, and when an event comes in I set a class last-updated on the desired row to trigger the animation.

However, when an update comes in for the same row multiple times in a row, only the first update causes a flash. (I believe this is because react persists the last-updated class on the row rather than re-rendering it, and thus the css doesn't re-start the animation.)

How can I re-trigger the animation if the same item gets updates multiple times in a row?

Demo and code: https://codesandbox.io/s/pensive-lamarr-d71zh?file=/src/App.js

Relevant portions of the react:

const Item = ({ id, isLastUpdated }) => (
  <div className={isLastUpdated ? "last-updated" : ""}>Item {id}</div>
);

const App = () => {
  const [lastUpdatedId, setLastUpdatedId] = React.useState(undefined);

  const itemIds = ["1", "2", "3"];
  return (
    <div className="App">
      <h2>The list of items</h2>
      {itemIds.map((id) => (
        <Item key={id} id={id} isLastUpdated={lastUpdatedId === id} />
      ))}

      <h2>Trigger updates</h2>
      {itemIds.map((id) => (
        <button onClick={() => setLastUpdatedId(id)}>Update item {id}</button>
      ))}
    </div>
  );
}

Styles:


@keyframes fadingHighlight {
  0% {
    background-color: #ff0;
  }
  100% {
    background-color: #fff;
  }
}

.last-updated {
  animation: fadingHighlight 2s;
}
3 Answers

Here we are fighting against the React principle of don't update things that don't change, and I've never done something like this but, it seems to solve your problem.

Due to the react behavior of waiting to the code to run, to then compare the results with the current values, this will not work:

// Let suppose it is currently `1`.

setLastUpdatedId(undefined); // <-- it does not apply.
setLastUpdatedId(1);         // <-- it does not trigger any changes.

Solution (sort of):

I used useEffect and an auxiliar state (waitAndHold) to play with the asynchronous behavior of useState & useEffect instead of just setting the new value in-line with setLastUpdatedId. So we can reset to undefined, wait for the changes to apply, and then reset again to the same value as before.

Working solution here.

What's new in the App:

const [waitAndHoldId, setWaitAndHoldId] = React.useState([]);

useEffect(() => {
  setLastUpdatedId(waitAndHoldId[0]);
}, [waitAndHoldId]);

function resetLastUpdatedId(id) {
  if (lastUpdatedId !== id) {
    // If it's different: just set it and we are done.
    setLastUpdatedId(id);
  } else {
    // If not:
    setWaitAndHoldId([id]);

    /**
     * Setting an array will always trigger the useEffect (even if
     * it remains the same). The useEffect will take an instant to
     * run. Meanwhile, force the `lastUpdatedId` to reset with a
     * different value (e.g.: undefined).
     */

    // This will run before the useEffect.
    setLastUpdatedId(undefined);
  }
}

// ...

<button onClick={() => resetLastUpdatedId(id)}>
  Update item {id}
</button>

Update

This solution works but I got some feedback from a teammate that set... functions generally shouldn't be called in the rendering loop and instead should go in a useEffect. When I made that change the demo still worked, but in our production code it didn't.

My guess is that relying on multiple useEffect leads to some sensitivity to react's event loop internals as to the sequence of those effect calls and when they are collapsed into a single rendering step.

Dodging that problem led to this answer (which also involves less code.)

Original answer

Leo's answer (and in particular the use of useEffect) works! And it led me to a revised updated solution (demo here) that has some desirable properties.

The main change from Leo's answer is that each Item component is now responsible for independently managing the state for the highlighting effect, and it needs to be passed the time of the most recent update. (Or, more precisely, it needs to be passed some new value on each update, but the time seems the most natural.)

A nice result of this approach is that multiple items can be highlighted at the same time (e.g. if updates for one item come in before the highlight fades on another).

const Item = ({ id, updateTime }) => {
  const [lastUpdateTime, setLastUpdateTime] = React.useState(undefined);
  const [showHighlight, setShowHighlight] = React.useState(false);
  const [triggerHighlight, setTriggerHighlight] = React.useState(false);

  if (updateTime && updateTime !== lastUpdateTime) {
    // Update time has changed! 
    setLastUpdateTime(updateTime);
    // Remove the highlighting class so that re-adding
    // it restarts the animation.
    setShowHighlight(false);
    // And record that we need to re-add the class (but don't do it yet).
    setTriggerHighlight(true);
  }

  useEffect(() => {
    if (triggerHighlight) {
      // Re-add the highlight class inside useEffect so 
      // that it will happen in a separate render step.
      setShowHighlight(true);
      setTriggerHighlight(false);
    }
  }, [triggerHighlight]);

  return <div className={showHighlight ? "updated" : ""}>Item {id}</div>;
};

And we need App-level state to track those most recent update times:

  const [updateTimes, setUpdateTimes] = React.useState({});

  // ...

        <Item key={id} id={id} updateTime={updateTimes[id]} />

  // ...
        <button
          onClick={() => {
            setUpdateTimes({ ...updateTimes, [id]: Date.now() });
          }}
        >

   // ...

(Thanks again to @leo for this answer which helped me get to this one!)

I got the desired behavior (forcing the animation to restart) by

  1. Immediately removing the highlight class, and
  2. Setting a timeout (10msec seems to be fine) for adding back the highlight class.

The timeout seems to force react to separately render the component without the highlight class and then again with the highlight class, causing the animation to restart. (Without the timeout, react may collapse these changes into one render step, causing the DOM to treat the whole thing as a no-op.)

A nice result of this approach where each Item manages its own highlight state is that multiple items can be highlighted at the same time (e.g. if updates for one item come in before the highlight fades on another).

Demo here

const Item = ({ id, updateTime }) => {
  const [showHighlight, setShowHighlight] = React.useState(false);

  // By putting `updateTime` in the dependency array of `useEffect,
  // we re-trigger the highlight every time `updateTime` changes.
  useEffect(() => {
    if (updateTime) {
      setShowHighlight(false);
      setTimeout(() => {
        setShowHighlight(true);
      }, 10);
    }
  }, [updateTime]);

  return <div className={showHighlight ? "updated" : ""}>Item {id}</div>;
};

const App = () => {
  // tracking the update times at the top level
  const [updateTimes, setUpdateTimes] = React.useState({});

  // ...
        <Item key={id} id={id} updateTime={updateTimes[id]} />

  // ...
        <button
          onClick={() => {
            setUpdateTimes({ ...updateTimes, [id]: Date.now() });
          }}
        >
   // ...
}

Related