React의 ComponentProps 타입 알아보기

jh·2024년 11월 28일

개요

공통 컴포넌트를 만들면서 radix-ui의 Slot 컴포넌트를 잘 사용하고 있는데, 이를 사용해서 컴포넌트를 만들게 되면

 const { asChild, ...primitiveProps } = props
      const Comp: any = asChild ? Slot : node
      return <Comp>...

이런 식으로 매번 asChild에 대한 타입과, 분기처리 로직이 반복되어 들어가야 해서 귀찮았는데 이를 radix-ui에서는 어떻게 처리하는지 살펴봤는데 이를 Primitive 라는 컴포넌트로 해결하고 있었다

그래서 직접 다운받아 사용하려고 봤더니 radix-ui 내부 로직이 적용되어 있어서 직접 옮겨서 사용하려다 타입 부분이 이해가 안가서 찾아봤다

Primitive 코드

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
const NODES = [
  "a",
  "button",
  "div",
  "form",
  "h2",
  "h3",
  "img",
  "input",
  "label",
  "li",
  "nav",
  "ol",
  "p",
  "span",
  "svg",
  "ul",
] as const

type Primitives = {
  [E in (typeof NODES)[number]]: PrimitiveForwardRefComponent<E>
} //key: Nodes들 , value: ref forwarding한 컴포넌트
type PrimitivePropsWithRef<E extends React.ElementType> =
  React.ComponentPropsWithRef<E> & {
    asChild?: boolean
  }

interface PrimitiveForwardRefComponent<E extends React.ElementType>
  extends React.ForwardRefExoticComponent<PrimitivePropsWithRef<E>> {}


const Primitive = NODES.reduce((primitive, node) => {
  const Node = React.forwardRef(
    (props: PrimitivePropsWithRef<typeof node>, forwardedRef: any) => {
      const { asChild, ...primitiveProps } = props
      const Comp: any = asChild ? Slot : node

      return <Comp {...primitiveProps} ref={forwardedRef} />
    },
  )

  Node.displayName = `Primitive.${node}`

  return { ...primitive, [node]: Node }
}, {} as Primitives)

const Root = Primitive

export { Primitive, Root }
export type { PrimitivePropsWithRef }

자주 사용하는 DOM elements의 문자열 리터럴들을 배열에 등록해주고,

reduce를 통해 각 element마다 위에서 해줬던 처리(asChild,Comp)를 해주고, ref forwarding까지 해준 뒤 key는 element, value는 컴포넌트 형태로 Primitive 라는 객체에 저장한다 (이 Primitive의 타입은 Primitives 이다)

이제 컴포넌트를 사용할때 Primitive.button 이런 식으로 사용하면 된다

컴포넌트의 props type은 PrimitivePropsWithRef<E extends React.ElementType> 인데,
이는 React.ComponentPropsWithRef<E>{asChild?:boolean} 을 추가한 타입이다

  • button이라면, button 기본 props(onClick,id,className...) + ref + asChild

이 객체의 타입을 Primitives라는 타입인데

type Primitives = {
  [E in (typeof NODES)[number]]: PrimitiveForwardRefComponent<E>
}

key의 경우 nodes 배열에 있는 element이고, value(컴포넌트 타입)의 경우
PrimitiveForwardRefComponent<E> 인데

extends React.ForwardRefExoticComponent<PrimitivePropsWithRef<E>> 이다

이 중에서 PrimitivePropsWithRef<E> 는 위에서 살펴봤고, React.ForwardRefExoticComponents 는 ref forwarding된 컴포넌트의 리턴 타입이다

  • Primitives 안에 들어가는 컴포넌트들은 모두 ref forwarding 되어있음

사용 방법

export interface SelectTriggerProps
  extends ComponentPropsWithoutRef<typeof Primitive.button> {
  placeholder?: string
}

export const SelectTrigger = forwardRef<
  React.ElementRef<typeof Primitive.button>,
  SelectTriggerProps
