Deep Dive: Radix UI의 React Presence 컴포넌트

들어가며

리액트는 컴포넌트 언마운트 시 DOM에서 해당 요소가 바로 삭제되기 때문에 fade-out과 같은 애니메이션을 구현하기가 까다롭습니다.

물론 motion이나 gsap 같은 애니메이션 라이브러리로 이 문제를 쉽게 해결할 수 있습니다. 하지만 단순한 기능 하나를 위해 이러한 라이브러리를 사용하면 번들 사이즈가 커진다는 부담이 있습니다.

그렇다면 라이브러리를 사용하지 않고 우리가 원하는대로 작동하게 하려면 어떻게 해야할까요?

❎ setTimeout

여기서 쉽게 저지르는 실수 중 하나는 setTimeout을 사용하는 건데요.

setTimeout을 활용한 unmount
function DelayUnmount({unmount, onClose}: {unmount: boolean, onClose: () => void)}) => {
  
  useEffect(() => {
    let id: NodeJS.Timeout;
 
    if (unmount) {
      id = setTimeout(() => {
        onClose()
      }, 100);
    }
 
    return () => clearTimeout(id)
  }, [unmount])
  
  return  <div className="duration-150 fade-out-animation"></div>
}

위에 보이는 것처럼 언마운트 싱크를 맞추기 위해 150ms100ms를 사용합니다. 그러면 모든 게 잘 작동할까요? 아쉽게도 아닙니다.

setTimeout을 사용한 언마운트 처리의 주요 문제점들을 살펴보겠습니다.

1. 타이밍 정확도 문제

타이밍과 관련해서 브라우저를 먼저 살펴보겠습니다. 브라우저는 초당 60프레임을 목표로 동작합니다. 이는 각 프레임당 약 16.67ms(권장 3ms) 안에 렌더링이 완료되어야 사용자에게 끊김 없는 경험을 제공할 수 있습니다.

하지만 브라우저의 이벤트 루프는 콜 스택 -> 마이크로태스크 큐 -> 매크로태스크 큐 순으로 작업을 처리합니다. 이는 매크로태스크 큐에서 처리되는 setTimeout이 다른 실행 중인 코드나 브라우저의 성능에 따라 지정된 시간에 정확히 실행되지 않을 수 있음을 의미합니다.

2. 코드 유지보수의 어려움

애니메이션의 시간을 수정할 때마다 setTimeout의 딜레이도 함께 수정해야 하는데 이는 코드 유지보수 시 문제를 야기할 수 있습니다.

우선 다른 개발자가 코드를 검토할 때 이러한 연관성을 놓치기 쉽고 이로 인해 의도하지 않은 동작이 발생할 수 있습니다. 특히 코드가 복잡해질수록 이러한 커플링 로직은 유지보수를 더욱 어렵게 만듭니다.

3. 제어권

위의 상황들을 해결하기 위해 개발자가 정교하게 타이밍을 설정하고 상태 관리를 아무리 잘 하더라도 언마운트에 대한 제어권은 결국 브라우저에게 있습니다.

특히 복잡한 애니메이션이나 상태 전환이 필요한 경우 이러한 제어권 문제로 인해 애니메이션이 부자연스럽게 끊기거나 상태 업데이트가 제대로 반영되지 않는 등 메모리 누수가 발생할 수 있습니다.

따라서 브라우저의 이벤트 루프와 별개로 개발자가 의도한 시점에 컴포넌트의 마운트/언마운트를 제어할 수 있는 방법이 필요합니다.

이를 위해 Radix UI는 해당 문제를 어떻게 해결했는지 코드를 분석해보도록 하겠습니다.

✅ Presence Component(제어권)

Presence.tsx
/* --- 생략 --- */
interface PresenceProps {
  children: React.ReactElement | ((props: { present: boolean }) => React.ReactElement);
  present: boolean;
}
 
