Animating list reordering with React Hooks

Animating list reordering with React Hooks

A little while ago I was given a cool design for an Instagram story styled bubble component where each bubble would smoothly slide into its new position when we got its new order from the API.

While it can be straightforward to do a whole load of animations and transitions with CSS, it took me a while to find an example of animating the reordering of list items, especially with React. Since I’ve also started to get used to the concepts of React Hooks I wanted to use them to implement this animation too.

I found this difficult to do using React hooks because my component would automatically rerender, in its new order, when it got new data. I was trying to hook into the moment before rerendering to smoothly transition from one state to another. Without the componentWillReceiveProps function call from the class components, this was hard to do.

I was under the (incorrect) assumption that there would be loads of React hooks examples out in the wild. I honestly just wanted a copypasta solution that I wouldn’t have to tweak too much 👀. I also didn’t want to bring in some huge, usually overly flexible package to reorder one small thing. I did come across a great post by Joshua Comeau (linked below). It explains how to do exactly what I needed, but with class components. With React hooks I needed to re-think some of the concepts to get it to work, but I’ve based the majority of this work on that post.

What we want to happen:

  1. Keep an eye out for when our element list is going to change
  2. When it changes we want to calculate the previous positions and the new positions of each element in the list before the DOM updates
  3. Also before the DOM updates with the new order of the list we want to “pause” the update and show a smooth transition of each item in the list from its old position to its new position

Let’s start with a parent component that just renders the children that is passed into it, AnimateBubbles:

1
2
3
4
5
6
7
import React from "react";

const AnimateBubbles = ({ children }) => {
  return children;
};

export default AnimateBubbles;

Then we can use that component by rendering our items inside of it. In my case I’ve created a Bubble component that adds the styles to make each image a circle, the full code is here. The Bubble component also forwards the ref onto the DOM element. This is important as we can use the ref to find where the element is rendered in the DOM, then we can calculate its position. Another important prop is the key, this is not only needed for React when mapping over elements, but we can also use later to uniquely identify each item and match its old and new positions in the DOM.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React, { useState, createRef } from "react";
import Bubble from "./Bubble";
import AnimateBubbles from "./AnimateBubbles";
import initialImages from "./initialImages";

export default function App() {
  const [images, setImages] = useState(initialImages);
  return (
    <div>
      <AnimateBubbles>
        {images.map(({ id, text }) => (
          <Bubble key={id} id={id} text={text} ref={createRef()} />
        ))}
      </AnimateBubbles>
    </div>
  );
}

Now that we have the foundations of our components we can start building out the logic of our AnimateBubbles component.

Keeping an eye out for React rerenders

With React hooks, we no longer have access to lifecycle methods like componentWillReceiveProps or componentDidUpdate, instead it’s all about effects. If we want to do something when a prop changes we can do the work inside of a useEffect. The useEffect hook tells React that our component needs to do something after it renders. In our case we only want to do any work if our list changes and the new order is rendered. Adding children as a dependency allows us to do that.

1
2
3
4
5
6
7
const AnimateBubbles = ({ children }) => {
  useEffect(() => {
    // do some layout-y stuff
  }, [children]);

  return children;
};

Measure each position in the the DOM

To calculate the position of each child in the DOM whenever the children prop changes, we can use getBoundingClientRect. I created a separate helper function to do this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from "react";

const calculateBoundingBoxes = children => {
  const boundingBoxes = {};

  React.Children.forEach(children, child => {
    const domNode = child.ref.current;
    const nodeBoundingBox = domNode.getBoundingClientRect();

    boundingBoxes[child.key] = nodeBoundingBox;
  });

  return boundingBoxes;
};

export default calculateBoundingBoxes;

In this function we pass in children as an argument and use the forEach function on React.Children to iterate over them, getting the measurements of each item in an object that we can later store in state. This is where setting a key on each child is important as we store each box with its key as the object key so we can match up the old position with the new position later. It is also why creating a ref for each child is important, as we use that to find the element in the DOM and measure the bounding box for it. Now, when we call this function inside of the useEffect we will get the bounding box for each child updated every rerender 🎉.

1
2
3
4
5
6
7
8
9
10
const AnimateBubbles = ({ children }) => {
  const [boundingBox, setBoundingBox] = useState({});

  useEffect(() => {
    const newBoundingBox = calculateBoundingBoxes(children);
    setBoundingBox(newBoundingBox);
  }, [children]);

  return children;
};

The problem now is we’re only getting the new positions, but we also need the old positions so we can do the slide animation from old position to new position.

Getting the previous state/props with the usePrevious hook

