본문 바로가기

카테고리 없음

Radix-ui 의 Slot 컴포넌트 사용 시 주의할 점

반응형

 

출처 : https://www.google.com/imgres?q=radix%20ui%20slot&imgurl=https%3A%2F%2Fradix-ui.com%2Fsocial%2Fprimitives.png&imgrefurl=https%3A%2F%2Fwww.radix-ui.com%2Fprimitives%2Fdocs%2Futilities%2Fslot&docid=1kXFYgA36Ob69M&tbnid=9_Tapagv421nEM&vet=12ahUKEwjLjoqdzK-MAxUqka8BHdCEPDIQM3oECBcQAA..i&w=3025&h=1700&hcb=2&ved=2ahUKEwjLjoqdzK-MAxUqka8BHdCEPDIQM3oECBcQAA#imgrc=9_Tapagv421nEM&imgdii=90XSRGtjyKqHyM

 

 

서론

 

 

사내 프로젝트에서도 그렇고 여러가지 공통 컴포넌트를 만들때 요새 자주 사용하게 되는 패턴 중 하나는 Radix-ui의 Slot 컴포넌트를 활용하는 방식이다. 이 컴포넌트를 활용하는 과정에서 예상치못한 이슈를 마주한 경험이 있어 이에 대해 다시한번 정리하고, 문제 및 해결 방안을 정리해보려 한다.

 

 

 

본론

 

 

아는 사람은 알겠지만 이 Slot 컴포넌트의 역할 자체는 렌더링을 위임할 수 있는 컴포넌트이다. 

 

위임한다는 것은 즉, 특정 역할을 가질 수 있도록 컴포넌트를 설계하고 (style, event 등등), children으로 들어오는 컴포넌트에 해당 역할을 그대로 부여해주는 것이다.

 

코드를 빼놓고 설명하기는 어려우니 빠르게 코드를 살펴보고 문제 상황에 대해 알아보겠다.

 

(원문 : https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/slot.tsx )

 

/* -------------------------------------------------------------------------------------------------
 * SlotClone
 * -----------------------------------------------------------------------------------------------*/

interface SlotCloneProps {
  children: React.ReactNode;
}

const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props;

  if (React.isValidElement(children)) {
    const childrenRef = getElementRef(children);
    const props = mergeProps(slotProps, children.props as AnyProps);
    // do not pass ref to React.Fragment for React 19 compatibility
    if (children.type !== React.Fragment) {
      props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
    }
    return React.cloneElement(children, props);
  }

  return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});

SlotClone.displayName = 'SlotClone';

/* -------------------------------------------------------------------------------------------------
 * Slottable
 * -----------------------------------------------------------------------------------------------*/

const Slottable = ({ children }: { children: React.ReactNode }) => {
  return <>{children}</>;
};

/* ---------------------------------------------------------------------------------------------- */

type AnyProps = Record<string, any>;

function isSlottable(
  child: React.ReactNode
): child is React.ReactElement<React.ComponentProps<typeof Slottable>, typeof Slottable> {
  return React.isValidElement(child) && child.type === Slottable;
}

 

 

 

메인 구현체인 Slot 을 보기 전 이를 구성하는 SlotClone (역할 자체는 Slot이나 외부로 노출할 실제 컴포넌트를 따로 두었기때문에 코어가 되는 역할을 수행하는 컴포넌트를 이렇게 정의한 것으로 보인다.) 를 먼저 보자.

 

SlotClone을 보면 getElementRef를 통해 children의 ref를 추출하고, React의 cloneElement를 통해 children을 복제해 해당 컴포넌트(SlotClone)에 부여받은 props(style, event, ref 등등)을 머지해서 복제한 컴포넌트에 부여해주는 것이다.

 

즉, 직전에 위에서 설명한 렌더링 위임을 직접적으로 수행하는 컴포넌트이다.

 