>((props, ref) => {
            return <Primitive.button {...props}/>

Primitive 컴포넌트를 사용할 거라면 props type은 ComponentPropsWithoutRef<typeof Primitive.element> 을 확장하여 사용하면 되는데,

여기서 어떻게 ref forwarding된 컴포넌트 타입에서 props의 타입만 빼올 수 있는걸까? 궁금해져서 알아봤다

제일 먼저 ComponentPropsWithoutRef 를 찾아보면

    type ComponentPropsWithoutRef<T extends ElementType> = PropsWithoutRef<ComponentProps<T>>;

여기서 또 궁금해지는 건

  1. typeof Primitive.button 이 어떻게 ElementType 에 할당 가능할까?
  • 지금까지 ElementType은 'div'|'span' 이걸로만 알고 있었다
type ElementType<P = any, Tag extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements> =
        | { [K in Tag]: P extends JSX.IntrinsicElements[K] ? K : never }[Tag]
        | ComponentType<P>;

ElementType에 제네릭을 명시하지 않으면 ElementType<any,keyof JSX.IntrinsicElements> 가 되고,

  • keyof JSX.IntrinsicElements는 존재하는 html element('div','span')들의 유니온타입

  • P는 any이기 때문에 P extends JSX.IntrinsicElements[K] 을 만족한다
    { [K in Tag]: P extends JSX.IntrinsicElements[K] ? K : never }
    {div : 'div', span : 'span'} 이런 식의 타입이 되고
    여기에 [Tag] 로 접근하면 최종적으로는 이런 식이 된다

type ElementType = 
  | 'div' | 'span' | 'button' | //html 태그
  | ComponentType<any>  // React 컴포넌트 타입

하지만 Primitive.button 은 첫번째가 아닌 ComponentType<any> 를 충족하기 때문에 ElementType을 만족한다

  1. 어떻게 ComponentPropsWithoutRef<typeof Primitive.button>Primitive.button 의 props type을 추론할 수 있을까?
  type ComponentPropsWithoutRef<T extends ElementType> = PropsWithoutRef<ComponentProps<T>>;

PropsWithoutRef<ComponentProps<T>> 에서 ComponentProps를 먼저 보면 주석이 적혀있는데

    * Used to retrieve the props a component accepts with its ref. Can either be
    * passed a string, indicating a DOM element (e.g. 'div', 'span', etc.) or the
    * type of a React component.

ref와 함께 컴포넌트의 props를 추출하는데, DOM element의 string이나, React component의 타입이 가능하다고 써 있다

type ComponentProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = T extends
       JSXElementConstructor<infer P> ? P
       : T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T]
       : {};
  1. typeof Primitive.button 은 두번째 JSXElementConstructor<any> 에 해당
    (JSXElementConstructor<any> 는 props => component 같은 함수 타입)
  2. T extends JSXElementConstructor<infer P> ? P 를 통해 P는 PrimitivePropsWithRef<'button'> -> primitive.button의 props type을 추론한다
  3. 마지막 PropsWithoutRef 는 ref를 제외해주는 타입이다
type PropsWithoutRef<P> =
        P extends any ? ("ref" extends keyof P ? Omit<P, "ref"> : P) : P

결론

  1. ComponentProps 라는 타입에서 infer를 통해 (ref forwarding이 적용되었더라도) 컴포넌트의 props type만 추출할 수 있다
  • 생각해보면 어차피 컴포넌트는 함수이고,
    infer를 사용한 ReturnType으로 Promise의 리턴 타입도 추출할 수 있으니 당연히 컴포넌트의 props(파라미터)만 추출하는 것도 가능하다
  1. ElementType은 생각보다 넓은 타입이다.
    정말 완벽하게 DOM 엘리먼트를 나타내는 문자열 ('div', 'span' 등)만 필요하다면 keyof JSX.IntrinsicElements 를 한번 고려해보는 것도 좋을 것 같다

0개의 댓글