styled-components
같은 라이브러리를 사용하다보면 as
라는 속성을 사용해본 적이 있으실겁니다. 원래라면 <div>
태그인 것을 <a>
태그처럼 다른 태그로 옮길 수 있도록 하는 속성입니다.
이러한 변형 가능한 특징은 끝에 위차한 컴포넌트일수록 자주 사용되는 트릭입니다. 사용자의 클릭을 받는 <Button>
컴포넌트만 생각해도, <input>
<a>
<Button>
등 여러 HTML 태그를 가질 수 있습니다. 이번 포스트에서는 다른 태그로 바꿀 수 있는 React 컴포넌트, 즉 Polymorphic
한 컴포넌트를 만들어보겠습니다.
의외로 자바스크립트에서 구현하는 것은 그 활용성에 비해 간단합니다.
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>
그러나 자바스크립트 방식에는 문제점이 있습니다. 타입 체크가 안되고, 인텔리센스도 지원되지 않는다는 것입니다. Polymorphic
한 컴포넌트에서 타입스크립트를 사용하기 위해서는 조금 번거러운 작업이 필요합니다.
타입스크립트로 구현하기에 앞서 다음 타입에 대해 알고 계신다면 이해가 편해집니다.
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.ComponentPropsWithRef
와 React.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>;
};
Props
와 Ref
를 가져오는 타입을 분리합니다. 이 타입을 사용하여 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
이 포스트는 위 포스트를 참고하여 작성하였습니다.
Opize 개발이야기와 관련한 이야기를 할 수 있는 디스코드 서버를 만들었습니다. 작성한 포스트에 대한 이야기도 하고, 포스트를 작성하는 중에는 디스코드에서 라이브를 열고 있으니 와서 구경도 하고 같이 이야기도 나누었으면 합니다!