Polymorphic React Component
다형성(Polymorphism)은 어떤 객체의 속성이나 기능이 상황에 따라 여러가지 형태를 가질 수 있는 성질을 의미합니다.
즉, Polymorphic React Component는 어떤 HTMLElement도 될 수 있는 컴포넌트라는 것을 의마합니다. 컴포넌트를 사용하다보면 때로는 스타일은 유지한채로 컴포넌트의 태그만 변경하고 싶은 경우가 있습니다. 예를 들어 텍스트가
a태그의 역할을 하여 링크를 연결하도록 쓰고 싶은 경우가 있겠습니다.Polymorphic React Component를 타입스크립트와 함께 type-safe 하도록 구현하기 위해 아래 글을 번역합니다.
'Type-safe polymorphic React components'를 번역하였습니 다. 원본 바로가기
Polymorphic Component은 props를 통해 리액트 컴포넌트를 사용하여 HTML 태그를 커스텀 할 수 있도록 하는 디자인 패턴입니다.
<Typography as='label'>foo</Typography>
Typography 는 다형성이 있습니다. 왜냐하면 생성될 HTML은 실제 label 태그가 되기 때문입니다.
<label>foo</label>
JavaScript를 이용하면 타입안정성은 신경쓰지 않아도 아주 쉽게 작동합니다.
const Typography = ({ as: Component = 'span' }) => {
return <Component />
}
하지만 컴포넌트의 타입을 엄격하게 관리하려면 훨씬 어려워집니다.
예를 들어, 특정 HTML element에 맞지 않는 props를 사용자가 전달하는 걸 허용하지 않는 경우가 있습니다. - span 요소는 for 속성을 받지 않아야 합니다.
// This should be fine
<Typography as="label" htmlFor='' />
// @ts-expect-error This shouldn't: htmlFor is not allowed in span elements
<Typography as="span" htmlFor="" />
그럼 지금부터 이 컴포넌트의 타입을 작성하겠습니다.
이 블로그는 타입스크립트 4.9.5 버전을 사용하여 작성하였습니다. 이 후 버전의 타입스크립트를 사용하면 에러가 발생 할 수 있습니다.
타입 안정적인 'as' prop
as prop은 span, div 등과 같은 HTML 태그만 하용합니다. 'foo' 같은 아무 문자열은 빌드가 안 됩니다.
<Typography as='foo' />
HTML 태그만 전달 받으려면 어떤 타입을 작성해야 할까요? 다행히 @types/react가 React.ElementType와 같은 타입을 제공해줍니다.
type TypographyProps = {
as?: React.ElementType
}
const Typography = ({ as: Component = 'span', ...rest }: TypographyProps) => {
return <Component {...rest} />
}
export default function App() {
return <>
{/* Type '"foo"' is not assignable to type 'ElementType<any> | undefined'. */}
<Typography as='foo' />
<Typography as='div' />
</>
}
제네릭을 이용한 Polymorphic props
지금부터 as prop을 바탕으로 허용가능한 props에 대해 적절한 타입이 필요합니다. 예를 들어 as가 label인 경우, htmlFor를 받을 수 있어야 합니다. 기본적으로 아래 컴포넌트는 span 요소이고, htmlFor를 허용하지 않습니다.
이게 가능하려면 제네릭 타입을 사용할 필요가 있습니다.
// TODO: implement PropsOf
type PropsOf<T> = {}
type TypographyProps<T extends React.ElementType> = {
as?: T
} & PropsOf<T>
const Typography = <T extends React.ElementType = 'span'>({
as,
...rest
}: TypographyProps<T>) => {
const Component = as ?? 'span'
return <Component {...rest} />
}
제레릭 타입이 무엇인가요?
제네릭 타입은 인자를 받아서 타입을 반환하는 함수입니다.
이 글에서 인자는 T이고 React.ElementType을 확장한다는 타입 제약조건을 가지고 있습니다. 그래서 무작위 문자열을 전달할 수 없고 유효한 리액트 엘리턴트를 전달해야 합니다.
지금부터 PropsOf를 작성하겠습니다. button 또는 label과 같은 유효한 리액트 요소를 받아 그 요소의 유효한 props를 가진 object를 반환하는 타입이 필요합니다.
다행히 이걸 위한 타입이 이미 존재합니다: React.ComponentPropsWithoutRef
type PropsOf<T extends React.ElementType> = React.ComponentPropsWithoutRef<T>
type TypographyProps<T extends React.ElementType = React.ElementType> = {
as?: T
} & PropsOf<T>
const Typography = <T extends React.ElementType = 'span'>({
as,
...rest
}: TypographyProps<T>) => {
const Component = as ?? 'span'
return <Component {...rest} />
}
export default function App() {
return (
<>
{/* These should be ok */}
<Typography data-foo='bar' />
<Typography as="label" htmlFor="my-input" />
{/* This should not, span elements don't accept htmlFor */}
<Typography htmlFor="my-input" />
{/* Neither this, div elements don't accept htmlFor */}
<Typography as="div" htmlFor="my-input" />
</>
)
}
운좋게도 현재 구현은 이미 커스텀 컴포넌트도 잘 처리합니다. - as에 넘기면 정상적으로 작동합니다.
예를 들어 react-router-dom 이나 next/link의 Link 컴포넌트로 링크를 만든다고 가정하겠습니다.
// A mock Link component
function Link(props: { to: string }) {
return <a href={props.to} />
}
export default function App() {
return (
<>
<Typography as={Link} to='#' />
{/* It has a error */}
<Typography to='#' />
</>
)
}
컴포넌트 자체 props 타입을 추가하기
자 이젠 Typography 컴포넌트가 variant 같은 자체 props를 가지길 원할겁니다. 다형성을 유지하면서 어떻게 자체 props를 가지게 할 수 있을까요?
새로운 제네릭 타입으로 PolymorphicProps라는 것을 만들면 가능합니다. PolymorphicProps은 요소 타입과 컴포넌트 자체 props를 인자로 받습니다.
그 인자들과, 요소 타입을 바탕으로 요소의 타입이 허용하는 모든 props와 컴포넌트 자체 props를 가져오면서도, 그 둘간의 잠재적인 충돌 또한 해결합니다:
type PropsOf<T extends React.ElementType> = React.ComponentPropsWithoutRef<T>
type PolymorphicProps<
T extends React.ElementType = React.ElementType,
TProps = {}
> = {
as?: T
} & TProps &
Omit<PropsOf<T>, keyof TProps | 'as'>
type BaseTypographyProps = {
variant: 'heading' | 'paragraph'
}
type TypographyProps<T extends React.ElementType = 'span'> = PolymorphicProps<
T,
BaseTypographyProps
>
const Typography = <T extends React.ElementType = 'span'>({
as,
...rest
}: TypographyProps<T>) => {
const Component = as ?? 'span'
// @ts-expect-error: FIXME this used to work in TypeScript 4.x but not anymore
return <Component {...rest} />
}
export default function App() {
return (
<>
<Typography variant="heading" data-foo="bar" />
<Typography variant="paragraph" as="label" htmlFor="my-input" />
</>
)
}
두 타입 간의 잠재적인 충돌 해결하기 위한 방법으로 Omit 타입을 사용하였습니다. 이 타입은 TProps에도 존재하는 PropsOf의 모든 props를 제거합니디. TProps는 우선순위 가집니다.
잘 모르는 경우에 대비해서, Omit은 내장 유틸 타입입니다. 자세한 내용은 타입스크립트 문서에서 확인하실 수 있습니다.
타입 안정적인 다형적 refs
as prop에 따라 엄격하게 타입이 정해진 컴포넌트의 ref props를 만들어보겠습니다. 정확히 의미하는바가 여기에 있습니다:
import { useRef } from 'react'
import { Typography } from './Typography'
export default function App() {
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<>
{/* This should be fine */}
<Typography as="button" ref={buttonRef} variant='paragraph' />
{/* This should be a type error... */}
<Typography as="label" ref={buttonRef} variant='paragraph' />
</>
)
}
현재까지 만든 컴포넌는 ref prop를 받지 않습니다. ref를 받기 위해서, PropsOf 타입을 React.ComponentPropsWithoutRef 대신 React.ComponentPropsWithRef를 사용하도록 변경해야 합니다.
type PropsOf<T extends React.ElementType> = React.ComponentPropsWithRef<T>
이 작업은 쉬운 부분이지만, 어떤 refs 전달하지 않았고, 우린 사실 forwardRef 함수를 사용할 필요가 있습니다.
안타깝게도 여기가 바로 어려워지는 부분입니다. - forwardRef는 다형성 컴포넌트를 다루는데 충분히 사용자 친화적이지 않아 보입니다.
이를 보여주기 위해서, 우리가 어떻게 일반적인 컴포넌트의 타입을 타입스크립트로 forwardRef를 사용하는지 보겠습니다.
import { forwardRef, useRef } from 'react'
type TypographyProps = {
variant: 'heading' | 'paragraph'
}
const Typography = forwardRef<HTMLSpanElement, TypographyProps>(
(props, ref) => {
return <span ref={ref} {...props} />
}
)
보시다시피, forwardRef 함수는 그 자체로 ref 요소의 타입과 해당 컴포넌트의 props를 인자로 받는 제네릭 함수입니다.
그러나 이것은 우리가 원하는 것을 달성하는 데 도움이 되지 않습니다… 우리가 원하는 것을 달성하기 위해서는 as 속성 값에 따라 조건적인 타입을 첫 번째 인자로 전달해야 합니다!
문제는 Ben Ilegbodu의 아티클에서처럼 타입 주석/타입 캐스팅에 의존하지 않는 한 해결할 수 없어 보입니다. 그래서 우리는 본질적으로 React.forwardRef 타입을 우회하고 컴포넌트 타입을 처음부터 다시 정의해야 합니다.
type PropsOf<T extends React.ElementType> = React.ComponentPropsWithRef<T>
type PolymorphicRef<T extends React.ElementType> =
React.ComponentPropsWithRef<T>['ref']
type PolymorphicProps<
T extends React.ElementType = React.ElementType,
TProps = {}
> = {
as?: T
} & TProps &
Omit<PropsOf<T>, keyof TProps | 'as' | 'ref'> & { ref?: PolymorphicRef<T> }
type BaseTypographyProps = {
variant: 'heading' | 'paragraph'
}
type TypographyProps<T extends React.ElementType = 'span'> = PolymorphicProps<
T,
BaseTypographyProps
>
type TypographyComponent = <T extends React.ElementType = 'span'>(
props: PolymorphicProps<T, TypographyProps<T>>
) => JSX.Element | null
const Typography: TypographyComponent = React.forwardRef(
<T extends React.ElementType = 'span'>(
props: TypographyProps<T>,
ref: PolymorphicRef<T>
) => {
const { as, ...rest } = props
const Component = as ?? 'span'
return <Component ref={ref} {...rest} />
}
)
import { useRef } from 'react'
export default function App() {
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<>
<Typography as="button" ref={buttonRef} variant="paragraph" />
<Typography as="label" ref={buttonRef} variant="paragraph" />
{/**
* Apparently this is fine... because HTMLSpanElement implements the
* HTMLElement interface, which the HTMLButtonElement inherits from.
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLSpanElement
*/}
<Typography ref={buttonRef} variant="paragraph" />
</>
)
}