One way in which we can get the old positions of the children is by keeping track of the previous state of the children. The React docs already suggest a hook for this called usePrevious. They say that it may be provided out of the box in the future since it’s considered a common use case. Using usePrevious means we can do the exact same thing to measure the bounding box of the old positions as we do for the new positions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const AnimateBubbles = ({ children }) => {
  const [boundingBox, setBoundingBox] = useState({});
  const [prevBoundingBox, setPrevBoundingBox] = useState({});
  const prevChildren = usePrevious(children);

  useEffect(() => {
    const newBoundingBox = calculateBoundingBoxes(children);
    setBoundingBox(newBoundingBox);
  }, [children]);

  useEffect(() => {
    const prevBoundingBox = calculateBoundingBoxes(prevChildren);
    setPrevBoundingBox(prevBoundingBox);
  }, [prevChildren]);

  return children;
};

I’ve put this into a separate useEffect as they don’t need to be done together and they both have different dependencies. These previous positions will now be recalculated every time children change.

Now we have these two important pieces of information, we can move on to the actual transition 😅

Making moves

When making the actual animation I looked more into FLIP as was talked about in Joshua Comeau’s post. FLIP stands for First, Last, Invert, Play, coined by Paul Lewis as a principle for rendering more performant animations.

So in our case, we find the first position of each child. We have this stored in state in prevBoundingBoxes. Then we find the last position of each child. We also have this stored in state in boundingBoxes. The next step is to invert, which is to figure out how each child has changed and apply those transformations to each child so it looks like it is in its first position.

We can set up a new useEffect to do this with dependencies on children, boundingBoxes and prevBoundingBoxes as we’ll use all of those values in the effect. Remember when React rerenders, it instantly updates the view with the new state, but we can use requestAnimationFrame to tell the browser that we want to perform an animation. The browser will call the function you give it before the next repaint.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const AnimateBubbles = ({ children }) => {
  // calculate bounding boxes from previous examples code goes here

  useEffect(() => {
    const hasPrevBoundingBox = Object.keys(prevBoundingBox).length;

    if (hasPrevBoundingBox) {
      React.Children.forEach(children, child => {
        const domNode = child.ref.current;
        const firstBox = prevBoundingBox[child.key];
        const lastBox = boundingBox[child.key];
        const changeInX = firstBox.left - lastBox.left;

        if (changeInX) {
          requestAnimationFrame(() => {
            // Before the DOM paints, invert child to old position
            domNode.style.transform = `translateX(${changeInX}px)`;
            domNode.style.transition = "transform 0s";
          });
        }
      });
    }
  }, [boundingBox, prevBoundingBox, children]);

  return children;
};

This we can do with first - last = inverted-value on the left values of the box. Since we also have a reference to the DOM node, we can apply the transform straight onto the node with a transition of 0 seconds so it inverts instantly.

The final step is to play the animation. To do this we wait for the child elements to be inverted, then we remove the transform and apply a smooth transition. The elements then slide into their new positions 💃🏾💃🏾💃🏾.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const AnimateBubbles = ({ children }) => {
  // calculate bounding boxes from previous examples code here

  useEffect(() => {
    const hasPrevBoundingBox = Object.keys(prevBoundingBox).length;

    if (hasPrevBoundingBox) {
      React.Children.forEach(children, child => {
        const domNode = child.ref.current;
        const firstBox = prevBoundingBox[child.key];
        const lastBox = boundingBox[child.key];
        const changeInX = firstBox.left - lastBox.left;

        if (changeInX) {
          requestAnimationFrame(() => {
            // Before the DOM paints, invert child to old position
            domNode.style.transform = `translateX(${changeInX}px)`;
            domNode.style.transition = "transform 0s";

            requestAnimationFrame(() => {
              // After the previous frame, remove
              // the transistion to play the animation
              domNode.style.transform = "";
              domNode.style.transition = "transform 500ms";
            });
          });
        }
      });
    }
  }, [boundingBox, prevBoundingBox, children]);

  return children;
};

Remove the glitch

While I started work with useEffect I found that it was looking super glitchy, then after digging around in the React docs I found this tip:

“Unlike componentDidMount or componentDidUpdate, effects scheduled with useEffect don’t block the browser from updating the screen. This makes your app feel more responsive. The majority of effects don’t need to happen synchronously. In the uncommon cases where they do (such as measuring the layout), there is a separate useLayoutEffect Hook with an API identical to useEffect.” - React docs

Since our aim is to measure the layout of elements in the DOM, what we actually need is useLayoutEffect. As noted, useLayoutEffect has an identical API to useEffect so I could easily switch out one for the other and it made the whole animation look super smooth 😎

Extras

← Back to writing list