mergeProps는 아래와 같지만 말 그대로 인자로 들어오는 두 Props를 안전하게 병합해주는 역할을 담당하는 추상체인데 이 글에서 중요한 내용은 아니기 때문에 코드만 남기고 넘어가겠다.

 

function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
  // all child props should override
  const overrideProps = { ...childProps };

  for (const propName in childProps) {
    const slotPropValue = slotProps[propName];
    const childPropValue = childProps[propName];

    const isHandler = /^on[A-Z]/.test(propName);
    if (isHandler) {
      // if the handler exists on both, we compose them
      if (slotPropValue && childPropValue) {
        overrideProps[propName] = (...args: unknown[]) => {
          childPropValue(...args);
          slotPropValue(...args);
        };
      }
      // but if it exists only on the slot, we use only this one
      else if (slotPropValue) {
        overrideProps[propName] = slotPropValue;
      }
    }
    // if it's `style`, we merge them
    else if (propName === 'style') {
      overrideProps[propName] = { ...slotPropValue, ...childPropValue };
    } else if (propName === 'className') {
      overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
    }
  }

  return { ...slotProps, ...overrideProps };
}

function getElementRef(element: React.ReactElement) {
  // React <=18 in DEV
  let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get;
  let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning;
  if (mayWarn) {
    return (element as any).ref;
  }

  // React 19 in DEV
  getter = Object.getOwnPropertyDescriptor(element, 'ref')?.get;
  mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning;
  if (mayWarn) {
    return (element.props as { ref?: React.Ref<unknown> }).ref;
  }

  // Not DEV
  return (element.props as { ref?: React.Ref<unknown> }).ref || (element as any).ref;
}

 

 

이제 SlotClone을 실제로 활용하는 Slot 컴포넌트를 확인해보자.

 

import * as React from 'react';
import { composeRefs } from '@radix-ui/react-compose-refs';

/* -------------------------------------------------------------------------------------------------
 * Slot
 * -----------------------------------------------------------------------------------------------*/

interface SlotProps extends React.HTMLAttributes<HTMLElement> {
  children?: React.ReactNode;
}

const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props;
  const childrenArray = React.Children.toArray(children);
  const slottable = childrenArray.find(isSlottable);

  if (slottable) {
    // the new element to render is the one passed as a child of `Slottable`
    const newElement = slottable.props.children;

    const newChildren = childrenArray.map((child) => {
      if (child === slottable) {
        // because the new element will be the one rendered, we are only interested
        // in grabbing its children (`newElement.props.children`)
        if (React.Children.count(newElement) > 1) return React.Children.only(null);
        return React.isValidElement(newElement)
          ? (newElement.props as { children: React.ReactNode }).children
          : null;
      } else {
        return child;
      }
    });

    return (
      <SlotClone {...slotProps} ref={forwardedRef}>
        {React.isValidElement(newElement)
          ? React.cloneElement(newElement, undefined, newChildren)
          : null}
      </SlotClone>
    );
  }

  return (
    <SlotClone {...slotProps} ref={forwardedRef}>
      {children}
    </SlotClone>
  );
});

Slot.displayName = 'Slot';

 

 

이 부분도 사실은 이 글에서는 크게 중요하지 않다.

 

Slot은 본인이 가진 역할 중 Slottable이라는 컴포넌트 요소를 children array에 가지고있을 경우 그 요소에게 렌더링을 위임할 수 있도록 설계해두어 만약 slottable한 요소가 있는 경우에는 그 컴포넌트에게 부모의 props을 머지해주고, 아니라면 children에게 그대로 위임해주는 역할을 하는 것이다.

 

