다른 태그로 바꿀 수 있는 React 컴포넌트

혀느현스·2022년 10월 11일
1

styled-components 같은 라이브러리를 사용하다보면 as 라는 속성을 사용해본 적이 있으실겁니다. 원래라면 <div> 태그인 것을 <a> 태그처럼 다른 태그로 옮길 수 있도록 하는 속성입니다.

이러한 변형 가능한 특징은 끝에 위차한 컴포넌트일수록 자주 사용되는 트릭입니다. 사용자의 클릭을 받는 <Button> 컴포넌트만 생각해도, <input> <a> <Button> 등 여러 HTML 태그를 가질 수 있습니다. 이번 포스트에서는 다른 태그로 바꿀 수 있는 React 컴포넌트, 즉 Polymorphic 한 컴포넌트를 만들어보겠습니다.

In Javascript

의외로 자바스크립트에서 구현하는 것은 그 활용성에 비해 간단합니다.

import {forwardRef} from 'react'

export const Button = forwardRef(({ as, ...props }, ref) => {
    const Element = as || "button";
    return <Element ref={ref} {...props} />;
  });

태그의 이름도 변수를 사용할 수 있으므로, as 속성을 받아 Element에 넣어주는 것이 전부입니다. Element에는 소문자로 시작하는 html 태그 외에도, 대문자로 시작하는 React 컴포넌트도 넣을 수 있습니다.

// 기본 태그 <button>으로 사용
<Button onClick={() => null}> ... </Button>

// <a> 태그로 사용
<Button as='a' href='https://opize.me'> ... </Button>

In Typescript

그러나 자바스크립트 방식에는 문제점이 있습니다. 타입 체크가 안되고, 인텔리센스도 지원되지 않는다는 것입니다. Polymorphic 한 컴포넌트에서 타입스크립트를 사용하기 위해서는 조금 번거러운 작업이 필요합니다.

React의 타입들

타입스크립트로 구현하기에 앞서 다음 타입에 대해 알고 계신다면 이해가 편해집니다.

  • React.ElementType
  • React.ComponentPropsWithRef
  • React.ComponentPropsWithoutRef

구현하기

먼저 forwardRef 를 이용해 다음과 같이 기본적인 컴포넌트를 만들어보겠습니다.

import React from "react";

type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
  children: React.ReactNode;
};

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ children, ...props }, ref) => {
    return (
      <button ref={ref} {...props}>
        {children}
      </button>
    );
  }
);

이제 이 컴포넌트에 as 속성을 추가해보겠습니다.

import React from "react";

type ButtonProps<T extends React.ElementType = "button"> =
  React.ComponentPropsWithoutRef<T> & {
    children: React.ReactNode;
    as?: T;
  };

export const Button = React.forwardRef(
  <T extends React.ElementType = "button">(
    { children, ...props }: ButtonProps<T>,
    ref: React.ComponentPropsWithRef<T>["ref"]
  ) => {
    const Element: React.ElementType = props.as || "button";
    return (
      <Element ref={ref} {...props}>
        {children}
      </Element>
    );
  }
);

제네릭을 이용해 컴포넌트를 만들었습니다. Props에 제네릭을 넣고, forwardRef 함수에서도 함수에 제네릭을 넣는 대신 내부 콜백함수에 제네릭을 추가하는 방식으로 구현했습니다. 이 과정에서 React.ComponentPropsWithRefReact.ComponentsPropsWithoutRef 유틸리티 타입을 통해 타입을 가져왔습니다.

<Button as="a" href="https://opize.me">
  Button
</Button>

놀랍게도 이 코드로도 작동은 했습니다. as 속성에 따라 태그가 변경되었고, 추가적인 속성도 잘 렌더링 되는 것을 확인할 수 있습니다.

그러나 이 코드에는 큰 문제가 있습니다.

이 컴포넌트는 인텔리센스가 정상적으로 작동하지 않았습니다. 실제로도 타입스크립트는 이 컴포넌트의 타입을 any로 인식하고 있습니다.