const Presence: React.FC<PresenceProps> = (props) => {
  const { present, children } = props;
  const presence = usePresence(present);
 
  const child = (
    typeof children === "function"
      ? children({ present: presence.isPresent })
      : React.Children.only(children)
  ) as React.ReactElement;
 
  const ref = useComposedRefs(presence.ref, getElementRef(child));
  const forceMount = typeof children === "function";
  return forceMount || presence.isPresent ? React.cloneElement(child, { ref }) : null;
};
 
Presence.displayName = "Presence";

우선 return문을 보면 forceMount 혹은 presence가 존재하면 자식 컴포넌트를 복사하는 것을 확인할 수 있습니다. 이를 통해서 우리는 Presence 컴포넌트가 자식 컴포넌트의 마운트/언마운트를 관리하는 래퍼 함수임을 알 수 있습니다.

이제 제어권이 브라우저에서 컴포넌트로 넘어왔습니다.

제어권을 넘겨 받았으니 마운트/언마운트에 대한 상태관리가 필요합니다. 어느 시점에 마운트하고 언마운트 할 것인지 usePresence 훅을 살펴 보겠습니다.

usePresence.ts
function usePresence(present: boolean) {
  const [node, setNode] = React.useState<HTMLElement>();
  const stylesRef = React.useRef<CSSStyleDeclaration>({} as any);
  const prevPresentRef = React.useRef(present);
  const prevAnimationNameRef = React.useRef<string>("none");
  const initialState = present ? "mounted" : "unmounted";
  const [state, send] = useStateMachine(initialState, {
    mounted: {
      UNMOUNT: "unmounted",
      ANIMATION_OUT: "unmountSuspended",
    },
    unmountSuspended: {
      MOUNT: "mounted",
      ANIMATION_END: "unmounted",
    },
    unmounted: {
      MOUNT: "mounted",
    },
  });
 
  /* --- 생략  --- */
 
  return {
    isPresent: ["mounted", "unmountSuspended"].includes(state),
    ref: React.useCallback((node: HTMLElement) => {
      if (node) stylesRef.current = getComputedStyle(node);
      setNode(node);
    }, []),
  };
}

24번째 줄을 보면 useStateMachine에서 반환한 상태가 mounted나 unmountSuspended 상태인지 확인합니다.

여기서 주목할 점은 unmountSuspended 상태입니다.

이 상태는 컴포넌트가 제거되어야 하지만, 아직 애니메이션이 완료되지 않아 잠시 언마운트를 미루고 있는 상태를 의미합니다.

그러면 이제 어느 상황에서 상태를 지연하거나 언마운트 혹은 마운트할 것인지 상태 관리를 하면 됩니다.

우선 상태 관리를 어떻게 구현했는지 확인해 보겠습니다.

✅ useStateMachine with Typescript(정확도)

코드는 간단하지만 타입이 상당히 어렵습니다.

useStateMachine.ts
import * as React from "react";
 
type Machine<S> = { [k: string]: { [k: string]: S } };
type MachineState<T> = keyof T;
type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;
 
// 🤯 https://fettblog.eu/typescript-union-to-intersection/
type UnionToIntersection<T> = (T extends unknown ? (x: T) => unknown : never) extends (
  x: infer R
) => unknown
  ? R
  : never;
 
export function useStateMachine<M>(
  initialState: MachineState<M>,
  machine: M & Machine<MachineState<M>>
) {
  return React.useReducer((state: MachineState<M>, event: MachineEvent<M>): MachineState<M> => {
    const nextState = (machine[state] as any)[event];
    return nextState ?? state;
  }, initialState);
}
 

위에서 확인해야 할 부분은 machin이 어떤 구조를 가지고 이를 타입으로 어떻게 보장하는가입니다.

우선 machine: M & Machine<MachineState<M>>을 파악하겠습니다.

MachineState<M>은 M 타입 객체의 키를 반환합니다.

따라서 Machine<MachineState<M>>는 다음과 같습니다.

type Machine<MachineState<M>> = { [k: string]: { [k: string]:  keyof M } };

