In Part 1 and Part 2, we built an animated list that animates layout changes and supported properties. We used AnimDiv
, AnimNode
, and a hook to connect them. The list supports enter animations, but exit animations are missing. In this post, we will add them.
Elements, Children and reconciliation
The issue with exit animations is that, at this point, we should play them on elements that we won’t even render (since they’ve been removed from the list props). So what can we do? If we look at the motion package documentation, we see that we should use an AnimatePresence
wrapper in these cases. So, what can this component do?
First, we need to understand what our function components return and how components are updated.
function Container() {
return (
<div>
<Item name='item1'/>
<Item name='item2'/>
</div>
);
}
What happens when my Container
component is updated? Will it automatically update the Item
components and run all the hooks inside them? The answer is: No. It just returns a React.Node
tree (or, with some simplification, a React.Element
tree). These are plain lightweight JavaScript objects that contain information about the element, like its type, props, etc.
After that, React compares this result with the previous one and decides what to update / mount / unmount.
If the type is the same and its position in the tree is the same, or it has a key prop that was previously present (maybe at a different position), React will just update the component.
So, what's different when we're using children props? Nothing. React doesn't care whether we received an element as a prop and used it in our result or just created it locally.
Children props are usually just used as-is in the result; otherwise, it would be pretty difficult to reason about our code. But in some cases, it's okay to deconstruct the list or modify the items, and this is one of those cases.
AnimPresence implementation
The idea is that the AnimPresence
component will track and store its children. When a child is removed, it will temporarily keep it in the output until the exit animation finishes.
To simplify things, only React.Element
nodes with key
s will be kept temporarily. Other node types will be filtered out, and elements without a key will be removed immediately.
There’s still the question of how we’ll notify items to start their exit animation and how we’ll be notified when their animations finish. We won’t solve this within the AnimPresence
component. Instead, we’ll delegate this to a component that will wrap each individual child element, called PresenceChild
, and handle it later.
type PresenceTrackedChild = {
element: ReactElement;
isPresent: boolean;
};
export function AnimPresence({ children: _children }: PropsWithChildren) {
const children = useMemo(() => filterElements(_children), [_children]);
const [trackedChildren, setTrackedChildren] = useState(
convertToTracked(children),
);
const childrenChanged = useChangeDetection(children);
if (childrenChanged) {
const newTrackedChildren = mergeChildrenLists(trackedChildren, children);
setTrackedChildren(newTrackedChildren);
}
return trackedChildren.map(({ element, isPresent }) => {
const optionalKey = element.key;
if (optionalKey === null) {
return element;
}
const key = optionalKey;
return (
<PresenceChild
key={key}
isPresent={isPresent}
onSafeToRemove={() => {
setTrackedChildren((children) =>
children.filter(
(child) => child.element.key !== key || child.isPresent,
),
);
}}
>
{element}
</PresenceChild>
);
});
}
function mergeChildrenLists(
oldTrackedChildren: PresenceTrackedChild[],
newChildren: ReactElement[],
) {
const childrenKeys = nonNullElementKeys(newChildren);
const newTrackedChildren = convertToTracked(newChildren);
oldTrackedChildren.forEach((oldTracked, index) => {
const key = oldTracked.element.key;
if (key !== null) {
if (!childrenKeys.includes(key)) {
newTrackedChildren.splice(index, 0, {
...oldTracked,
isPresent: false,
});
}
}
});
return newTrackedChildren;
}
- First, we filter out child nodes that are not
React.Elements
(like text nodes). - Then, we map child elements to objects that contain the element itself and an
isPresent
flag. -
useChangeDetection
is a utility hook that returns true when its parameter changes from the previous update. -
mergeChildrenLists
inserts removed child elements back into the new children list at their last known index. - Finally, we wrap elements that have a key prop with
PresenceChild
components.
PresenceChild implementation
For this component, we can "cheat" again by checking the motion documentation for hints on how they implemented it. They use a usePresence
hook that returns the presence state and a safeToRemove
callback, which should be called when the exit animation finishes.
We'll follow the same approach by creating a React.Context
with these two properties and implementing a usePresence
hook to access them.
export function PresenceChild({
children,
onSafeToRemove,
isPresent,
}: PresenceChildProps) {
const contextValue = {
onSafeToRemove,
isPresent,
};
return (
<PresenceChildContext.Provider value={contextValue}>
{children}
</PresenceChildContext.Provider>
);
}
export function usePresence(): UsePresenceResult {
const context = useContext(PresenceChildContext);
return {
isPresent: context?.isPresent,
safeToRemove: context.onSafeToRemove.bind(context),
};
}
This implementation has a few issues, tough.
What happens when components using the usePresence
hook are not wrapped by an AnimPresence
component?
In this case, the context is undefined
. Usually, this would be an error (we forgot to wrap components with a Provider). But for us, this should be a valid use case (there might not be any exit animations for example).
What happens if a child doesn’t use the usePresence
hook at all?
The safeToRemove
callback will never be called, and removed items will be kept indefinitely. To fix this, we need to track whether the hook is used. We should only wait for the safeToRemove call when we know the child component uses it. Otherwise, we can remove the child element immediately.
So, we’ll add another callback to our context: preventImmediateRemoval
. This callback won’t be exposed through the usePresence
hook but will be called whenever the hook executes. In PresenceChild
this will set a boolean ref to indicate whether we should wait for safeToRemove
or not.
export function PresenceChild({
children,
onSafeToRemove,
isPresent,
}: PresenceChildProps) {
// will be set to true when a component in the subtree uses the `usePresence` hook
const isRemovalDeferred = useRef(false);
const contextValue = {
onSafeToRemove,
isPresent,
preventImmediateRemoval: () => (isRemovalDeferred.current = true),
};
useEffect(() => {
if (!isPresent && !isRemovalDeferred.current) {
onSafeToRemove();
}
}, [isPresent, onSafeToRemove]);
return (
<PresenceChildContext.Provider value={contextValue}>
{children}
</PresenceChildContext.Provider>
);
}
export function usePresence(): UsePresenceResult {
const context = useContext(PresenceChildContext);
if (!context) {
// It's completly valid to use this hook without <AnimPresence> wrapper
// in that case nothing will defer the unmouning of the element,
// so isPresent can always be true, and safeToRemove is a no-op.
return {
isPresent: true,
safeToRemove: () => {},
};
}
// when a component uses this hook we can assume that it will handle the removal process
// and eventualy it will call safeToRemove at some point
context.preventImmediateRemoval();
return {
isPresent: context?.isPresent,
safeToRemove: context.onSafeToRemove.bind(context),
};
}
Support exit animations in AnimDiv
First, we need to add a new exit
prop to our component.
type AnimDivOwnProps = {
initial?: Animation;
animate?: Animation;
+ exit?: Animation;
options?: AnimationOptions;
};
We'll use the usePresence
hook we just implemented. Since the target animation can now change based on the isPresent
state, we'll use a target
ref to store the currently active animation.
Additionally, we need to reimplement the layout effect that triggers animations and make it dependent on the isPresent
state.
function useAnimNode({ initial, animate, exit, options }: UseAnimNodeParams) {
...
const target = useRef<Animation>(undefined);
const { isPresent, safeToRemove } = usePresence();
...
useLayoutEffect(() => {
if (isPresent && target.current !== animate) {
target.current = animate;
animNode.animateTo(target.current ?? {}, target.current?.options);
} else if (!isPresent && target.current !== exit) {
target.current = exit;
animNode.animateTo(target.current ?? {}, target.current?.options);
animNode.setFinishedAnimatingCallback(() => {
safeToRemove();
});
}
return () => {
animNode.setFinishedAnimatingCallback(undefined);
};
}, [isPresent, animate, exit]);
...
}
The finish animating callback will be called by AnimNode
when the animations
Set becomes empty.
Now after updating our List component and setting the exit property, we can check the motion implementation (left) and our own implementation (right) side by side.
The two look almost identical with some minor animation curve differences.
Top comments (0)