React animation onDestroy (removed from the DOM)

aspnxdd

Arnau Espin

Posted on July 14, 2022

React animation onDestroy (removed from the DOM)

Hello and welcome to this post about animating a transition in React when a component is destroyed, AKA removed from the DOM.

I usually face issues when applying some fadeIn/fadeOut transition on a React component or JSX element. Because when the component/element is removed from the DOM I can no longer apply any transition.

Let's write some basic React app with a counter which is increased by 1 every second.
We display the counter and below it we display a react image whenever the count is divisible by 3.

Demo here.

// Disable React Strict mode for this example
function App() {
  const [count, setCount] = useState(0);
  const [isDivisibleByThree, setIsDivisibleByThree] = useState(false);

  useEffect(() => {
    if (count !== 0 && count % 3 === 0) setIsDivisibleByThree(true);
  }, [count]);

  useEffect(() => {
    setInterval(() => {
      setCount((p) => (p += 1));
    }, 1000);
  }, []);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "50px",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <div>
        <h1>count is {count}</h1>
      </div>
      <p>
        {isDivisibleByThree && (
          <img ref={imageRef} src={reactLogo} alt="react logo"/>
        )}
      </p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We can animate whenever the <img> is visible with the following CSS.
This way when the image first appears it will have some slide-in animation, moving from top to bottom.

img {
  animation: slide-in 0.5s ease-in-out;
}
p {
  height: 5rem;
  width: 5rem;
}
@keyframes slide-in {
  0% {
    opacity: 0;
    transform: translateY(-50%);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}
Enter fullscreen mode Exit fullscreen mode

So... how can we animate when the <img> element is removed from the DOM.

Let's create a custom hook called useHandleDestroyAnimated, this hook accepts a ref (from the useRef hook) from the HTML element we wish to animate on destroy.

Before hiding the <img> through setting the isDivisibleByThree state to false, we animate its destruction after 1000ms, then, we set the state to false.

async function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function useHandleDestroyAnimated<T extends HTMLElement>(
  ref: MutableRefObject<T | null>
): [boolean, (_: boolean) => void] {
  const [state, setState] = useState(false);

// everytime the state or ref change, if state is true, we animate the destroy of the component.
  useEffect(() => {
    if (state) {
      handleDeletion(ref);
    }
  }, [ref, state]);

  function handleDeletion<T extends HTMLElement>(
    element: MutableRefObject<T | null>
  ) {
    const style = element?.current?.style;
    if (!style) return;
    sleep(1000).then(() => {
      style.transition = "all 0.5s";
      style.transform = "translateY(-50%)";
      style.opacity = "0";
      sleep(1000).then(() => {
        setState(false);
      });
    });
  }
  return [state, setState];
}
Enter fullscreen mode Exit fullscreen mode

Let's add the ref from the useRef hook to the App.tsx:

const imageRef = useRef<HTMLImageElement | null>(null);
.....
.....
.....
 <p>
        {isDivisibleByThree && (
          <img ref={imageRef} src={reactLogo} alt="react logo" />
        )}
      </p>
Enter fullscreen mode Exit fullscreen mode

The final code is:

import { useEffect, useState, MutableRefObject, useRef } from "react";
import reactLogo from "./assets/react.svg";
import "./App.css";

async function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const styles = Object.freeze({
  opacity: "0",
  transform: "translateY(-50%)",
  transition: "all 0.5s",
});

function useHandleDestroyAnimated<T extends HTMLElement>(
  ref: MutableRefObject<T | null>
): [boolean, (_: boolean) => void] {
  const [state, setState] = useState(false);

  useEffect(() => {
    if (state) {
      handleDeletion(ref);
    }
  }, [ref, state]);

  function handleDeletion<T extends HTMLElement>(
    element: MutableRefObject<T | null>
  ) {
    const style = element?.current?.style;
    if (!style) return;
    sleep(1000).then(() => {
      style.transition = styles.transition;
      style.transform = styles.transform;
      style.opacity = styles.opacity;
      sleep(1000).then(() => {
        setState(false);
      });
    });
  }
  return [state, setState];
}

function App() {
  const [count, setCount] = useState(0);
  const imageRef = useRef<HTMLImageElement | null>(null);
  const [isDivisibleByThree, setIsDivisibleByThree] =
    useHandleDestroyAnimated(imageRef);

  useEffect(() => {
    if (count !== 0 && count % 3 === 0) setIsDivisibleByThree(true);
  }, [count]);

  useEffect(() => {
    setInterval(() => {
      setCount((p) => (p += 1));
    }, 1000);
  }, []);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "50px",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <div>
        <span>count is {count}</span>
      </div>
      <p>
        {isDivisibleByThree && (
          <img ref={imageRef} src={reactLogo} alt="react logo" />
        )}
      </p>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

I hope you found this useful!

You can follow me on:

💖 💪 🙅 🚩
aspnxdd
Arnau Espin

Posted on July 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related