이전에 Polymorphic한 React 컴포넌트 만들기라는 글을 통해 컴포넌트에 다형성을 적용하는 방법을 소개했었다. 해당 글에서는 as 속성을 이용하여 컴포넌트를 변형시키는 것을 소개하는 데 이를 이용하면 하나의 컴포넌트로 여러 요소(Element)나 그에 따른 속성(Attribute)을 이용할 수 있고 이를 넘어 다른 컴포넌트를 합성하는 것도 가능하다. 따라서 이를 이용하여 무엇이든지 될 수 있는 가장 추상화된 형태의 컴포넌트를 만들 수 있다.

다만 이 방법은 여러 비판을 받기도 하는데 가장 큰 이유는 모호성 때문이다. as 속성을 이용하여 컴포넌트를 결합하는 경우 전달할 수 있는 prop이 어떤 컴포넌트의 것인지 알기 힘들다는 점과 코드만 봐서는 어떻게 렌더링 되는지 알기 힘들다는 점이다. 그 외의 비판점으로는 자동 완성이 느려진다거나 TypeScript에서 타입을 추론하기 어렵다는 점 등이 있다.

따라서 이에 대한 대안으로 자신의 속성과 행동을 자식 컴포넌트에 넘긴 후 자식이 직접 부모 컴포넌트를 대신하여 렌더링하는 방법인 Render Delegation1이 등장했다. 이는 Radix라는 오픈소스 리액트 컴포넌트 라이브러리를 통해 유명해진 방법으로 Render Delegation 기능을 제공하는 라이브러리는 보통 asChild라는 속성을 통해 해당 기능을 이용할 수 있게 제공한다.

Render Delegation 컴포넌트의 구성

Render Delegation 컴포넌트는 보통 SlotSlottable이라는 두 컴포넌트를 통해 구성된다. Slot은 자식 컴포넌트를 렌더링하는 역할을 하고 SlottableSlot에 들어갈 것이 무엇인지를 나타낸다.

참고로 Polymorphic 컴포넌트처럼 변형을 통한 다형성 문제 해결이라는 점은 같지만 Render Delegation 컴포넌트는 기존 컴포넌트와 합성이될 컴포넌트를 코드에서 분리한다는 점이 다르다.

문제 인식하기

만약 앞서 언급한 Polymorphic 컴포넌트와 이번 글에서 다룰 Render Delegation 컴포넌트가 왜 필요한지 잘 모를 수 있다. 이전에 작성한 글에서도 설명했지만 다시 한번 간단히 설명해 보겠다.

/**
 * Button.jsx
 */
export const Button = ({ ...props }) => {
  return (
    <button 
      style={{ backgroundColor: 'black', color: 'white' }} 
      {...props} 
    />
  );
}

/**
 * App.jsx
 */
import { Button } from './Button';

const App = () => {
  return (
    <div>
      <Button onClick={() => alert('Good!')}>Click Me!</Button>
    </div>
  );
}

설명이 필요 없을 정도로 간단한 코드다. 단순하게 표현했지만 위 코드처럼 스타일만 적용한 컴포넌트는 실제로도 많이 사용된다. Button 컴포넌트는 prop으로 넘기는 값을 button 태그의 속성으로 전부 넘기기 때문에 꽤 확장성 있게 사용할 수 있는 컴포넌트라고 생각할 수 있다. 그런데 만약 버튼에 페이지 링크를 추가하고 싶다면 어떻게 해야 할까?

import { Button } from './Button';

const App = () => {
  return (
    <div>
      <a href="https://kciter.so">
        <Button>Click Me!</Button>
      </a>
    </div>
  );
}

위 코드처럼 작성할 수도 있겠지만 재사용성 측면에선 그다지 좋은 방법은 아니다. 추후 재사용을 고려하여 새로운 컴포넌트를 만들 수도 있다.

/**
 * Button.jsx
 */
export const Button = ({ ...props }) => {
  return (
    <button 
      style={{ backgroundColor: 'black', color: 'white' }} 
      {...props} 
    />
  );
}

/**
 * LinkButton.jsx
 */
import { Button } from './Button';

export const LinkButton = ({ href, ...props }) => {
  return (
    <a href={href}>
      <Button {...props} />
    </a>
  );
}

/**
 * App.jsx
 */
import { LinkButton } from './LinkButton';

