DEV Community

Cover image for Part 3: Exit animations
Zsolt Szabó
Zsolt Szabó

Posted on

Part 3: Exit animations

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

AnimPresence props and element results

To simplify things, only React.Element nodes with keys 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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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.Contextwith 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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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]);

  ...
}
Enter fullscreen mode Exit fullscreen mode

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.

Side by side animated list implementations

The two look almost identical with some minor animation curve differences.

You can view the full code or check the diff from Part 2.

Top comments (0)