React에서 팝오버나 모달 같은 컴포넌트를 만들 때, 사용자 경험을 향상시키기 위해 부드러운 열고 닫는 애니메이션을 적용하는 경우가 많습니다. 하지만 열고 닫음을 상태(state)로 관리할 때 닫기(Exit)에 애니메이션을 적용하는 것은 생각보다 까다로울 수 있습니다.

닫기 시 애니메이션이 적용되지 않고 있다

이는 컴포넌트의 존재 여부를 상태로 관리할 때 발생하는 문제인데요. 상태가 "닫힘"으로 변경되면 컴포넌트가 즉시 언마운트되어, 애니메이션을 적용할 시간이 없기 때문입니다.

function PopoverContent() {
  const { isOpen, ... } = usePopoverContext();

	// 컴포넌트가 바로 사라져 애니메이션이 적용되지 않는다.
  return (
    <>
      {isOpen && (
        <div>
          {...내용}
        </div>
      )}
    </>
  );
}

언듯 보면 문제가 없어 보일 수 있지만, 이러한 경험이 축적되면 자칫 완성도가 떨어지는 애플리케이션처럼 보일 수 있습니다. 따라서 오늘은 이 문제를 해결하기 위해 컴포넌트 언마운트 시 애니메이션을 적용할 수 있는 커스텀 훅을 만들어 보겠습니다.

 

 

useExitAnimation 훅 만들기

오늘 만들 useExitAnimation 훅은 컴포넌트의 닫기 또는 언마운트 시 애니메이션 진행 여부를 알려줍니다. 즉 애니메이션이 진행 중일 때 그 상태를 전달함으로써, 컴포넌트를 즉시 제거하지 않고 애니메이션 완료 후에 제거할 수 있게 합니다. 전체 코드는 맨 아래에 있으니 사용하실 분들은 참고 바랍니다.

 

우선 기본 구조를 잡아봅시다.

type ExitState = "idle" | "exiting" | "exited";

export function useExitAnimation(
  ref: RefObject<HTMLElement | null>,
  isOpen: boolean
) {
	// Exit 애니메이션이 진행 중인지를 알려준다
  const [isExiting, setIsExiting] = useState(false);
  // Exit의 진행 상태를 기록한다.
  const [exitState, setExitState] = useState<ExitState>("idle");
  
  // ...
  
  return isExiting;
}

사용 시에는 아래와 같이 Exit 애니메이션이 진행 중일 경우 컴포넌트를 유지하도록 합니다.

function PopoverContent() {
  const { isOpen, ... } = usePopoverContext();
  
  const isExiting = useExitAnimation(ref,isOpen);
    
  // 컴포넌트가 애니메이션이 진행중이면 사라지지 않는다.
  return (
    <>
      {(isOpen || isExiting) && (
        <div ref={ref}>
          {...내용}
        </div>
      )}
    </>
  );
}

먼저 Exit이 트리거될 때와 종료될 때의 상태 변경 로직을 작성합니다.

type ExitState = "idle" | "exiting" | "exited";

export function useExitAnimation(
  ref: RefObject<HTMLElement | null>,
  isOpen: boolean
) {
  const [isExiting, setIsExiting] = useState(false);
  const [exitState, setExitState] = useState<ExitState>("idle");

	// Exit이 트리거 될 때
  if (!isOpen && ref.current && exitState === "idle") {
    setExitState("exiting");
    setIsExiting(true);
  }

  // 컴포넌트가 제거되면 동작이 완료되었기에 idle로 변경
  if (!ref.current && exitState === "exited") {
    setExitState("idle");
  }
	
  // Exit이 끝났을 때 호출할 콜백 함수, 이후 사용할 예정
  const handleExitEnd = useCallback(() => {
    setExitState("exited");
    setIsExiting(false);
  }, []);
  
	// ...

  return isExiting;
}

다음으로 애니메이션이 완료될 때 콜백 함수를 호출하기 위해 animationend 이벤트를 등록하겠습니다.

type ExitState = "idle" | "exiting" | "exited";

export function useExitAnimation(
  ref: RefObject<HTMLElement | null>,
  isOpen: boolean
) {
  const [isExiting, setIsExiting] = useState(false);
  const [exitState, setExitState] = useState<ExitState>("idle");

  if (!isOpen && ref.current && exitState === "idle") {
    setExitState("exiting");
    setIsExiting(true);
  }

  if (!ref.current && exitState === "exited") {
    setExitState("idle");
  }

  const handleExitEnd = useCallback(() => {
    setExitState("exited");
    setIsExiting(false);
  }, []);

  useLayoutEffect(() => {
    if (isExiting && ref.current) {
      const computedStyle = window.getComputedStyle(ref.current);
      if (
        computedStyle.animationName &&
        computedStyle.animationName !== "none"
      ) {
        const handleAnimationEnd = (e: AnimationEvent) => {
          if (e.target === ref.current) {
            element.removeEventListener("animationend", handleAnimationEnd);
            handleExitEnd();
          }
        };
        const element = ref.current;
        element.addEventListener("animationend", handleAnimationEnd);
        return () => {
          element.removeEventListener("animationend", handleAnimationEnd);
        };
      } else {
        handleExitEnd();
      }
    }
  }, [isExiting, ref, handleExitEnd]);

  return isExiting;
}

애니메이션 존재 여부를 확인하려면 ComputedStyle이 필요하므로, useLayoutEffect 훅에서 이 정보를 가져옵니다. useLayoutEffect를 사용하는 이유는 애니메이션이 존재하지 않을 때 isExiting을 즉시 false로 설정해야 하는데, useEffect를 사용하면 렌더링이 완료된 후 상태가 변경되어 문제가 발생할 수 있습니다. 따라서 useLayoutEffect를 사용해 사용자가 화면을 보기 전에 이를 변경합니다.

 