const App = () => {
  return (
    <div>
      <LinkButton href="https://kciter.so">Click Me!</LinkButton>
    </div>
  );
}

혹은 위와 같이 작성할 수도 있지만 이 경우 a 태그가 확장되지 않는다는 문제점이 있고 컴포넌트의 의존 관계가 새롭게 추가된다는 문제점이 있다. 그리고 만약 react-router나 Next.js를 사용하여 SPA를 위한 Link 컴포넌트를 사용한다면 또 새로운 컴포넌트를 만들어줄 수밖에 없다. 이 문제의 해결법으로 이전 글에서 다룬 Polymorphic 컴포넌트나 이번 글에서 다룰 Render Delegation 컴포넌트를 사용할 수 있다.

살펴보기

앞서 언급한 Radix에서는 asChild를 통해 Render Delegation이 가능한 컴포넌트를 제공한다. 해당 라이브러리를 통해 동작을 살펴보자.

엿보기

먼저 아주 간단한 예제를 살펴보자. 다음은 Radix에서 제공하는 Label을 사용하는 예제다.

import * as Label from "@radix-ui/react-label";

const App = () => {
  return (
    <div>
      {/* asChild를 사용하지 않은 경우 */}
      <Label.Root>
        https://kciter.so
      </Label.Root>
    </div>
  );
};

위 코드에서 사용하는 Label.Root 컴포넌트를 이용하면 특별한 기능 없이 label을 렌더링할 수 있다. 여기서 만약 링크를 추가하고 싶다면 다음과 같이 asChild 속성을 사용할 수 있다.

import * as Label from "@radix-ui/react-label";

const App = () => {
  return (
    <div>
      {/* asChild를 사용한 경우 */}
      <Label.Root asChild>
        <a href="https://kciter.so">https://kciter.so</a>
      </Label.Root>
    </div>
  );
};
요소가 변경되었다

위와 같이 작성하면 렌더링 되는 요소가 a로 변경된다.

Slot 사용하기

앞서 살펴본 예제는 기본적으로 제공하는 컴포넌트에서 단순히 요소를 변경하기만 하는 아주 간단한 예제였다. Radix에서 제공하는 Slot을 이용하면 사용자가 직접 Render Delegation이 가능한 컴포넌트를 만들 수 있다.

import { Slot } from "@radix-ui/react-slot";

const Button = ({ asChild, ...props }) => {
  const Element = asChild ? Slot : "button";
  return (
    <Element 
      {...props}
      style={{
        padding: "10px",
        border: "1px solid #000",
        borderRadius: "5px",
        backgroundColor: 'transparent',
        fontSize: 12
      }}
    />
  );
};

const App = () => {
  return (
    <div>
      <Button>
        This is button
      </Button>

      <Button asChild>
        <a href="https://kciter.so">This is link</a>
      </Button>
    </div>
  );
};

Slot 컴포넌트는 children으로 받은 JSX 요소를 렌더링한다. 위 코드를 살펴보면 asChild 속성이 true일 때는 Slot 컴포넌트를 사용하고 false일 때는 button를 사용한다. 따라서 asChild를 사용한 컴포넌트의 렌더링 결과를 보면 기존 Button 컴포넌트에서 설정한 스타일은 변하지 않지만 요소가 변경된 것을 확인할 수 있다.

asChild 여부에 따라 렌더링이 달라진다

Slot을 이용하여 부모 컴포넌트의 속성을 자식 컴포넌트에 넘기고 렌더링을 위임했다고 볼 수 있다.

Slottable 사용하기

Slottable을 이용하면 일부분만 위임하는 것도 가능하다. 다음 예제를 살펴보자.

import { Slot, Slottable } from "@radix-ui/react-slot";

const Icon = () => (
  <span>🔴</span>
)

const Button = ({ asChild, icon, children, ...props }) => {
  const Element = asChild ? Slot : "button";
  return (
    <Element 
      {...props}
      style={{
        padding: "10px",
        border: "1px solid #000",
        borderRadius: "5px",
        backgroundColor: 'transparent',
        fontSize: 12
      }}
    >
      {icon}
      <Slottable>{children}</Slottable>
    </Element>
  );
};

const App = () => {
  return (
    <div>
      <Button icon={<Icon />}>
        This is button
      </Button>

      <Button icon={<Icon />} asChild>
        <a href="https://kciter.so">This is link</a>
      </Button>
    </div>
  );
};