이제 M &으로 인해 객체의 최상위 키와 최하위 밸류가 서로 같아야 합니다.

M & Machine<MachineState<M>>
{
  mounted: {
    UNMOUNT: "unmounted",
    ANIMATION_OUT: "unmountSuspended"
  }
  unmountSuspended: {
    MOUNT: "mounted",
    ANIMATION_END: "unmounted"
  }
  unmounted: {
    MOUNT : "mounted"
  }
}

이제 machin 구조가 보장되었습니다.

event 타입을 확인하기 전에 왜 이런 구조를 가졌는지 확인해보겠습니다.

만약 statemounted로 들어올 경우 UNMOUNT, ANIMATION_OUT을 통해 다음 렌더링에 대한 상태 관리가 가능해집니다.

이를 통해서 우리는 setTimeout으로 관리하던 불안정한 방식에서 벗어나 리액트 렌더링 사이클 안에서 어느 상황에 대한 대처가 가능해집니다.

이제 event 타입을 확인해 보겠습니다.

event
type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;
 
// 🤯 https://fettblog.eu/typescript-union-to-intersection/
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any
  ? R
  : never;

우선 keyof UnionToIntersection<T[keyof T]>의 결과를 확인하기 위해 UnionType을 사용해 보겠습니다.

type UnionType<T> = T[keyof T]
 /* type E = UnionType<typeof M & Machine<MachineState<M>>> */
type E = {
    UNMOUNT: string;
    ANIMATION_OUT: string;
} | {
    MOUNT: string;
    ANIMATION_END: string;
} | {
    MOUNT: string;
}
 
type T = keyof E // never

위와 같이 유니온 타입은 공통의 키가 없을 경우 never 타입을 반환하기 때문에 교차 타입으로 만들어 타입을 보장해줘야 합니다.

UnionToIntersection에 대한 상세한 설명은 블로그type-challenges를 참고해주세요.

UnionToIntersection
type I = UnionToIntersection<'foo' | 42 | true> // expected to be 'foo' & 42 & true

교차 타입으로 키 타입을 반환할 경우 우리가 원하는 상황, 즉 애니메이션 상태를 얻을 수 있습니다.

MachineEvent<M>
type event = "UNMOUNT" | "ANIMATION_OUT" | "MOUNT" | "ANIMATION_END"

이제 우리가 원하는 상황과 타입을 보장 받았으니 결과를 반환하기만 하면 됩니다.

reducer를 통한 상태 반환
  return React.useReducer((state: MachineState<M>, event: MachineEvent<M>): MachineState<M> => {
    const nextState = (machine[state] as any)[event];
    return nextState ?? state;
  }, initialState);

✅ 상태 업데이트(유지보수)

setTimeout을 활용할 경우 유지보수가 어려운 점을 앞서 확인했습니다. (커플링, 코드 복잡도에 의한 가독성 저하)

하지만 제어권과 정확도를 커스텀 컴포넌트로 분리함으로써 개발자는 필요한 부분만 신경을 쓰면 되기 때문에 유지보수가 더욱 편리해졌습니다.

여기서는 useEffectuseLayoutEffect의 의존성 배열을 기준으로 상태 업데이트를 어떻게 하는지 확인해 보겠습니다.

[state]
  const stylesRef = React.useRef<CSSStyleDeclaration>({} as any);
  const prevAnimationNameRef = React.useRef<string>("none");
  /* --- 생략 --- */
  React.useEffect(() => {
    const currentAnimationName = getAnimationName(stylesRef.current);
    prevAnimationNameRef.current = state === "mounted" ? currentAnimationName : "none";
  }, [state]);

요소가 처음 마운트되거나 다시 마운트될 경우 해당 시점의 애니메이션 이름을 저장하는 걸 확인할 수 있습니다.

이는 언마운트 시 애니메이션 변화를 감지하는 기준으로 사용되기 때문입니다.

