(오픈소스 분석) Radix Presence

·

6 min read

@radix-ui/react-presence 라이브러리 코드를 살펴본다.

용도

리액트 컴포넌트가 unmount될 때 애니메이션을 재생하려면 unmount되는 시점을 제어할 수 있어야 한다. Presence컴포넌트는 해당 기능을 구현하여 unmount 애니메이션을 쉽게 추가할 수 있다.

사용 예시

const Component = () => {
  const [present, setPresent] = useState(true);
  return (
    <>
      <Button onClick={() => setPresent(!present)}>Toggle</Button>
      <Presence present={present}>
        <div 
          className="component"
          data-state={present ? "open" : "closed"}
        >
          ABC
        </div>
      </Presence>
    </>
  )
}

/* css */
`
.component { 
  &[data-state="open"] {
    animation: ...;
  }

  &[data-state="closed"] {
    animation: ...;
  }
}
`

토글 버튼을 누르면 present값이 false로 바뀌는데, 만약 child컴포넌트가 애니메이션 스타일을 갖고 있다면 해당 애니메이션이 종료된 이후 child컴포넌트를 언마운트시키고, 애니메이션 스타일이 없다면 즉시 언마운트시킨다.

State machine

Presence 컴포넌트는 내부적으로 상태 머신 훅을 사용하여 3가지 상태를 관리한다.

상태 머신 훅에는 타입이 세개 있는데, 하나씩 살펴보자.

  1. Machine<S>

Machine타입의 객체는 다음과 같이 상태를 키로 갖는 객체이다. 그리고 상태에 매핑된 값은 이벤트를 키로 갖는 객체이다.

type Machine<S> = { [k: string]: { [k: string]: S } };

{
  상태1: {
    이벤트1: 상태2,
    이벤트2: 상태3,
  },
  상태2: {
    이벤트3: 상태3,
  },
  상태3: {
    이벤트4: 상태1,
  }
}

상태[이벤트]는 해당 상태에서 이벤트(브라우저 이벤트가 아니라, setState에 전달되는 인자를 의미한다.)가 발생한 경우를 나타내며, 이 경우엔 매핑되어있는 상태로 이동한다. 제네릭인 S는 이벤트를 통해 변경될 수 있는 상태를 나타낸다.

  1. MachineState<T>

객체의 키를 의미한다.

type MachineState<T> = keyof T;
  1. MachineEvent<T>

T객체의 프로퍼티 값을 &처리한 후, 그 객체타입의 키를 의미한다.

type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;

예시

{
  mounted: { 
    UNMOUNT: 'unmounted',
    ANIMATION_OUT: 'unmountSuspended',
  },
  unmountSuspended: {
    MOUNT: 'mounted',
    ANIMATION_END: 'unmounted',
  },
  unmounted: {
    MOUNT: 'mounted',
  },
}

// 1. 위 객체의 프로퍼티 값을 | 처리 (T[keyof T]의 의미)
type A = 
 | { UNMOUNT: 'unmounted', ANIMATION_OUT: 'unmountSuspended' } 
 | { MOUNT: 'mounted', ANIMATION_END: 'unmounted' } 
 | { MOUNT: 'mounted' }

// 2. 유니온을 인터섹션으로 변경
type B = 
 & { UNMOUNT: 'unmounted', ANIMATION_OUT: 'unmountSuspended' } 
 & { MOUNT: 'mounted', ANIMATION_END: 'unmounted' } 
 & { MOUNT: 'mounted' }

// 3. 키 값만 추출
type C = 
  'UNMOUNT' | 'ANIMATION_OUT' | 'MOUNT' | 'ANIMATION_END'

사용방법은 다음과 같다.

const [state, send] = useStateMachine("mounted", {
  mounted: { 
    UNMOUNT: 'unmounted',
    ANIMATION_OUT: 'unmountSuspended',
  },
  unmountSuspended: {
    MOUNT: 'mounted',
    ANIMATION_END: 'unmounted',
  },
  unmounted: {
    MOUNT: 'mounted',
  },
});

send("UNMOUNT"); // -> unmounted
send("MOUNT"); // -> mounted
send("ANIMATION_OUT"); // -> unmountSuspended
send("ANIMATION_END"); // -> unmounted