Slottable 컴포넌트는 Slot 컴포넌트로 렌더링 될 컴포넌트의 children이 들어갈 곳을 정할 수 있다. 즉, 위 코드에선 Buttonchildren이 아닌 a 요소의 childrenSlottable 컴포넌트 내부로 들어가게 된다.

렌더링 결과

실제로 렌더링 된 HTML을 보면 다음과 같다.

<div>
  <button style="...">
    <span>🔴</span>
    This is button
  </button>
  
  <a href="https://kciter.so" style="...">
    <span>🔴</span>
    This is link
  </a>
</div>

결과적으로 icon 속성은 Button 컴포넌트에서 설정한 대로 따르지만, 그 외 속성은 asChild 속성에 따라 위임된다. 즉, 위임하고 싶은 부분만 따로 지정해서 구현하는 것이 가능하다. 이렇게 Slottable을 사용하면 조금 더 풍부한 표현이 가능해진다. 참고로 Slot만 사용하는 것은 Slot 내부에 Slottable을 최상위로 둬서 사용하는 것과 같다.

JavaScript에서 구현하기

이제 SlotSlottable 컴포넌트가 어떻게 동작하는지 알았으니 이 두 컴포넌트를 직접 만들어보자. 여기서는 먼저 편의를 위해 JavaScript로 구현해 볼 것이다.

Slot 구현하기

Slot 컴포넌트는 children을 조금만 변형해서 렌더링하면 되기 때문에 아주 쉽게 구현할 수 있다. 다음 코드를 살펴보자.

/**
 * Slot.jsx
 */

import React from "react";

export const Slot = ({ children, ...props }) => {
  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    });
  }

  // 올바르지 않은 컴포넌트라면 경고 로그를 출력하고 null을 반환
  console.warn("Slot component should have only one React element as a child");

  return null;
};

위 코드에서 Slot 컴포넌트는 children으로 받은 JSX 요소를 렌더링한다. 만약 children이 JSX 요소라면 props를 합성하여 새로운 컴포넌트를 만들어 렌더링하고 그렇지 않다면 렌더링하지 않는다. 이때 children이 React Element가 아니거나 여러 개 들어오는 경우엔 경고 로그를 보여주고 null을 반환하도록 구현했다. 그럼 벌써 Slot을 구현했다. 이전에 Radix 예제에 사용했던 코드로 테스트하면 잘 돌아가는 것을 확인할 수 있다.

/**
 * App.jsx
 */

import { Slot } from "./Slot";

const Button = ({ asChild, ...props }) => {
  const Element = asChild ? Slot : "button";
  return (
    <Element {...props} style={/* ... */} />
  );
};

const App = () => {
  return (
    <div>
      <Button>
        This is button
      </Button>

      <Button asChild>
        <a href="https://kciter.so">This is link</a>
      </Button>
    </div>
  );
};
잘 동작한다

Slottable 구현하기

그럼 이번에는 Slottable 개념을 구현해보자.

/**
 * Slottable.jsx
 */

export const Slottable = ({ children }) => {
  return <>children</>;
}

사실 Slottable 컴포넌트는 따로 하는 게 없다. 그저 스스로 Slottable라는 것을 알려줄 수만 있으면 되기에 위와 같이 구현하면 된다. 대신 기존에 구현했던 Slot 컴포넌트의 로직을 변경할 필요가 있다. 이 부분이 조금 어려울 수 있다.

/**
 * Slot.jsx
 */

import React from "react";
import { Slottable } from "./Slottable";

export const Slot = ({ children, ...props }) => {
  const childrenArray = React.Children.toArray(children);
  const slottable = childrenArray.find((child) => {
    return React.isValidElement(child) && child.type === Slottable
  });

  if (slottable) { // Slottable이 있다면
    const newElement = slottable.props.children;
    const newChildren = childrenArray.map((child) => {
      // Slottable이 아닌 것은 그대로 반환
      if (child !== slottable) return child;

      // Slottable이라면 해당 영역을 자식 컴포넌트의 children으로 교체
      if (React.isValidElement(newElement)) {
        return newElement.props.children;
      } else {
        console.warn(
          "Slot component should have only one React element as a child"
        );
      }

      return null;
    });

    // 새로운 요소를 렌더링
    return React.isValidElement(newElement)
      ? React.cloneElement(
          newElement, 
          { ...props, ...newElement.props }, 
          newChildren
        )
      : null
  }

  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    });
  }

  console.warn("Slot component should have only one React element as a child");

  return null;
};

