Go to main content
June 29, 2022
Cover image

Unlike Svelte which has built-in animation and transition, React does not. If you have worked with animation in React, you probably faced the problem of not being able to animate easily a component that will unmount.

function App() {
  const [shouldShow, setShouldShow] = useState(true);

  // Do some animation when unmounting
  const onExitAnimation = ...;

  return shouldShow ? (
    <div onExit={onExitAnimation}>
      Animated when unmounting
    </div>
  ) : (
    <p>No more component</p>
  );
}

For example, when working with react-spring, you have to pass your state to the useTransition hook that will give you a new variable to use. You can’t directly condition the display of your component with the shouldShow state. This way react-spring manages this state internally to change it when the component has finished the animation.

function App() {
  const [shouldShow, setShouldShow] = useState(true);
  const transitions = useTransition(shouldShow, {
    leave: { opacity: 0 },
  });

  return transitions(
    (styles, show) =>
      // Here we do not use directly `shouldShow`
      show && (
        <animated.div style={styles}>
          Animated when unmounting
        </animated.div>
      ),
  );
}

To me, it doesn’t feel natural.

When I finally decided to take a look at framer-motion, it was a real pleasure when I discovered the AnimatePresence component that handles it more naturally for me.


Let’s start by looking at the code to do such animation with framer-motion.

It’s pretty simple to do this animation:

import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';

export default function App() {
  const [show, setShow] = useState(true);

  return (
    <>
      <button type="button" onClick={() => setShow(!show)}>
        Show / Unshow
      </button>
      <AnimatePresence>
        {show ? (
          <motion.p exit={{ opacity: 0 }}>
            Animated content
          </motion.p>
        ) : null}
      </AnimatePresence>
    </>
  );
}

Crazy simple. But how do they manage to do this exit animation? Have you an idea? Just two words React ref :)


As you have seen in the previous example of framer-motion you can access to an object named motion. From it, you can get your animated elements on which you can use the props initial, animate and exit.

Own implementation specification

  • make a motion object which has a key p that returns a React component to do animation
  • this component has two public props named onEnter to animate when mounting and onExit to animate when unmounting
  • use the animation web API

Let’s trigger the entry and exit animation thanks to an useEffect. We get the following implementation for AnimatedComponent and motion:

const AnimatedComponent =
  (Tag) =>
  ({ onExit, onEnter, ...otherProps }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      const animation = elementRef.current.animate(
        onEnter,
        {
          duration: 2000,
          fill: 'forwards',
        },
      );

      return () => {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: 'forwards',
          },
        );
        animation.commitStyles();
      };
      // I don't include onEnter and onExit as dependency
      // Because only want them at mount and unmount
      // Could use references to satisfy the eslint rule but
      // too much boilerplate code
    }, []);

    return <Tag {...otherProps} ref={elementRef} />;
  };

const motion = {
  p: AnimatedComponent('p'),
};

Unfortunately if we try this implementation the exit animation will not work :(

Why is it complicated to do such animation?

The reason is that when a component is no more in the React tree, it’s directly removed from the DOM tree too.

How to solve this?

The idea is to trigger the animations thanks to a property isVisible.

const AnimatedComponent =
  (Tag) =>
  ({ onExit, onEnter, isVisible, ...otherProps }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      if (isVisible) {
        const animation = elementRef.current.animate(
          onEnter,
          {
            duration: 2000,
            fill: 'forwards',
          },
        );

        return () => animation.cancel();
      } else {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: 'forwards',
          },
        );
        animation.commitStyles();

        return () => animation.cancel();
      }
    }, [isVisible]);

    return <Tag {...otherProps} ref={elementRef} />;
  };

But we do not want the user to handle the isVisible property. Moreover, the component needs to stay in the React tree to work.

It’s here that comes the AnimatePresence component that will keep the unmounted children in a reference and at each render detects components that are removed.

In order to do that, we need to be able to distinguish each children components. We are going to use key for that.

Detection of removed elements

Things you need to know

  • React.Children.forEach utility function that allows us to loop through all children
  • React.isValidElement function that allows us to validate that we have a React element
  • the key is at the first level of ReactElement and not in props!

Let’s do a function to get all valid children components:

function getAllValidChildren(children) {
  const validChildren = [];

  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child)) {
      validChildren.push(child);
    }
  });

  return validChildren;
}

As I said previously, we are going to keep children of the previous render thanks to React reference.

If you want to know more about the usage of React ref, you can see my article Things you need to know about React ref .

import { useRef, useLayoutEffect } from 'react';

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });
}

Now let’s write the method to get the key of a React element:

function getKey(element) {
  // I just define a default key in case the user did
  // not put one, for example if single child
  return element.key ?? 'defaultKey';
}

