🔯 다형성 컴포넌트 시리즈
⚠️ 본 게시글은 이론적으로 검증된 방법이 아닌 시행착오 끝에 구현되는 방법을 찾아 기술한 글로
잘못되거나 정확하지 않은 내용을 포함할 수 있습니다.
다형성 컴포넌트에서 As 속성을 제한해보자
지난 글에서는 as 속성에 따라 다르게 렌더링되는 다형성 컴포넌트를 어떻게 만드는지 살펴봤었다.
그런데 새로운 요구사항이 등장했다.
음...그런데 Button 컴포넌트가 button 태그나 a 태그로만 렌더링 되게 할 수는 없나요?
물론 타입을 2개를 만든다던가 하는 방식으로 구현이 가능하지만,
오늘은 기존에 만들었던 다형성 컴포넌트에 변주를 주어 조금 더 일반화 된 방법으로 as를 제한하는 방법을 적어보려한다.
위에서도 언급했지만 이 방법은 이론적으로 깔끔하거나 검증된 방법이 아니라
여러가지를 파보고 시도해본 결과 구현된 방법이기 때문에 더 좋은 개선책이나 방법이 존재할 수 있다.
⚠️ 변경사항
forwardRef 미적용
먼저 지난 다형성 컴포넌트와 달리 forwardRef를 적용하는 방법은 포기했다.
forwardRef 자체가 다형성 컴포넌트와 엮기가 매우 힘들기 때문에
props 내에 innerRef라는 속성을 따로 만들어 이 타입을 통해 ref를 전달하도록 변경했다.
renderAs 속성 사용
as 속성의 이름을 renderAs로 변경했다.
이는 next/Link 컴포넌트에 as 속성이 이미 있어서 겹치기 때문인데, 이름만 변경되었을 뿐 큰 상관은 없다.
✅ 구현 기준
As를 제한하면서 만족해야할 몇 가지 충족해야할 기준들을 작성했다.
1. as 속성에서 TypeScript의 자동 완성이 지원되는가?
가장 중요시 여겼던 부분으로 as 속성에서 타입스크립트의 자동 완성 기능이 지원되어야한다.
2. as를 지정하지 않을 시 TypeScript 상에서 렌더링 기본값 타입이 지정이 되는가?
as를 직접적으로 지정하지 않아도 기본값으로 지정된 태그의 props 타입을 따라야한다.
3. as로 지정한 값들 사이에서 ref 타입 구분이 되는가?
예를 들어 as에 button 태그와 a 태그를 지정할 수 있다면 button 태그일 경우 aRef를 사용하거나
a 태그일 경우 buttonRef를 사용하면 TypeScript 오류가 나야한다.
4. Storybook에서 ArgsType을 불러올 수 있는가?
스토리북의 autodocs 기능을 통한 문서화를 진행 중이기 때문에 이 조건 역시 포함했다.
위 구현 사항을 충족하면서 as 속성을 제한할 수 있는 타입을 구현해보자.
⏹️ Box 컴포넌트
Box는 무엇이든 될 수 있는 가장 기본적인 다형성 컴포넌트이다.
우리가 지난 글에서 만들었던 다형성 컴포넌트가 바로 이 Box 컴포넌트라고 생각하면 된다.
(Box든 Slot이든 이름은 상관없지만 나는 Box라고 이름 붙였다.)
이 컴포넌트가 필요한 이유는 as 속성을 제한할 경우 컴포넌트의 타입도 지정해주어야 TS 에러가 나지 않기 때문이다.
즉, 필요한 경우에 특정 타입의 다형성 컴포넌트로 사용할 수 있도록 범용적인 "Box" 컴포넌트를 만든다.
import { PolymorphicPropsWithInnerRefType } from "@customTypes/polymorphicType";
/**
* 무엇이든 될 수 있는 기본 다형성 컴포넌트입니다.
*
* T를 통해 컴포넌트의 타입을 지정할 수 있습니다.
*/
const Box = <T extends React.ElementType = "div">({
renderAs,
innerRef,
...props
}: PolymorphicPropsWithInnerRefType<T>) => {
const Root = renderAs || "div";
return <Root ref={innerRef} {...props}></Root>;
};
export default Box;
여기서 Box는 제네릭 `T`를 받아서 T 타입의 Box 컴포넌트를 return한다.
✏️ Polymorphic Type 수정
이제 지난 글에서 만들었던 Polymorphic Type을 수정해준다.
사실 아이디어는 간단한데, Polymorphic Type이 받는 제네릭 타입을 `T`와 `A`로 나누어서 받는다.
여기서 `T`는 컴포넌트가 기본적으로 렌더링 될 `DefaultType`과 그 밖에 렌더링 될 수 있는 `AlternateAs`의 유니온 타입이고,
기본값으로 `DefaultType`이 지정되어 들어온다. (하단 Usage를 참고)
`A`는 `AlternateAs `타입이고 기본값으로 `T`가 지정되어 있다.
T extends DefaultType | AlternateAs = DefaultType
A extends AlternateAs = AlternateAS
굳이 이렇게 한 이유는 1번 조건이었던 TS 자동완성 때문인데, 나눠서 받지 않으면 TS에서 as 속성에 자동완성 지원이 안된다.
// As 속성
type AsPropsType<T extends React.ElementType> = {
/** 렌더링할 태그 속성 */
renderAs?: T;
};
// innreRef : ref 대체 속성
// T를 받아서 T타입의 ref 타입 지정
type InnerRefType<T extends React.ElementType> = {
innerRef?: React.ComponentPropsWithRef<T>["ref"];
};
// innerRef가 없는 다형성 Props Type
// T와 A를 받아서 AsProps에 넘겨줌
export type PolymorphicPropsType<
T extends React.ElementType,
Props = object,
AlternateAs extends React.ElementType = T
> = AsPropsType<T | AlternateAs> & Props & Omit<React.ComponentPropsWithoutRef<T>, keyof Props>;
// 다형성 Props 타입에 innerRef 추가
export type PolymorphicPropsWithInnerRefType<
T extends React.ElementType,
Props = object,
AlternateAs extends React.ElementType = T
> = PolymorphicPropsType<T, Props, AlternateAs> & InnerRefType<T>
- Usage
이제 타입을 만들었으니 사용해보자.
먼저 컴포넌트 단에서 사용시 아래와 같이 DefaultType과 AlternateAs 타입을 지정해준다.
/** Button 컴포넌트 기본 타입 */
export type ButtonDefault = "button";
/** Button 컴포넌트가 렌더링될 수 있는 다른 타입 */
export type ButtonAlterAs = "a" | "div"
/** 커스텀 버튼 속성 */
export type ButtonBasePropsType = {
/** 렌더링할 Icon 컴포넌트
*
* @type Icon
*/
icon?: React.ReactElement;
/** 버튼 내 아이콘 위치 */
iconPosition?: "before" | "after";
};
그 다음 as를 제한할 컴포넌트인 Button 컴포넌트를 아래와 같이 작성한다.
const Button = <
T extends ButtonDefault | ButtonAlterAs = ButtonDefault,
A extends ButtonAlterAs = ButtonAlterAs
>({
renderAs,
children,
...props
}: PolymorphicPropsWithInnerRefType<T, ButtonBasePropsType, A>) => {
return (
<Box<ButtonDefault | ButtonAlterAs>
renderAs={renderAs || "button"}
{...props}
>
{children}
</Box>
);
};
export default Button;
먼저 Button 컴포넌트는 제네릭 `T`와 `A`를 받아서 PolymorphicProps에 `T`와 `A`를 넘겨준다.
여기서 `T`는 `DefaultType`과 `AlterAs` 타입의 유니온 타입으로 기본값으로 `DefaultType`이 설정된다.
props의 타입은 `PolymorphicPropsWithInnerRefType`(혹은 `PolymorphicPropsType`)으로
여기에 `T`와 커스텀 Props 타입인 `BasePropsType` 그리고 `A`를 전달한다.
Button은 Root 컴포넌트로 Box를 사용한다.
이 때 Box에 `DefaultType`과 `AlterAs`를 넣어서 Box가 두 타입을 모두 받을 수 있게 만들어준다.
(코드 짜면서 처음보는 형태인데 Box도 함수니까....제네릭을 넣을 수 있는게 맞더라.)
그 밑에서는 renderAs 속성에 renderAs를 넣거나 혹은 기본값(`"button"`)을 넣어준다.
renderAs를 별도로 설정해야하는 이유는 타입 쪽에서는 타입을 체크하기 위한 기본값이 설정되어있지만,
JSX 쪽에서는 기본값이 설정되어있지 않기 때문이다. 이 둘은 별개다.
이제 이 타입이 정상적으로 동작하는지 체크해보자.
먼저 renderAs에서 위와 같이 TS 자동완성이 잘 동작하는 것이 보인다.
또, renderAs를 설정하지 않아도 타입의 기본값이 DefaultType("button")으로 설정되어있어
a 태그의 속성인 href를 사용 시 에러가 나는 모습이 보인다.
반대 역시 마찬가지이고 ref 타입에 대해서도 정상적으로 에러 표시를 해주는 모습이 보인다.
as 속성에서 TypeScript의 자동 완성이 지원되는가? | ✅ |
as를 지정하지 않을 시 TypeScript 상에서 렌더링 기본값 타입이 지정이 되는가? | ✅ |
as로 지정한 값들 사이에서 ref 타입 구분이 되는가? | ✅ |
Storybook에서 ArgsType을 불러올 수 있는가? | ✅ |
🔴 CommponentType으로 일반화
type PolymorphicBaseType<T extends React.ElementType, Props = object, A = T> = {
renderAs?: T | A;
innerRef?: React.ComponentPropsWithRef<T>["ref"];
} & Props &
Omit<React.ComponentPropsWithoutRef<T>, keyof Props>;
export type PolymorphicComponentType<
DefaultType extends React.ElementType,
Props = object,
AlternateAs extends React.ElementType = DefaultType
> = <T extends DefaultType | AlternateAs = DefaultType, A extends AlternateAs = AlternateAs>(
props: PolymorphicBaseType<T, Props, A>
) => JSX.Element;
위 타입들을 이용하여 함수 타입으로 작성할 수도 있다.
- Usage
const Button: PolymorphicComponentType<ButtonDefault, ButtonBasePropsType, ButtonAlterAs> = ({
renderAs,
children,
...props
}) => {
return (
<Box<ButtonDefault | ButtonAlterAs>
renderAs={renderAs || "button"}
{...props}
>
{children}
</Box>
);
};
export default Button;
위에 비해 굉장히 간략화된 모습이다.
단, 이 경우 Storybook에서 argsType을 인식하지 못하는 문제가 발생한다.
스토리북을 사용하지 않는다면 고려해볼만하다.
as 속성에서 TypeScript의 자동 완성이 지원되는가? | ✅ |
as를 지정하지 않을 시 TypeScript 상에서 렌더링 기본값 타입이 지정이 되는가? | ✅ |
as로 지정한 값들 사이에서 ref 타입 구분이 되는가? | ✅ |
Storybook에서 ArgsType을 불러올 수 있는가? | ❌ |
마무리
2편이 나올 줄은 몰랐는데 욕심 때문에 별별 방법을 시도해보다가 이런....게시글도 적게 되었다.
온라인 상에 관련 글도 없어서 직접 디자인 시스템들도 까보고, 구별된 유니온으로 구현도 해보고 했는데
위 방법이 내가 찾아낸 방법 중 유일하게 조건들을 전부 만족시킬 수 있는 방법이었다.
물론 다른 TypeScript 버전이나 환경에서는 어떻게 될지 모르겠지만........일단은 직접 구현해본 것에 만족한다.
혹시나 다형성 컴포넌트에서 as를 제한하는 방법을 찾는 사람들에게 이 글이 자그마한 도움이 되었으면 좋겠다.
Thanks to
creating typesafe polymorphic react components
We're attempting to bring a little more type safety to a bunch of polymorphic react design system components (eg ONLY allow htmlFor prop on label tags). Below is a contrived example where we have a...
stackoverflow.com
'Frontend > React' 카테고리의 다른 글
재사용할 수 있는 상호작용 상태 만들기 (0) | 2024.03.15 |
---|---|
태그에 따라 바뀌는 React 컴포넌트 만들기 3 - 컴포넌트 별칭 정하기 (0) | 2023.09.06 |
Icon 컴포넌트 만들기(with SVGR, 동적 import) (0) | 2023.08.08 |
Tailwind에서 재사용 가능한 컴포넌트로(with cva, tailwind-merge) (0) | 2023.08.05 |
태그에 따라 바뀌는 React 컴포넌트 만들기(with TypeScript) (0) | 2023.08.03 |