거듭 이야기하지만 해당 글에서는 Slot에 대한 해체분석이 목적이 아니기 때문에 가장 중요한 역할을 담당하는 SlotClone만 설명했고, 나머지는 활용할 수 있는 정도로만 이해하고 넘어가겠다. 만약 정확한 역할과 배경이 궁금하다면 너무나 잘 설명된 글이 있어 이 글을 참조해보면 좋을것같다. (https://kciter.so/posts/render-delegation-react-component/)

 

 

 

그렇다면 실제 프로젝트에서 어떤 부분들이 문제가 되었는지 보겠다.

 

 

이러한 Slot(실제로는 SlotClone이 담당했던 역할이지만 우리는 추상화된 Slot을 사용하기때문에 지금부터는 Slot이라고 표현하겠다.)의 역할을 잘 생각해보면, Slot을 활용하는 컴포넌트에 어떠한 역할을 부여하면, 하위 컴포넌트에 이 역할을 씌울 수 있는 선언적인 컴포넌트를 만들기에 아주 유용하다.

 

 

간단하게 예를 들어보자.

 

 

const LogImpression = ({ children }: PropsWithChildren) => {
  const observerRef = useIntersectionObserver({
    onIntersectStart: () => {
      doSendLog()
    }
  })  
  
  return <Slot ref={observerRef>{children}</Slot>
}

 

 

위의 예시는 실무에서 꽤 자주 활용될 수 있는 컴포넌트 패턴 중 하나인데, Impression Log를 남길 수 있는 컴포넌트를 만든다고 하자.

 

최근에 이러한 Logging 컴포넌트는 일반적으로 비즈니스 로직에 결합되는 형태를 지양하기 위해 선언적 컴포넌트로 추상화하여 로그를 남기고자 하는 대상 컴포넌트에 감싸기만하면 해당 역할을 할 수 있도록 분리하는데에 자주 사용된다.

 

이런 상황에서 React.cloneElement를 사용해 children를 복제하고 해당 컴포넌트에 직접 ref를 부여해줄 수도 있지만, Radix-ui의 Slot컴포넌트를 사용한다면 ref뿐 아니라 다른 props까지 전부 merge해주기 때문에 기존 방식보다 깔끔하고 완벽하게 이를 수행해줄 수 있는 것이다. 

 

필자의 경우도 사내프로젝트를 진행하며 이런 컴포넌트 설계 방식에 매력을 느꼈고, 굉장히 좋은 방식이라고 판단하여 필요한 상황에서는 자주 활용하고자 했었다.

 

다만 여기서 한가지 문제가 발생했다.

 

 

Slot에 넣어주는 ref를 만들어주는 특정 훅 내에서 기존에는 없던 변화가 생긴 것인데, 기존에는 해당 훅에서 관리하는 상태를 Ref로만 관리했고 이것만으로도 충분했다. 하지만 특정 요구사항으로 인해 훅 내에서 상태(state)를 가지게 되었고, 그 이후부터 문제가 발생한 것이다.

 

 

어디서 문제가 발생했을까?

 

 

 

문제 파악

 

 

문제가 어디인지 찾아내기 위해서는 변경된 점을 기준으로 봐야 가장 빨리 찾을 수 있다.

 

훅에서 ref만 가지고있다가 상태를 가지게 되었다는 것은 결국 그 상태가 변경되는 시점이 오고 이를 통해 re-render가 일어날 수 있는 것이다.

 

 

그러면 상태가 변경되어 re-render가 일어났을 때 영향을 받을만한 부분을 찾아보자.

 

 

 

const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props;

  if (React.isValidElement(children)) {
    const childrenRef = getElementRef(children);
    const props = mergeProps(slotProps, children.props as AnyProps);
    // do not pass ref to React.Fragment for React 19 compatibility
    if (children.type !== React.Fragment) {
      props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
    }
    return React.cloneElement(children, props);
  }

  return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});

 

 

 

이 코드는 아까 언급했던 렌더링 위임을 담당하는 SlotClone 컴포넌트이다.

 

여기를 자세히보면 두 Props를 merge해주는 mergeProps가 존재하는데, 이는 따로 메모이제이션이 되어있지 않은 일반 객체를 반환하는 함수이다.

 