Alright, now let’s get keys of the current render and of the previous one to determine which elements have been removed:

import { useRef, useLayoutEffect } from 'react';

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter(
      (key) => !currentKeys.includes(key),
    ),
  );
}

Now that we get keys of element that will unmount in the current render, we need to get the matching element.

To do that the easier way is to make a map of elements by key.

function getElementByKeyMap(validChildren, map) {
  return validChildren.reduce((acc, child) => {
    const key = getKey(child);
    acc[key] = child;
    return acc;
  }, map);
}

And we keep the value in a ref to preserve values at each render:

import { useRef, useLayoutEffect } from 'react';

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {}),
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current,
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter(
      (key) => !currentKeys.includes(key),
    ),
  );

  // And now we can get removed elements from elementByKey
}

It’s going well!


What’s going next?

As we have seen at the beginning we can’t do the exit animation when unmounting the component thanks to the cleaning function in useEffect. So we will launch this animation thanks to a boolean isVisible that will trigger

  • the entry animation if true
  • the exit one if false.

This property will be injected to the AnimatedComponent by AnimatePresence thanks to the React.cloneElement API.

So we are going to change dynamically at each render the element that are displayed:

  • inject isVisible={true} if always presents
  • inject isVisible={false} if removed

import { useRef, useLayoutEffect } from 'react';

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {}),
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current,
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter(
      (key) => !currentKeys.includes(key),
    ),
  );

  // We know that `validChildren` are visible
  const childrenToRender = validChildren.map((child) =>
    React.cloneElement(child, { isVisible: true }),
  );

  // We loop through removed children to add them with
  // `isVisible` to false
  removedChildrenKey.forEach((removedKey) => {
    // We get the element thanks to the object
    // previously builded
    const element = elementByKey.current[removedKey];
    // We get the index of the element to add it
    // at the right position
    const elementIndex = previousKeys.indexOf(removedKey);

    // Add the element to the rendered children
    childrenToRender.splice(
      elementIndex,
      0,
      React.cloneElement(element, { isVisible: false }),
    );
  });

  // We don't return `children` but the processed children
  return childrenToRender;
}

Oh wouah! The animation works now, but it’s not totally perfect because the element stays in the tree. We need to re-render the AnimatePresence when all exit animation has been done.

We can know when an animation is ended thanks to the animation.finished promise.


The useForceRender hook can be done with a simple counter:

import { useState, useCallback } from 'react';

function useForceRender() {
  const [_, setCount] = useState(0);

  return useCallback(
    () => setCount((prev) => prev + 1),
    [],
  );
}

The final step is to re-render the AnimatePresence component when all the exit animation are finished to render the right React elements.

After this triggered render, there will be no more the removed element in the React tree.

import { useRef, useLayoutEffect } from 'react';

function AnimatePresence({ children }) {
  const forceRender = useForceRender();
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {}),
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current,
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter(
      (key) => !currentKeys.includes(key),
    ),
  );

  const childrenToRender = validChildren.map((child) =>
    React.cloneElement(child, { isVisible: true }),
  );

  removedChildrenKey.forEach((removedKey) => {
    const element = elementByKey.current[removedKey];
    const elementIndex = previousKeys.indexOf(removedKey);

    const onExitAnimationDone = () => {
      removedChildrenKey.delete(removedKey);

      if (!removedChildrenKey.size) {
        forceRender();
      }
    };

    childrenToRender.splice(
      elementIndex,
      0,
      React.cloneElement(element, {
        isVisible: false,
        onExitAnimationDone,
      }),
    );
  });

  return childrenToRender;
}

And the AnimateComponent finally becomes:

const AnimatedComponent =
  (Tag) =>
  ({
    onExit,
    onEnter,
    isVisible,
    onExitAnimationDone,
    ...otherProps
  }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      if (isVisible) {
        const animation = elementRef.current.animate(
          onEnter,
          {
            duration: 2000,
            fill: 'forwards',
          },
        );

        return () => animation.cancel();
      } else {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: 'forwards',
          },
        );
        animation.commitStyles();
        // When the animation has ended
        // we call `onExitAnimationDone`
        animation.finished.then(onExitAnimationDone);

        return () => animation.cancel();
      }
    }, [isVisible]);

    return <Tag {...otherProps} ref={elementRef} />;
  };

And here we go!


I hope I’ve managed to make you understand how it all works under the hood. Actually the real implementation is not the same that I have done. They do not cloneElement but use the React context API to be able not to pass directly an animated component (motion.something). But the main point to remember is the usage of references to get children of previous render and that the returned JSX is something processed by the AnimatePresence that manages the animation of its children and more specifically the exit one by delaying the unmounting of components to see the animation.

If you have any question do not hesitate to ask me.


You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.