OTTER-LOG

Polymorphic Components

Polymorphic Components
by otter2022년 11월 10일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

Intro

재사용성이 좋은 컴포넌트를 만들기 위해, 노력하는 과정 중 다음과 같은 문제를 겪은 적이 있습니다.

const Container = (props) { const { children, styleProps } = props; return <div style={...styleProps}>{children}</div> } export default Container;

간단한, 스타일을 입힐 수 있는 Container 컴포넌트를 만들었습니다. 위의 과정에서 styled component 를 사용하고 있었기 때문에 매번 width, height 가 다른 컴포넌트를 만들어주는 것이 비효율적이라고 생각했기 때문입니다. 그런데, 위 컴포넌트는 다음과 같은 문제점이 있었습니다.

div 라는 태그는 절대 바꾸지 않는 부분이었습니다. article, section 등의 다른 태그를 이용해야 할때에는 결과적으로 새로운 태그를 이용해야 했습니다. 매번 새로운 컴포넌트를 만들어 내는 상황은 해결되지 못했습니다.

Polymorphic Component

컴퓨터 과학에서 다형성은 프로그래밍 요소가 여러 형태로 표현될 수 있는 것을 의미합니다. 여기로부터 폴리모픽 컴포넌트는 다른 태그로의 변환이 자유로운 컴포넌트의 명칭으로 사용됩니다. 예를 들어 as 라는 prop 을 통해 다른 태그로 다형적으로 렌더링 될 수 있는 것입니다.

JS에서 만들기

export const Text = ({ as, children }) => { const Component = as || "span"; return <Component>{children}</Component>; };

자바스크립트에서, 다형적 컴포넌트를 만든다면 위와 같이 간단히 만들 수 있습니다. 실제로 작동하는 데에도 문제가 없고 의도한 바로 작동합니다. 하지만, 이러한 방식은 다음과 같은 문제점이 있습니다.

  • as 로 잘못된 HTML elements 를 받을 수 있습니다.

    <error>HTML elements 가 아닙니다. 오류가 발생해야 합니다.

  • as 로 선언된 HTML elementsattribute 가 아닌, 잘못된 attribute 를 받을 수 있습니다.

  • attribute 를 작성할 때에 아무런 서포트가 없습니다.


TypeScript로 문제 해결하기

타입스크립트로, 위의 문제점을 해결할 수 있습니다.

유효한 HTML Elements

export const Text = <C extends React.ElementType>({ as, children, }: { as?: C; children: React.ReactNode; }) => { const Component = as || "span"; return <Component>{children}</Component>; };

타입스크립트의 제네릭을 사용해서, 위와 같은 방법으로 우리는 이 문제를 쉽게 해결할 수 있습니다. 이제 asprops 으로 유효한 HTML Elements 만 받을 수 있습니다.

// React.ElementType type ElementType<P = any> = { [K in keyof JSX.IntrinsicElements]: P extends JSX.IntrinsicElements[K] ? K : never }[keyof JSX.IntrinsicElements] | ComponentType<P>; // IntrinsicElements interface IntrinsicElements { // HTML a: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>; abbr: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>; address: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>; area: React.DetailedHTMLProps<React.AreaHTMLAttributes<HTMLAreaElement>, HTMLAreaElement>; article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>; aside: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>; // ....

React.ElementType 에서 HTML elements 들을 가지고 있기 때문입니다.

유효한 Attributes

위의 문제를 해결 하고 우리는 다음과 같이 attributes 의 선언을 해줄 수도 있습니다.

type TextProps<C extends React.ElementType> = { as?: 'a'; children: React.ReactNode; } & { href: string };

그런데 이와 같은 방식으로 하는 것은 비효율적일 것입니다. 태그는 너무나 많고 그에대한 attributes 는 더욱 많기 때문입니다. 그러므로 React.ComponentPropsWithoutRef 을 이용합니다. 물론, 이름 그대로 ref 를 사용할 수 없습니다.

type TextProps<C extends React.ElementType> = { as?: C; children: React.ReactNode; } & React.ComponentPropsWithoutRef<C>; export const Text = <C extends React.ElementType>({ as, children, ...restProps }: TextProps<C>) => { const Component = as || "span"; return <Component {...restProps}>{children}</Component>; };

이제 올바른 attribute 만 사용 가능하고, 올바르지 않을 경우 타입스크립트가 오류를 만들어 냅니다.

Default generic

type TextProps<C extends React.ElementType> = { as?: C; children: React.ReactNode; } & React.ComponentPropsWithoutRef<C>; export const Text = <C extends React.ElementType>({ as, children, ...restProps }: TextProps<C>) => { const Component = as || "span"; return <Component {...restProps}>{children}</Component>; };

현재 진행된 부분에서 우리는 Component 의 기본값을 span 으로 지정하고 있습니다. 그런데, 타입스크립트는 이 부분을 알지 못합니다. 우리가 해당 부분에 작성해준 것이 없기 때문입니다.

