radix-ui의 Render Delegation 패턴과 asChild prop 까보기

dodog·2024년 9월 23일
post-thumbnail

직접 디자인 시스템 패키지를 만드는 프로젝트를 진행하기 위해, radix-ui의 DX를 참고하기로 했다. 그래서 가장 간단한 Label 컴포넌트를 살펴보다가 API reference에서 asChild Prop이라는 것을 발견하게 되었다.

asChild prop이 뭘까

Radix-ui Composition 설명 공식문서 를 참고해보면 Radix ui의 모든 Primitive 컴포넌트는 asChild라는 prop을 받을 수 있는데, 이게 true일 때는 default DOM을 렌더하지 않고, children으로 받은 요소를 그대로 렌더링할 수 있다고 한다.

import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';

export default () => (
  <Tooltip.Root>
    <Tooltip.Trigger asChild>
      <a href="https://www.radix-ui.com/">Radix UI</a>
    </Tooltip.Trigger>
    <Tooltip.Portal></Tooltip.Portal>
  </Tooltip.Root>
);

이런식으로 asChild를 사용하면 Tooltip.Trigger 컴포넌트에 default로 렌더링되는 Primitive.button 대신에 자식인 a가 대신 렌더링된다.

만약 asChild를 사용하지 않는다면

import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';

export default () => (
  <Tooltip.Root>
    <a href="https://www.radix-ui.com/">
      <Tooltip.Trigger>
        Radix UI
      </Tooltip.Trigger>
    </a>
    <Tooltip.Portal></Tooltip.Portal>
  </Tooltip.Root>
);

이런식으로 Tooltip.Trigger의 default인 button이 a태그 내부에 위치하게 되고,
그러면 HTML 명세에 맞지 않고, 접근성 이슈도 있고 불필요한 태그도 늘어난다.

그래서 이런 문제를 해결하기 위해 Radix-ui는 asChild prop을 통해 렌더링 책임을 자식 컴포넌트에 위임하는 방식을 채택했는데, 이것을 Render Delegation 패턴이라고 부른다고 한다.

Render Delegation을 처리하는 내부 구현방법 알아보기

이걸 이해하기 위해선 먼저 Primitive.tsx 컴포넌트부터 살펴보아야 한다.
radix-ui의 모든 디자인 컴포넌트는 Primitive의 요소를 통해 만들어지기 때문이다.

// 핵심 로직만 남긴 Primitive.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Slot } from '@radix-ui/react-slot';

const NODES = [
  'a',
  'button',
  'div',
  'form',
  'h2',
  'h3',
  'img',
  'input',
  'label',
  'li',
  'nav',
  'ol',
  'p',
  'span',
  'svg',
  'ul',
] as const;

const Primitive = NODES.reduce((primitive, node) => {
  const Node = React.forwardRef((props, forwardedRef) => {
    const { asChild, ...pimitiveProps } = props;
    const Comp: any = asChild ? Slot : node;

    return <Comp {...primitiveProps} ref={forwardedRef} />;
  });

  return { ...primitive, [node]: Node };
}, {});

Primitive가 처음엔 컴포넌트처럼 보였지만, 사실 Primitive는 그냥 객체이며, NODES의 각 요소를 key로 가지고 해당 요소의 forwardRef 컴포넌트를 value로 갖는다

그래서 결국 객체의 value가 되는 Node가 리턴하는 Comp를 살펴보면 const Comp: any = asChild ? Slot : node 인데,
여기서 asChild가 true이면 default인 node 대신 Slot 컴포넌트를 리턴하는 것을 알 수 있다. 그리고 바로 이 Slot 컴포넌트에 Render Delegation을 구현하는 핵심 로직이 다 들어있다.

Slot 컴포넌트 내부 구현

지금까지는 서론이였고, 앞으로 소개할 Slot 컴포넌트가 본론이고 핵심이다.
사실 실제 Slot 컴포넌트 내부에는 Render Delegation할 영역을 선택해주기 위한 Slottable 컴포넌트를 처리하는 로직만 있다. 이 부분은 좀 복잡해서 이번 글에서는 Slot 컴포넌트 내부의 실제로 Render Delegation하는 로직이 들어있는 SlotClone이라는 컴포넌트만 살펴볼 예정이다.

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

interface SlotCloneProps {
  children: React.ReactNode;
}

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

  if (React.isValidElement(children)) { // 2.
    const childrenRef = getElementRef(children); // 3.
    return React.cloneElement(children, { //4. 
      ...mergeProps(slotProps, children.props),
      // @ts-ignore
      ref: forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef,
    });
  }

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

이 컴포넌트의 흐름은 다음과 같다.

  1. children과 Slot 컴포넌트가 직접 넘겨받은 slotProps를 분리
  2. children이 유효한 React Element인지 판별한다
  3. children의 ref를 받아온다.
  4. children에 props를 넘겨주기 위해 React.cloneElement로 children 다시 생성
  5. slotProps와 children.props를 병합
  6. Slot이 받은 ref와 children이 받은 ref가 둘 다 children 요소를 가르키도록 합성

이런 과정을 통해서 Slot 컴포넌트는 최종적으로 Slot이 직접 받은 모든 props 및 ref를 넘겨받은 children을 return하게 된다.

그래서 아래처럼 asChild를 사용할 때,

<Primitive.button asChild ref={ref1} {...butonProps}>
  <a {...props} ref={ref2}>아무 요소나 상관없음</a>
</Primitive.button>

이렇게 부모에 props를 주던 children에 직접 ref를 주던 어디에 어떻게 주던지 관계없이 모두 children으로 합쳐져 들어가서 동작하게 되는 것이다.

마무리

오픈소스를 소스코드를 이렇게 깊게 파본 것은 처음이여서 쉽지 않았다. 특히나 내 글에서는 생략했지만 원본 소스코드의 type 관련 코드가 정말 복잡했다. 그래도 한참을 보니까 결국 익숙해져서 이해할 수 있었고, 덕분에 오픈소스와 복잡한 타입을 보는 것에 좀 더 친숙해지게 된 좋은 경험이었다.

profile
심리학, 사회문제해결에 관심이 많은 프론트엔드 개발자

0개의 댓글