[present, send]
useLayoutEffect(() => {
  /* --- 생략 --- */
  const prevAnimationName = prevAnimationNameRef.current;
  const currentAnimationName = getAnimationName(styles);
  
  const isAnimating = prevAnimationName !== currentAnimationName;
  /* --- 생략 --- */
}, [present, send]);

이후 조건문들을 통해 상황에 맞게 machin event로 렌더링 상태를 업데이트합니다.

[present, send]
useLayoutEffect(() => {
  const styles = stylesRef.current;
  const wasPresent = prevPresentRef.current;
  const hasPresentChanged = wasPresent !== present;
 
  if (hasPresentChanged) {
    const prevAnimationName = prevAnimationNameRef.current;
    const currentAnimationName = getAnimationName(styles);
 
    if (present) {
      send("MOUNT");
    } else if (currentAnimationName === "none" || styles?.display === "none") {
      // If there is no exit animation or the element is hidden, animations won't run
      // so we unmount instantly
      send("UNMOUNT");
    } else {
      /**
       * When `present` changes to `false`, we check changes to animation-name to
       * determine whether an animation has started. We chose this approach (reading
       * computed styles) because there is no `animationrun` event and `animationstart`
       * fires after `animation-delay` has expired which would be too late.
       */
      const isAnimating = prevAnimationName !== currentAnimationName;
 
      if (wasPresent && isAnimating) {
        send("ANIMATION_OUT");
      } else {
        send("UNMOUNT");
      }
    }
 
    prevPresentRef.current = present;
  }
}, [present, send]);

DOM 노드에 대한 useLayoutEffect는 다음과 같습니다.

[node, send]
const [node, setNode] = React.useState<HTMLElement>();
 
useLayoutEffect(() => {
  if (node) {
    const handleAnimationEnd = (event: AnimationEvent) => {
      const currentAnimationName = getAnimationName(stylesRef.current);
      const isCurrentAnimation = currentAnimationName.includes(event.animationName);
    if (event.target === node && isCurrentAnimation) {
        // With React 18 concurrency this update is applied
        // a frame after the animation ends, creating a flash of visible content.
        // By manually flushing we ensure they sync within a frame, removing the flash.
        ReactDOM.flushSync(() => send("ANIMATION_END"));
      }
    };
    const handleAnimationStart = (event: AnimationEvent) => {
      if (event.target === node) {
        prevAnimationNameRef.current = getAnimationName(stylesRef.current);
      }
    };
    // ... 이벤트 리스너 등록 및 제거
  }
}, [node, send]);

여기서는 React의 동시성 문제로 인한 상태 업데이트 지연을 방지하기 위해 flushSync를 사용합니다.

이외의 상태 업데이트는 이벤트 리스너에게 위임하여 애니메이션의 시작과 종료를 처리합니다.

각 의존성 배열을 기준으로 종합해보면 다음과 같습니다.

[state] 의존성

[present, send] 의존성

[node, send] 의존성

나가며

리액트의 언마운트 애니메이션을 위해 setTimeout을 사용하면 당장은 간편할 수 있습니다.

혹은 requestAnimationFrame을 사용하면 되는 거 아니야?라고 생각할 수도 있습니다.

하지만 제어권, 정확도, 유지보수 측면에서 보면 그렇지 않습니다.

이러한 문제점들을 해결하기 위해 우리는 CSS 애니메이션과 상태 머신을 결합한 접근 방식을 살펴보았습니다.

끝으로 사용 예시를 확인하며 글을 마치겠습니다.

PresenceExample.tsx
export default function PresenceExample() {
  const [open, setOpen] = useState(false);
 
  return (
    <div>
      <button onClick={() => setOpen(true)}>open</button>
      <Presence present={open}>
        <div
          data-state={open ? "open" : "closed"}
          className="overlay"
          onClick={() => setOpen(false)}
        />
      </Presence>
    </div>
  );
}
 
styles.css
.overlay {
  position: fixed;
  inset: 0;
  background: blue;
  animation: fadeIn 0.5s;
 
  &[data-state="closed"] {
    animation: fadeOut 0.5s;
  }
}