기존 코드에서 달라진 점은 Slottable이 있는지 확인하고 있다면 이에 대해 처리해 주는 부분이다. 이 부분을 제외하면 기존 코드와 동일하다. 이렇게 만들어진 Slot과 Slottable을 이용하여 이전에 사용했던 예제로 실행해 보면 잘되는 것을 확인할 수 있다.

아주 잘 동작한다

TypeScript에서 구현

이제 TypeScript에서 구현해 보자. 타입을 신경써야 해서 구현이 복잡하지 않을까 싶지만 Render Delegation 컴포넌트는 Polymorphic 컴포넌트와 다르게 변형할 컴포넌트가 코드에서 분리되기 때문에 타입 추론이 쉽다. 그래서 TypeScript에서 구현하는 것은 JavaScript 구현과 크게 다르지 않다.

먼저 Slot 컴포넌트에 타입을 붙이면서 다시 구현해 보자.

/**
 * Slot.tsx
 */

import React from "react";

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

export type RenderDelegationProps<T> = T & {
  asChild?: boolean;
};

export const Slot = ({ children, ...props }: SlotProps) => {
  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    });
  }

  console.warn("Slot component should have only one React element as a child");

  return null;
};

타입을 상식적인 수준 정도로만 붙였는데 벌써 완성됐다. 코드 중 RenderDelegationProps는 Render Delegation을 사용할 컴포넌트에 붙일 수 있는 타입이다. 이 타입을 이용하면 asChild 속성을 사용할 수 있게 된다.

이어서 Slottable 개념과 관련된 로직에 타입을 붙이면서 다시 구현해 보자.

/**
 * Slottable.tsx
 */

import React from "react";

export interface SlottableProps {
  children: React.ReactNode;
}

export const Slottable = ({ children }: SlottableProps) => {
  return <>children</>;
};

마찬가지로 아주 간단하게 구현할 수 있다. 이제 Slot 컴포넌트의 로직을 변경하면서 타입을 붙이면 된다.

/**
 * Slot.tsx
 */

import React from "react";
import { Slottable, SlottableProps } from "./Slottable";

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

export type AsChildProps<T> = T & {
  asChild?: boolean;
};

export const Slot = ({ children, ...props }: SlotProps) => {
  const childrenArray = React.Children.toArray(children);
  const slottable = childrenArray.find((child) => {
    return React.isValidElement(child) && child.type === Slottable;
  }) as React.ReactElement<SlottableProps>;

  if (slottable) {
    const newElement = slottable.props.children;
    const newChildren = childrenArray.map((child) => {
      if (child !== slottable) return child;

      if (React.isValidElement(newElement)) {
        return newElement.props.children;
      } else {
        console.warn(
          "Slot component should have only one React element as a child"
        );
      }

      return null;
    });

    return React.isValidElement(newElement)
      ? React.cloneElement(
          newElement,
          { ...props, ...newElement.props },
          newChildren
        )
      : null;
  }

  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    });
  }

  console.warn("Slot component should have only one React element as a child");

  return null;
};

마찬가지로 기존 코드와 크게 다른 점이 없다. 딱 한 라인이 추가되었는데 이는 Slottable 컴포넌트를 찾은 후 어떤 타입인지 명시해 주는 것뿐이다. 만약 TypeScript의 사용자 정의 타입 가드 문법을 사용하면 다음과 같이 작성하는 것도 가능하다.

/**
 * Slot.tsx
 */

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

export const Slot = ({ children, ...props }: SlotProps) => {
  const childrenArray = React.Children.toArray(children);
  const slottable = childrenArray.find(isSlottable);
  
  // ...
}

마치며

이전에 포스팅한 as를 이용한 Polymorphic 컴포넌트와 asChild를 이용하는 Slottable 컴포넌트는 각각 장단점이 있다. 따라서 무엇이 더 좋다면서 추종하는 것보단 다양한 패턴이 있음을 인지하고 상황에 따라 적절한 방법을 선택하여 사용하는 것이 중요하다. 참고로 이 포스팅을 통해 만들어진 최종적인 코드는 GitHub 저장소에서 확인할 수 있다.


  1. 렌더링을 다른 컴포넌트에 맡기기 때문에 Render Delegation이라 부른다.