여기서 만약 re-render가 일어나면 어떻게될까? 과정은 다음과 같다.

 

 

  1. 최초 렌더, 즉 마운트 시에 merge된 props를 가진 복제 Element가 생성
  2. 이후 특정 훅으로 인해 re-render 발생
  3. re-render로 인해 SlotClone의 내부 로직도 다시 실행된다.
  4. 이때 다시 계산된 머지된 Props를 가진 복제 Element 반환
  5. 기존에 있던 복제 Element의 ref와 새로 만들어진 복제 Element의 ref를 비교
  6. 일반 객체이기때문에 우리가 보는 값은 같아도 실제 참조값이 다르기때문에 다른 ref로 인지
  7. Element가 변경된 것으로 인지하여 새로운 Element를 반환 및 훅을 연결

 

 

 

위와 같은 과정이 일어나게된다.

 

 

그렇다면 예시로 제시한 LogImpression에서 이런 상황이 발생했다면 어떻게 될까?

 

 

LogImpression은 특정 영역이 뷰포트에 나타나는 순간 onIntersectionStart에 담아둔 이벤트를 실행해준다.

 

최초에 컴포넌트가 렌더링되었고 뷰포트에 해당 영역이 나타나는 순간 intersection start 이벤트가 발생한다. 이때 내부적으로 상태 변경이 발생해 re-render가 일어난다면, 해당 컴포넌트가 변경된 것으로 인지되어 다시 렌더 및 ref가 부착되어 callback ref가 동작, 이로 인해 이미 전송된 Impression Log에 대한 intersection start 이벤트가 다시 동작하게된다. (이미 해당 영역은 뷰포트에 나타나있기 때문에 즉각적으로 intersection start가 동작한다.)

 

이렇게된다면 의도한대로 동작하지 않는 것이 되어버려 추가적인 처리가 필요해진다.

 

다만 이런 상황에서 훅 자체에 제한을 두기보다는 활용도가 높은 Slot 자체에서 이러한 상황에 있어서 대응할 수 있는 memoization 코드를 두는게 더 자연스럽다고 생각했다.

 

 

그렇다면 개선한 코드를 한번 보자.

 

const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props

  if (!React.isValidElement(children)) {
    return React.Children.count(children) > 1 ? React.Children.only(null) : null
  }

  const childRef = getElementRef(children)

  const composedRef = React.useMemo(() => mergeRefs(forwardedRef, childRef), [forwardedRef, childRef])

  return React.cloneElement(children, {
    ...mergeProps(slotProps, children.props),
    // @ts-ignore
    ref: composedRef,
  })
})

 

 

기존의 valid check는 조건문 안에서만 로직이 동작하도록 되어있기 때문에 훅을 사용할 수 없다. 

 

따라서 얼리 리턴으로 유효하지 않은 Element를 가진 경우를 먼저 처리하고, 이후에 두 Props를 merge하는 것을 useMemo로 메모이제이션해두는 것이다.

 

이렇게한다면 메모이제이션된 ref에 의해 의도치않은 re-render가 일어나더라도 의도한대로 동작할 수 있을 것이다.

 

 

 

TMI

 

Radix에서는 그렇다면 이러한 이슈가 있었을까 ?

 

당연하지만 이런 케이스는 없었다. Radix 내에서는 실제로 이런 케이스가 발생할 수 있는, 즉 state를 활용하는 곳에서는 mergeProps를 memoization 해두는 useComposedRefs 라는 훅을 주로 활용했기때문에 이러한 이슈를 피해갈 수 있었다.

 

 

정리

 

 

 

잘 만들어진 Radix-ui 의 특정 컴포넌트를 활용하는 과정에서 생각지못한 이슈를 마주했고, 어쩌면 너무 믿고 사용하던 컴포넌트였기때문에 에러트래킹에 조금 더 애를 먹었다. 사용하는 컴포넌트의 스펙을 정확히 인지하고, 활용하는데에 있어 사이드이펙트가 생길만한 여지를 잘 생각하며 사용하는게 좋겠다.

반응형