애니메이션이 등록되었다면 animationend 이벤트에 이벤트 핸들러를 등록하면 됩니다. 이제 어떻게 동작하는지를 한번 확인해 볼까요?

애니메이션이 동작하지만 무언가가 이상하다

얼핏 보면 잘 동작하는 것처럼 보이지만 문제가 있습니다. 눈치채셨나요? 잘 보이지 않을 수 있으니 좀 더 느린 속도로 살펴보겠습니다.

마지막에 컴포넌트가 깜박거리면서 사라진ㄷ

자세히 보면 애니메이션이 끝나고 바로 사라지지 않고 잠깐 다시 등장했다가 깜빡이며 사라집니다. 이는 React의 상태 업데이트가 비동기로 처리되기 때문에 발생하기 때문입니다. 이벤트 핸들러에서 isExiting 상태를 false로 변경하더라도, 이 변경이 비동기적으로 이루어지기 때문에 상태가 실제로 변경되기 전에 화면이 다시 노출되는 것이지요. 이러한 문제는 사용자 경험에 치명적일 수 있으니 개선해 봅시다.

 

 

DOM 업데이트 동기화하기

상태 변경으로 인한 DOM의 변경을 동기적으로 처리하려면 react-dom에서 제공하는 flushSync 함수를 사용해야 합니다. flushSync 함수는 콜백을 인자로 받아 해당 콜백 내부의 모든 업데이트를 동기적으로 처리하며, DOM이 즉시 업데이트되는 것을 보장합니다.

주의할 점은 React의 상태가 비동기로 업데이트되는 것이 성능 측면에서 의도적인 결정이라는 것입니다. 따라서 flushSync 함수를 과도하게 사용해서는 안 됩니다. 상태 변경으로 인한 DOM의 즉시 업데이트가 꼭 필요한 경우에만 사용해야 성능 저하를 방지할 수 있습니다.

// flushSync 불러오기
import { flushSync } from "react-dom";

type ExitState = "idle" | "exiting" | "exited";

export function useExitAnimation(
  ref: RefObject<HTMLElement | null>,
  isOpen: boolean
) {
	// ...
	
  useLayoutEffect(() => {
    if (isExiting && ref.current) {
      const computedStyle = window.getComputedStyle(ref.current);
      if (
        computedStyle.animationName &&
        computedStyle.animationName !== "none"
      ) {
        const handleAnimationEnd = (e: AnimationEvent) => {
          if (e.target === ref.current) {
            element.removeEventListener("animationend", handleAnimationEnd);
            // flushSync를 사용해 DOM을 즉시 업데이트
            flushSync(() => handleExitEnd());
          }
        };
        const element = ref.current;
        element.addEventListener("animationend", handleAnimationEnd);
        return () => {
          element.removeEventListener("animationend", handleAnimationEnd);
        };
      } else {
        handleExitEnd();
      }
    }
  }, [isExiting, ref, handleExitEnd]);

  return isExiting;
}

이제 모든 애니메이션이 아래와 같이 자연스럽게 동작하는 것을 확인할 수 있습니다. useExitAnimation 훅은 상태에 따라 컴포넌트를 언마운트해야 하는 UI 컴포넌트 어디에서나 유용하게 활용할 수 있겠네요!

애니메이션이 우아하게 작동하고 있다

 

 

마무리하며

오늘은 컴포넌트를 언마운트할 때 애니메이션을 우아하게 적용하기 위한 useExitAnimation 훅을 만들어 봤습니다. UI에서 이런 의도적인 언마운트가 필요한 컴포넌트가 종종 있는데, 이런 상황에서 이 훅이 유용하게 사용될 수 있을 것 같습니다.

 

덧붙여, transition 속성을 이용해 애니메이션 효과를 주는 경우도 많은데요. transition의 경우도 useExitAnimation과 유사하게 구현할 수 있으므로, 관심 있는 분들은 직접 만들어 보는 것도 좋을 것 같습니다.

 

 

전체 코드

import {
  RefObject,
  useCallback,
  useEffect,
  useLayoutEffect,
  useState,
} from "react";
import { flushSync } from "react-dom";

type ExitState = "idle" | "exiting" | "exited";

export function useExitAnimation(
  ref: RefObject<HTMLElement | null>,
  isOpen: boolean
) {
  const [isExiting, setIsExiting] = useState(false);
  const [exitState, setExitState] = useState<ExitState>("idle");

  if (!isOpen && ref.current && exitState === "idle") {
    setExitState("exiting");
    setIsExiting(true);
  }

  // 컴포넌트가 제거되면 동작이 완료되었기에 idle로 변경
  if (!ref.current && exitState === "exited") {
    setExitState("idle");
  }

  const handleExitEnd = useCallback(() => {
    setExitState("exited");
    setIsExiting(false);
  }, []);

  useLayoutEffect(() => {
    if (isExiting && ref.current) {
      const computedStyle = window.getComputedStyle(ref.current);
      if (
        computedStyle.animationName &&
        computedStyle.animationName !== "none"
      ) {
        const handleAnimationEnd = (e: AnimationEvent) => {
          if (e.target === ref.current) {
            element.removeEventListener("animationend", handleAnimationEnd);
            flushSync(() => handleExitEnd());
          }
        };
        const element = ref.current;
        element.addEventListener("animationend", handleAnimationEnd);
        return () => {
          element.removeEventListener("animationend", handleAnimationEnd);
        };
      } else {
        handleExitEnd();
      }
    }
  }, [isExiting, ref, handleExitEnd]);

  return isExiting;
}