Minimal React Transition Hook (useTransition)
Published: 2021-11-29 21:26:32

I often find myself in the need of a very basic open/close transition in React for components like dialogs, side sheets, or dropdowns. The goto solution for a while seemed to be [`React Transition Group`](https://reactcommunity.org/react-transition-group/), which I never understood how to use properly. An excellent solution for animations is [`react-spring`](https://react-spring.io/hooks/use-spring), but I'd consider it an overkill for a basic CSS powered open/close transition (but it's great for animations in something like [an image viewer](https://github.com/rkusa/react-image-viewer-hook)).

This is why I’ve ended up writing my own hook: [`react-css-transition-hook`](https://github.com/rkusa/react-css-transition-hook)

It is used like this:

```ts
const { isOpen } = useMenu();
const [isVisible, props] = useTransition(isOpen, {
  entering: "transition ease-out duration-100 transform opacity-0 scale-95",
  entered: "transition ease-out duration-100 transform opacity-100 scale-100",
  exiting: "transition ease-in duration-75 transform opacity-100 scale-100",
  exited: "transition ease-in duration-75 transform opacity-0 scale-95",
});

if (!isVisible) {
  return null
}

return (
  <div {...props}>
    ...
  </div>
)
```

Isn’t that easy to understand and reason about just from reading the usage? Here is a complete example using the hook: [Demo](https://react-menu-hook.vercel.app), [Source](https://github.com/rkusa/react-menu-hook/blob/main/website/components/Menu.tsx#L16-L22).

The hook itself is just ~50 lines long (excluding the typings and doc comments) and so simple, it easily fits into this post:

```ts
export function useTransition(
  desiredState: boolean,
  opts: UseTransitionOpts
): [boolean, TransitionProps, TransitionStep] {
  const [currentState, setCurrentState] = useState(
    Boolean(desiredState && opts.disableInitialEnterTransition)
  );
  const [transition, setTransition] = useState<TransitionStep>(() =>
    desiredState ? "entered" : null
  );

  useEffect(() => {
    // exited -> entering
    if (!currentState && desiredState) {
      setCurrentState(true);
      setTransition("entering");
    }
    // entered -> exited
    else if (currentState && !desiredState) {
      setTransition("exiting");
    }
  }, [currentState, desiredState]);

  // Once the state changed to true, trigger another re-render for the switch to
  // the entered classnames
  useEffect(() => {
    switch (transition) {
      case "entering":
        setTransition("entered");
        break;
      case "exiting":
        setTransition("exited");
        break;
    }
  }, [transition]);

  const onTransitionEnd = useCallback(() => {
    if (!desiredState) {
      setCurrentState(false);
      setTransition(null);
    }
  }, [desiredState]);

  return [
    currentState,
    { className: transition ? opts[transition] ?? "" : "", onTransitionEnd },
    transition,
  ];
}
```

This is exactly what I wanted. Simple, small, no fancy magic - just using basic `useState`, `useEffect`, and `useCallback` hooks.

Let’s dissect its inner workings top-down.

Typically, when a component is closed, it is just not rendered anymore. This does not work well with a close transition, because it is necessary to keep the component in the DOM until the close transition finished. This is why the hook takes the desired state (visible or not; `isOpen` in the usage example above, and `desiredState` in the code above) as an input, and returns whether you should still render the component or not (`isVisible` in the example usage above, and `currentState` in the code below).

```ts
const [currentState, setCurrentState] = useState(
  Boolean(desiredState && opts.disableInitialEnterTransition)
);
const [transition, setTransition] = useState<TransitionStep>(() =>
  desiredState ? "entered" : null
);
```

When the hook is first used, it determines what the initial state is and also provides an option to skip the enter transition if it starts being visible right away. It also sets its initial transition state (`transition`), which is either `entered`, if the component is already visible, or `null` if it is not.

```ts
useEffect(() => {
  // exited -> entering
  if (!currentState && desiredState) {
    setCurrentState(true);
    setTransition("entering");
  }
  // entered -> exited
  else if (currentState && !desiredState) {
    setTransition("exiting");
  }
}, [currentState, desiredState]);
```

When either the current or desired states change, it updates the active transition accordingly:
- Not visible right now (`currentState === false`), but should be shown (`desiredState === true`):  Render the component and set `entering` (usually something like 0% opacity, or moved outside of the screen) as the active transition.
- Visible right now (`currentState === true`), but should not be shown anymore (`desiredState === false`): Set active transition to `exiting` (often the same as `entering`, so something like 0% opacity, …) and keep the component for now.

For the open transition, the transition cannot be set to `entered` right away. It is always necessary to render the component with `entering` first so that there is a starting point for the transition to be based on. Example:
1. Render with `0%` opacity, and once that is reflected in the DOM,
2. Set the opacity to `100%` for the transition to start.

This is what the second `useEffect` is for.

```ts
useEffect(() => {
  switch (transition) {
    case "entering":
      setTransition("entered");
      break;
    case "exiting":
      setTransition("exited");
      break;
  }
}, [transition]);
```

The second `useEffect` cannot be integrated into the first one, because there needs to be a DOM update before the state changes of the second `useEffect` are applied. By separating them, the state changes from the first effect are reflected in the DOM before the whole hook is called again and applies the changes from the second effect. The second effect is thereby simply reacting on the changes from the first `useEffect` and kicks of the transition by moving from `entering` to `entered`, or from `exiting` to `exited`.

```ts
const onTransitionEnd = useCallback(() => {
  if (!desiredState) {
    setCurrentState(false);
    setTransition(null);
  }
}, [desiredState]);
```

It is necessary to know when the close transition finished so that the component can be removed from the DOM. This is achieved by a simple `onTransitionEnd` event handler. Once fired, it sets the current state to `false` and resets the transition to `null`.

That’s all there is to it.

Finally, as a small bonus, an advanced example of how to use it for a [Radix UI Dialog](https://www.radix-ui.com/docs/primitives/components/dialog)-based side sheet:

```ts
import React, { PropsWithChildren, useCallback } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { XIcon } from "@heroicons/react/outline";
import { useTransition } from "react-css-transition-hook";
import classNames from "classnames";

export default function SideSheet({
  isOpen,
  dismiss,
  title,
  children,
}: PropsWithChildren<{ isOpen: true; dismiss(): void; title: string }>) {
  const handleOpenChange = useCallback(
    (open: boolean) => {
      if (!open) {
        dismiss();
      }
    },
    [dismiss]
  );

  const [isVisible, { className: contentClassName, ...props }, step] =
    useTransition(isOpen, {
      entering: "translate-x-full",
      entered: "translate-x-0",
      exiting: "translate-x-0",
      exited: "translate-x-full",
    });

  const backdropClassName = step
    ? {
        entering: "opacity-0",
        entered: "opacity-100",
        exiting: "opacity-100",
        exited: "opacity-0",
      }[step]
    : "";

  if (!isVisible) {
    return null;
  }

  return (
    <Dialog.Root open={true} onOpenChange={handleOpenChange}>
      <Dialog.Overlay
        className={classNames(
          "fixed inset-0 bg-black bg-opacity-50",
          "transition-opacity duration-500 ease-in-out",
          backdropClassName
        )}
      />
      <Dialog.Content
        className={classNames(
          "fixed inset-y-0 right-0 px-4 md:px-16 pt-8 pb-16",
          "w-screen max-w-[496px]",
          "bg-white overflow-auto",
          "transform transition-transform duration-500 ease-in-out",
          contentClassName
        )}
        {...props}
      >
        <header className="flex justify-between items-center mb-8">
          <Dialog.Title className="text-2xl m-0">{title}</Dialog.Title>
          <Dialog.Close asChild>
            <button>
              <XIcon className="h-6 w-6" aria-hidden="true" />
            </button>
          </Dialog.Close>
        </header>

        {children}
      </Dialog.Content>
    </Dialog.Root>
  );
}

```