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 keyp
that returns a React component to do animation - this component has two public
props
namedonEnter
to animate when mounting andonExit
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.
Things you need to know
React.Children.forEach
utility function that allows us to loop through all childrenReact.isValidElement
function that allows us to validate that we have a React element- the
key
is at the first level ofReactElement
and not inprops
!
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.