// 만약 `send`의 인자로 올바르지 않은 키를 전달하면 초깃값이 된다.
send("UNKNOWN_EVENT"); // -> mounted (= initial state)

usePresence

usePresence{ isPresent, ref }를 반환하는 커스텀 훅이고, 컴포넌트가 mount -> unmount되는 상황에서, unmount애니메이션을 보여주기 위해 실제 unmount를 animation이 종료된 이후로 미루는 용도로 사용한다.

const { isPresent, ref } = usePresence(present);

여기서 반환된 ref를 Presence의 child에 전달하는 방식으로 Presence가 동작한다. 따라서 Presence의 child는 무조건 ref를 받을 수 있는, forwardRef로 감싼 컴포넌트여야 한다.

const Presence = (props) => {
    const { present, children } = props;
    const presence = usePresence(present);
    // child를 render prop으로 사용할 수도 있고
    // ReactElement로 사용할 수도 있다.
    const child = (
        typeof children === 'function'
            ? children({ present: presence.isPresent })
            : React.Children.only(children)
    );

    // presence.ref는 usePresence에서 반환한 ref
    // child.ref는 child의 prop으로 전달된 ref이다.
    const ref = useComposedRefs(presence.ref, child.ref)

    // render prop을 사용하는 경우 present값에 따른 엘리먼트의 mount여부를 consumer가 제어한다.
    // 그렇지 않으면 mount는 Presence컴포넌트가 알아서 제어한다.
    const forceMount = typeof children === 'function';
    return (forceMount || presence.isPresent) 
        ? React.cloneElement(child, { ref })
        : null;
}

여기서 props.presentusePresence().isPresent의 차이를 알아야 하는데, 전자는 consumer가 조작할 수 있는 상태이며 이 상태는 컴포넌트의 mount 혹은 unmount상태와 동일하지 않다. 후자는 실제 컴포넌트를 mount 또는 unmount시킬지 결정한다.

예를들어 consumer가 props.presenttrue에서 false로 바꾼경우, 재생할 애니메이션이 있다면 isPresent값은 여전히 true이다. 해당 애니메이션이 종료된 후 isPresent값이 false가 되어 그제서야 child 컴포넌트가 unmount된다.

구현 세부사항

usePresence는 3개의 Effect함수를 가지고, ref callback을 반환한다.

const usePresence = () => {
  const [node, setNode] = useState();
  const [state, send] = useStateMachine(...);
  const stylesRef = useRef();
  const [state, send] = useStateMachine(initialState, {
    mounted: {
      UNMOUNT: 'unmounted',
      ANIMATION_OUT: 'unmountSuspended',
    },
    unmountSuspended: {
      MOUNT: 'mounted',
      ANIMATION_END: 'unmounted',
    },
    unmounted: {
      MOUNT: 'mounted',
    },
  });
  // 4
  useEffect(() => {}, [state]);

  // 2
  useLayoutEffect(() => {}, [present]);

  // 3
  useLayoutEffect(() => {}, [node]);

  return {
    // `isPresent`는 실제 mount 상태를 의미한다.
    // `unmountSuspended`는 unmount 애니메이션을 재생중인 상태이므로
    // 이 때 `isPresent`상태는 `true`여야 한다.
    isPresent: isPresent: ['mount', 'unmountSuspended'].includes(state),
    ref: () => {
      // 1
      if (node) stylesRef.current = getComputedStyle(node);
      setNode(node);
    }
  }
}

주석 1, 2, 3, 4로 실행 순서를 표시했다. 리액트의 라이프사이클에서 실행 순서는 ref callback -> layout effect -> effect로 정해져 있고, ref callback에서 이미 getComputedStyle로 스타일 객체를 stylesRef에 동기화하기 때문에, effect나 layout effect에선 항상 스타일 객체에 접근할 수 있다.

  1. 첫번째 layout effect

present가 바뀔 때 마다 호출되는 함수이다. present는 위에서 언급했듯 consumer가 제어하는 상태이다. 다음 코드로 present가 바뀌었는지를 판단하여, mount상태에서 unmount로 가려는건지, 그 반대인지에 따라 다른 로직을 수행한다.

const prevPresentRef = useRef(props.present);