그렇기 때문에 아래와 같이 사용해도 오류가 나지 않습니다.

<Text href='https://google.com' error='error'> no default generic </Text> // component의 기본값은 span이므로, 두 props모두 받을 수 없습니다.

이는 우리가 의도한 것이 아니므로, generic 에 기본값을 넣어줍니다.

type TextProps<C extends React.ElementType> = { as?: C; children: React.ReactNode; } & React.ComponentPropsWithoutRef<C>; export const Text = <C extends React.ElementType = "span">({ // generic에 기본값을 넣어줍니다. as, children, ...restProps }: TextProps<C>) => { const Component = as || "span"; return <Component {...restProps}>{children}</Component>; };

여기까지, 우리는 위의 문제들을 해결했습니다. 이제 다른 부분을 진행합시다.


Type을 견고하게 만들기

우리가 지금 문제를 해결한 최종 타입은 다음과 같습니다.

type TextProps<C extends React.ElementType> = { // C는 React.ElementType 입니다. as?: C; children: React.ReactNode; } & React.ComponentPropsWithoutRef<C>;

이 부분의 children 부분을 다음과 같이 고칠 수 있습니다.

type TextProps<C extends React.ElementType> = { as?: C; }; type Props<C extends React.ElementType> = React.PropsWithChildren< TextProps<C> > & React.ComponentPropsWithoutRef<C>;

그런데, 한가지 문제점이 있습니다. 예를 들어 color 라는 props 가 들어온다면 어떻게 될까요?

type TextProps<C extends React.ElementType> = { as?: C; color?: 'black' | 'red'; }; type Props<C extends React.ElementType> = React.PropsWithChildren< TextProps<C> > & React.ComponentPropsWithoutRef<C>;

사실 아무런 문제 없이 지금 코드만으로도, props 으로 잘 등록이 됩니다. 그런데 여기서 하나의 문제점이 있습니다. 사실 color 라는 이름으로 등록된 HTML attribute 가 있기 때문입니다.

가운데에 color 이라는 attribute 가 존재합니다.

즉 현재 시점에서, 우리의 코드는 color 이라는 타입을 두가지 모두가 가지고 있습니다

이를 해결해야 합니다. 해결하지 않으면 의도하지 않은 overriding 이 일어날 수 있기 때문입니다. omit 을 사용해서 다음과 같이 타입을 수정합니다.

type TextProps<C extends React.ElementType> = { as?: C; color?: "black" | "red"; }; type Props<C extends React.ElementType> = React.PropsWithChildren< TextProps<C> > & Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>; // TextProps의 key를 Omit 합니다. 그러면, 중복되지 않을 것입니다.

Reusable 하게 만들기

그런데 위의 타입을 매번 설정해 주어야 하는 문제점이 있습니다. TextProps 은 다른 컴포넌트에 대응해 계속해서 바뀔 수 있기 때문입니다. 이를, 재사용가능하게 만들어 봅시다.

type TextProps<C extends React.ElementType> = { as?: C; color?: "black" | "red"; }; type Props<C extends React.ElementType> = React.PropsWithChildren< TextProps<C> > & Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>;

현재 우리의 type 은 이런 형태입니다. 우리가 여기서 동적으로 받아올 부분은 TextProps 부분입니다. 그 중에서도 as 를 사용하는 부분은 언제나 사용할 것이므로 제외하고, TextProps = color 부분을 제네릭으로 변환해야 합니다.

일단, type을 나누어봅시다.

type TextProps<C extends React.ElementType> = { as?: C; color?: "black" | "red"; }; // 이 TextProps중 as는 언제나 사용합니다. // color은 실제 컴포넌트가 props로 받는 부분입니다. // asProp을 따로 뺍니다. type AsProp<C extends React.ElementType> = { as?: C; } // 이 AsProp은 반드시 사용할 것입니다.

그리고, 아래 부분도 다음과 같이 수정합니다.

type Props<C extends React.ElementType> = React.PropsWithChildren< TextProps<C> > & Omit<React.ComponentPropsWithoutRef<C>, keyof TextProps<C>>; // 이중 TextProps를 제네릭으로 바꾸어야 합니다. type PolymorphicComponentProps< C extends React.ElementType, Props = {}, // 제네릭이 2개 필요합니다. C는 이제는 ElementType, // Props 는 어떠한 값이 들어올 지 모르므로, 빈 객체로 사용합니다. > = React.PropsWithChildren<Props & AsProp<C>> & // children과 함께, Props, AsProp을 타입으로 가집니다. Omit<React.ComponentPropsWithoutRef<C>, keyof (AsProp<C> & Props)>; // 어트리뷰트는, AsProp, Props의 key를 제외하고 가집니다.

그리고, Omit 을 가독성 좋게 하기 위해 다음과 같이해서 마무리 합니다.

type AsProp<C extends React.ElementType> = { as?: C; }; type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P); // AsProp을 이용하고 제네릭을 받습니다. type PolymorphicComponentProps< C extends React.ElementType, Props = {}, > = React.PropsWithChildren<Props & AsProp<C>> & Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>; // PropsToOmit을 사용해 가독성 좋게 변환합니다. // 더 가독성 좋게 하기 위해, type Props<C extends React.ElementType, P> = PolymorphicComponentProps<C, P>; // 다시 한번 선언해두면, 더 쉽게 사용할 수 있습니다.

이 최종적인 코드를 통해, 우리는 다음과 같이 사용할 수 있습니다.

type ActualProps = { color?: "black" | "red"; }; export const Text = <C extends React.ElementType = "span">({ as, children, ...restProps }: Props<C, ActualProps>) => { const Component = as || "span"; return <Component {...restProps}>{children}</Component>; };

그런데, 아직 ref 를 사용할 수 없습니다.


Ref 사용하기

ref 를 위한 타입을 지정해봅시다.

type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>;

ref 는 이렇게만 지정해도 문제없이 사용할 수 있습니다. 그런데, 이렇게 하면 ref 를 제외한 다른 값이 들어올 수 있다는 문제점이 있습니다.

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

pick 을 해줘서, ref 만 가지고 옵시다.

이렇게, ref 를 위한 타입을 만들고, 위에 마무리한 타입과 합쳐 다음으로 마무리 할 수 있습니다.

type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"]; type PolymorphicRefProps<C extends React.ElementType> = { ref?: PolymorphicRef<C>; }; type PolymorphicComponentWithRef< C extends React.ElementType, P, > = PolymorphicComponentProps<C, P> & PolymorphicRefProps<C>;

그리고 아래와 같이 사용합니다.

export const TextwithRef = React.forwardRef( <C extends React.ElementType = "span">( { as, children, ...restProps }: Props<C, ActualProps>, ref?: PolymorphicRef<C>, ) => { const Component = as || "span"; return ( <Component {...restProps} ref={ref}> {children} </Component> ); }, );

정리하기

지금 너무 무수한 타입들이 존재합니다. 반복되는 부분도 많고, 이를 쉽게 사용하기 위해 합친 부분도 있습니다. 이를 마지막으로 정리 하려고 합니다.

type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref']; type AsProp<C extends React.ElementType> = { as?: C; }; type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P); type PolymorphicComponentProp<C extends React.ElementType, Props = {}> = React.PropsWithChildren< Props & AsProp<C> > & Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>; type PolymorphicComponentPropWithRef< C extends React.ElementType, Props = {} > = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> }; type TextProps<C extends React.ElementType> = PolymorphicComponentPropWithRef< C, { color?: 'white' | 'black' } >; type TextComponent = <C extends React.ElementType = 'span'>( props: TextProps<C> ) => React.ReactElement | null; export const Text: TextComponent = React.forwardRef( <C extends React.ElementType = 'span'>( { as, color, children }: TextProps<C>, ref?: PolymorphicRef<C> ) => { const Component = as || 'span'; const style = color ? { style: { color } } : {}; return ( <Component {...style} ref={ref}> {children} </Component> ); } );

주의점

위로 진행해, span 태그를 기반으로 작업하던 중 span 으로 이루어진 태그 자체는 대부분의 ref 타입을 받을 수 있는 것처럼 보입니다. 타입을 잘못 지정한 것 같아, 수정해보니 이는 span 태그의 특징으로 보입니다.

const videoRef = React.useRef<HTMLVideoElement>(null); return <span ref={videoRef}>some fancy text</span> // typescript 에러가 일어나지 않습니다.

마치며

재사용가능한, 태그까지 조절 가능한 유연한 컴포넌트를 만드려고 생각했을 때 그렇게 어렵지 않을 것이라고 생각했습니다. 그런데, 유연한 컴포넌트를 만든 다는 것은 이 컴포넌트를 사용할 사용자가 굉장히 많은 방법으로 사용할 수있다는 것을 의미했고 이를 위해, ElementType 을 불러와야 했습니다. 또 그에 대한 Attributes 를 지정해 주어야 했고 마지막으로는 ref 까지 사용의 염두에 두어야 했습니다.

만들고 보니, 실제로 사용할 때에는 크게 유용하지 않을 것 같다라는 생각도 들었습니다. 태그의 시맨틱적으로 바꾸는 것이 좋지 않다고 느껴졌기 때문입니다. 예를 들어 Toggle 을 위한 button 태그를 div 태그로 바꾸는 것이 의미가 있을까요? 저는 이 부분은 시맨틱적으로 button 으로 유지해야 한다고 생각합니다.

그럼에도, div 또는 visaullayHidden 과 같은 컴포넌트를 작성할때에 다형적 컴포넌트를 사용하면 코드의 중복을 많이 줄일 수 있을 것 같습니다.