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.
Note: As always, the code I will write is simplified compared to the library code.
Exit animation with framer-motion
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>
</>
);
}
Note: I just configure the
exit
but you can also configure the "enter" transition thanks tointial
andanimate
prop.
Crazy simple. But how do they manage to do this exit animation? Have you an idea? Just two words React ref
:)
Under the hood
Make enter and exit animation
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 have two public
props
namedonEnter
to animate when mounting andonExit
to animate when unmounting - use the animation web API
Note: If you want to know more about the animation web API you can read my article Let's do some animations in native Javascript
Let's trigger the enter 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 because 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 children -
React.isValidElement
function that allows us to validate that we have a React element - the
key
is at the first level ofReactElement
and not inprops
!
Get all valid children
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;
}
Keep children of previous render
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;
});
}
Get key of children and determine removed keys
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))
);
}
Get removed elements
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 enter 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
Note: We are going to introduce a new variable named
childrenToRender
to do that.
Injection of isVisible
into AnimatedComponent
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.
useForceRender
hook
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),
[]
);
}
Re-render when all exit animation are done
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!
Conclusion
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 unmount of components to see the animation.
If you have any question do not hesitate to ask me.
Do not hesitate to comment and if you want to see more, you can follow me on Twitch or go to my Website.
Top comments (2)
This is exactly what i'm looking for days, great article. Thanks you!!!
Oh thank you <3