useLayoutEffect(() => {
  const wasPresent = prevPresentRef.current;
  const hasPresentChanged = wasPresent !== present;

  if (hasPresentChanged) {
      ...
      // present가 바뀐 경우만 실행되고, 아닌 경우 즉시 return한다.
      // 마지막에는 이전 present값을 업데이트한다.
      prevPresentRef.current = present;
  }
}, [present]);

present가 바뀌었다면, true -> false이거나, false -> true이거나 둘 중 하나다.

const wasPresent = prevPresentRef.current;

// send는 상태머신 훅에서 반환된 상태를 업데이트하는 함수다.
if (hasPresentChanged) {
    // false -> true인 경우 `mounted`로 바꾼다.
    if (present) {
      send("MOUNT");
    } else if (currentAnimationName === 'none' || styles?.display === 'none') {
      // true -> false인 경우일지라도
      // 애니메이션이 없거나 DOM에 보이지 않는 경우엔 
      // 즉시 unmount로 바꾼다.
      send("UNMOUNT");
    } else {
        // true -> false이고, 애니메이션이 있는 경우
      // unmount 애니메이션이 있다면
      const isAnimating = prevAnimationName !== currentAnimationName;

      if (wasPresent && isAnimating) {
        send("ANIMATION_OUT");
      } else {
        send("UNMOUNT");
      }
    }
}

true -> false이고, 애니메이션이 있는 조건문의 경우, mount할 때만 애니메이션이 있고 unmount애니메이션은 없는 경우엔 isAnimatingfalse가 될 수 있다. 이 경우에도 즉시 unmount하면 되기 때문에 내부적으로 분기처리를 한번 더 해준다.

추가적으로 animation이 변경되었는지를 animationstart이벤트에서 감지할 수도 있는데도 styles.animationName을 확인하여 직접 비교하는 이유는 animation-delay가 적용된 경우 animationstart이벤트가 delay이후로 미뤄지기 때문이라고 한다.

  1. 두번째 layout effect

Presence의 child컴포넌트에 애니메이션 이벤트를 등록하는 블럭이다.

useLayoutEffect(() => {
  // node는 child 엘리먼트를 가리킨다.
  if (node) {
    const handleAnimationStart = (event) => {
      // prevAnimationNameRef 업데이트
      if (event.target === node) {
        // if animation occurred, store its name as the previous animation.
        prevAnimationNameRef.current = getAnimationName(stylesRef.current);
      }
    }
    const handleAnimationEnd = (event) => {
      // 애니메이션이 종료되면 unmount로 상태 업데이트
      const currentAnimation = getAnimationName(stylesRef.current);
      const isCurrentAnimation = currentAnimationName.includes(event.animationName);
      if (event.target === node && isCurrentAnimation) {
        // react 18 cuncurrency때문에 업데이트가
        // animation 종료 후 다음 프레임에 반영된다. 
        // 이게 flash현상을 만들기 때문에, 
        // 업데이트 된 상태를 즉시 동기화시킨다.
        ReactDOM.flushSync(() => send('ANIMATION_END'));
      }
    }
    node.addEventListener('animationstart', handleAnimationStart);
    node.addEventListener('animationcancel', handleAnimationEnd);
    node.addEventListener('animationend', handleAnimationEnd);
    return () => {
      node.removeEventListener('animationstart', handleAnimationStart);
      node.removeEventListener('animationcancel', handleAnimationEnd);
      node.removeEventListener('animationend', handleAnimationEnd);
    };
  } else {
    // 노드가 제거될 때 unmount상태로 변경한다.
    // 클린업 함수에서 수행하는 경우엔 node가 바뀔 때 마다 호출되기 때문에
    // 아예 DOM에서 제거되었을 때만 호출하기 위해 여기서 업데이트한다.
    send('ANIMATION_END');
  }
}, [node])
  1. Effect

prevAnimationNameRef를 업데이트한다.

useEffect(() => {
  const currentAnimationName = getAnimationName(stylesRef.current);
  prevAnimationNameRef.current = state === 'mounted' ? currentAnimationName : 'none';
}, [state]);

애니메이션 재생중에 mount 상태 바꾸기

mount animation 재생중에 unmount로 바꾸거나, unmount animation을 재생중일 때 mount로 바꾸거나 둘 다 기존 애니메이션을 종료하고 즉시 다음 애니메이션을 재생한다. 스토리북 데브서버에서 직접 확인해볼 수 있는 예제가 여러개 있다.