이러한 문제가 발생한 이유는, forwardRef에 대한 타입이 제대로 적용되지 않았기 때문입니다. 우리는 분명 타입을 정의하였지만, 이 타입은 forwardRef 파라미터의 함수에만 적용되었습니다. 따라서 함수 내부 뿐만 아니라, forwardRef 자체, 즉 Button에 대한 함수 정의가 필요합니다.

import React from "react";

type ButtonProps<T extends React.ElementType> = 
  React.ComponentPropsWithoutRef<T> & {
    children: React.ReactNode;
    as?: T;
  };

type ButtonComponent = <C extends React.ElementType = "button">(
  props: ButtonProps<C> & {
    ref?: React.ComponentPropsWithRef<C>["ref"];
  }
) => React.ReactElement | null;

export const Button: ButtonComponent = React.forwardRef(
  <T extends React.ElementType = "button">(
    { children, ...props }: ButtonProps<T>,
    ref: React.ComponentPropsWithRef<T>["ref"]
  ) => {
    const Element: React.ElementType = props.as || "button";
    return (
      <Element ref={ref} {...props}>
        {children}
      </Element>
    );
  }
);

ButtonComponent의 타입은 forwardRef의 리턴타입을 분석하여 할 수 있습니다.

ButtonComponent 를 작성하여 <Button>에 추가하였습니다. 드디어 인텔리센스가 정상적으로 동작하는 것을 확인할 수 있습니다.

재사용

완벽하게 동작하지만, 이런 컴포넌트를 만들 때마다 매번 이 긴 코드를 입력하기에는 불편함이 많습니다. 재사용할 코드를 분리하여 이후에 컴포넌트를 작성할 때 편하게 만들겠습니다.

import React from "react";

export type PolymorphicRef<T extends React.ElementType> =
  React.ComponentPropsWithRef<T>["ref"];

export type PolymorphicProps<
  T extends React.ElementType,
  Props = Record<string, unknown>
> = {
  as?: T;
} & React.ComponentPropsWithoutRef<T> &
  Props & {
    ref?: PolymorphicRef<T>;
  };

PropsRef를 가져오는 타입을 분리합니다. 이 타입을 사용하여 children과 onClick를 가진<Button> 버튼을 만들 수 있습니다.

type ButtonProps<T extends React.ElementType = "button"> = PolymorphicProps<
  T,
  {
    children?: React.ReactNode;
    onClick?: () => void;
  }
>;

type ButtonComponent = <C extends React.ElementType = "button">(
  props: ButtonProps<C>
) => React.ReactElement | null;

export const Button: ButtonComponent = React.forwardRef(
  <T extends React.ElementType = "button">(
    { children, onClick, as, ...props }: ButtonProps<T>,
    ref: PolymorphicRef<T>
  ) => {
    const Element = as || "button";
    return <Element ref={ref} {...props} />;
  }
);

이미 PolymorphicProps에서 React.ComponentPropsWithoutRef<T> 을 통해 onClick과 children을 가져왔으므로 굳이 추가할 필요는 없습니다. 이 코드에서는 예를 들기 위해 추가했습니다.

코드를 분리하긴 했지만 아직 작성해야 하는 코드가 많습니다... 더욱 줄여보려고 했지만 제 타입스크립트 실력이 부족한지라 줄이지 못했습니다. 여러분도 한 번 코드를 줄일 수 있도록 시도해보시는 것도 좋을 거라 생각합니다.

<Button as="a" href="https://opize.me">
  Button
</Button>

최종적인 코드는 다음과 같이 사용할 수 있습니다.

레퍼런스

https://kciter.so/posts/polymorphic-react-component
이 포스트는 위 포스트를 참고하여 작성하였습니다.

디스코드 서버

https://discord.gg/BWutkCwtsy

Opize 개발이야기와 관련한 이야기를 할 수 있는 디스코드 서버를 만들었습니다. 작성한 포스트에 대한 이야기도 하고, 포스트를 작성하는 중에는 디스코드에서 라이브를 열고 있으니 와서 구경도 하고 같이 이야기도 나누었으면 합니다!

profile
새로운 상상을 하고, 상상을 현실로 만드는 개발자

0개의 댓글