🔯 다형성 컴포넌트 시리즈
Polymorphic한 React 컴포넌트
디자인 시스템을 구성하면서, 다음과 같은 요구사항에 마주쳤다.
렌더링 될 태그를 정할 수 있는 Text 컴포넌트를 만들고 싶어요.
즉, h1이라는 props를 넘겨준다면 h1이라는 태그가 렌더링되고,
span이라는 props를 넘겨준다면, span 태그가 렌더링되는 Text 태그를 만들어야했다.
이를 어떻게 구현할 수 있을지 찾아보다가 Polymorphic한 컴포넌트(이하 다형성 컴포넌트)라는 개념을 발견했다.
Polymorphic이라는 것은 다형성을 띈~ 이라는 말인데, 상황에 따라 다른 형태로 적용할 수 있다는 것을 의미한다.
이러한 다형성 컴포넌트를 리액트에서 구현하는 방법을 몇몇 분들이 친히 서술해놓으셨기에
오늘은 이를 따라하면서, 어떻게 다형성 컴포넌트를 만드는지 알아보려고 한다.
1️⃣ 태그 이름을 props로 받아 렌더링하기
// Txt.tsx
type TxtPropsType = {} & React.ComponentProps<"span">;
export const Txt = ({ children, ...other }: TxtPropsType) => {
return <span {...other}>{children}</span>;
};
export default Txt;
위 코드로부터 시작해보자.
이 코드는 span 태그를 렌더링해주는 Txt 컴포넌트이다.
이 span 태그가 실제 span 태그처럼 동작하기 위해서는
span 태그가 가지고 있던 속성(Attribute)들을 이 컴포넌트에도 적용시킬 수 있어야 할 것이다.
이 속성들은 이미 React에서 다 Props로 지정을 해놓았다.
즉, 가져와서 사용하면 된다. 이는 아래 코드를 통해서 가져올 수 있다.
React.ComponentProps<"span">
이를 내가 넣고 싶은 Txt의 Props의 Type과 결합시켜 만든 것이 위의 TxtPropsType 이다.
(현재는 추가적인 props가 없음)
문제는 요구사항은 태그에 따라 바뀌는 컴포넌트인데 이 컴포넌트는 span 태그로 밖에 렌더링하지 못한다.
요구사항을 구현하기 위해서는 props로 태그명을 받아서 렌더링해주어야하는데, 이는 아래와 같은 코드로 구현할 수 있다.
export const Txt = ({ as, children, ...other }) => {
const Component = as || "span";
return <Component {...other}>{children}</Component>;
};
export default Txt;
먼저 as라는 props를 통해 태그 이름을 받아온다. 만약 태그 이름이 없다면 span 태그로 렌더링한다.
이 태그를 Component에 넣어서 이를 그대로 리액트 컴포넌트 사용하듯이 작성해주면 된다.
와, 벌써 포스팅이 끝났다.....가 아니라 이 상태로 사용하면 문제점이 있다.
바로, span 태그에서 href 속성을 사용해도 타입스크립트에서는 아무런 에러를 내주지 않는다는 것이다.
<Text as="span" href="https://www.tistory.com/">티스토리</Text> // TS : 에러 없눈뎅?
href는 a 태그에서 링크를 받기 위한 속성이라 span 태그가 가질 수 있는 속성이 아닌데
이렇게 잘못된 속성을 사용해도 TS가 잡아주지 못하는 문제가 발생한다.
이를 위해서는 TS의 제네릭을 통해서 props의 타입을 동적으로 정하는 방법을 사용해야한다.
2️⃣ 제네릭을 통해 Props 타입 동적으로 정의하기
type TxtPropsType<T extends React.ElementType> = {
as?: T;
} & React.ComponentPropsWithoutRef<T>;
export const Txt = <T extends React.ElementType = "span">({
as, children, ...other
}: TxtPropsType<T>) => {
const Component = as || "span";
return <Component {...other}>{children}</Component>;
};
export default Txt;
위 코드를 보자. 조금 더 복잡해졌지만 차근히 분해해보자.
export const Txt = <T extends React.ElementType = "span">({
as, children, ...other
}: TxtPropsType<T>) => {
const Component = as || "span";
return <Component {...other}>{children}</Component>;
};
Txt 컴포넌트에 제네릭 T를 설정해주었다.
침착하자. 함수형 컴포넌트는 함수다. 따라서 위 코드는 아래와 같다.
const identity = <T>(arg: T): T => {
return arg;
}
즉, Txt 컴포넌트는 TxtPropsType 타입의 props를 받아서 Component를 return하는 함수다.
그런데 T라는 React.ElementType 1을 확장한 타입을 입력받아 이를 TxtPropsType에 넘겨준다.
type TxtPropsType<T extends React.ElementType> = {
as?: T;
} & React.ComponentPropsWithoutRef<T>;
TxtPropsType은 as라는 속성과 ComponentPropsWithoutRef 2라는 React 유틸리티 타입이 병합된 결과이다.
ComponetPropsWithoutRef는 앞서 본 ComponentProps와 비슷하게
html 태그에 들어가는 React props 속성들을 정의한 타입이다.
여기에 T라는 타입의 as가 합쳐져서 TxtPropsType이 된다.
type propType<T> = {
args: T;
};
const testFunction = <T>(props: propType<T>): T => {
return props.args;
};
const testResult = testFunction({ args: "hello" });
//testFunction -> <string>(props: propType<string>) => string
T가 정해지는 과정은 타입 추론을 통해 일어난다.
이는 위 구조를 비슷한 형태로 간략하게 만들어본 내용인데,
testFunction을 실행할 때, 제네릭에 직접적으로 정보를 제공하지 않고, args가 string형태로 들어간다는 정보만 주었다.
그럼에도 여기서 제네릭 T 타입을 보면 args의 타입과 동일한 string으로 나온다.
타입 추론이 일어난 것이다.
위의 코드도 마찬가지다.
개발자가 props의 as에 "span"이라고 넣으면 이 T가 "span"이라는 태그로 타입 추론이 일어난다.
ComponentPropsWithoutRef에서 "span" 태그의 속성들을 가져와 as 속성과 병합하게 된다.
herf과 같이 span 태그가 가질 수 없는 속성들은 여기서 쳐내지게된다.
여기까지만 해도 위처럼 어느 정도 사용하는데는 문제가 없을 정도의 Text 컴포넌트를 만들 수 있다.
하지만 개발은 이렇게 간단한 컴포넌트만 사용하지 않으므로 몇 가지 후처리가 필요하다.
3️⃣ Omit을 통해 중복되는 속성 제거하기
만약에 Text 컴포넌트에 추가적인 props들을 정의해줘야한다면 어떨까?
color, variants, 그 외 추가적인 props 속성들이 기존의 html 속성의 이름과 겹친다면 문제가 발생한다.
이를 위해 Omit을 사용하여 처리를 해줘야한다.
type AddProps = {
color?: string;
fontSize?: string;
};
type TxtPropsType<T extends React.ElementType> = {
as?: T;
} & Omit<React.ComponentPropsWithoutRef<T>, keyof AddProps> & AddProps;
Omit은 TS의 유틸리티 타입으로, 타입에서 특정 속성들을 제외해준다.
Omit<타입, 제외될 속성들>
위 코드를 다시 보면,
Omit<React.ComponentPropsWithoutRef<T>, keyof AddProps>
ComponentPropsWithoutRef를 통해서 가져온 HTML 태그가 가질 수 있는 속성들 중에서
AddProps가 가지고 있는 key(속성)들을 제거한 타입이라는 것을 알 수 있다.
이를 통해 HTML 태그의 속성과 내가 추가한 속성들을 겹치지 않고 병합할 수 있다.
4️⃣ forwardRef 적용하기
ref는 React에서 렌더링을 통해 생성된 DOM 요소에 직접 접근할 수 있도록 해주는 객체이다.
HTML 요소는 ref 요소를 직접적으로 받을 수 있지만,
리액트 컴포넌트는 직접 ref를 받을 수 없기 때문에 forwardRef로 감싸줘야한다.
그런데 위를 통해 만든 다형성 컴포넌트에서 ref를 받아야한다면 기존과 똑같이 forwardRef로 감싸주면 되는걸까?
한 번 적용해보자.
export const Txt = forwardRef(
<T extends React.ElementType = "span">(
{ as, children, ...other }: TxtPropsType<T>,
ref: React.ComponentPropsWithRef<T>["ref"] //ref 파라미터 타입 정의
) => {
const Component = as || "span";
return (
<Component ref={ref} {...other}>
{children}
</Component>
);
}
);
ref의 타입은 ComponentPropsWithRef에 이미 정의가 되어있다. 따라서 이 타입에서 ref만 따로 빼와서 정의해준다.
와! 먼가 된다! 포스팅 끝!......이라고 하고 싶지만 여기엔 함정이 있다.
export default function Home() {
const divRef = useRef<HTMLDivElement>(null);
return (
<Txt ref={divRef} as="a" href="www.naver.com">
hello
</Txt>
);
}
TS에서 useRef를 통해 ref를 사용할 때는 제네릭을 통해 ref가 어떤 요소를 가르킬지 타입을 지정한다.
그런데 위 ref는 div 태그를 타입으로 가졌지만, a 태그의 ref에 넣어도 아무런 에러를 나타내지 않는 모습을 볼 수 있다.
(테스트해 본 결과 일부 tag 간 ref가 호환이 되는 경우도 있지만, a 태그와 div 태그는 명백히 호환이 불가능하다.)
왜 이런 문제가 발생하냐하면, forwardRef라는 함수를 사용할 때에도 제네릭을 통해 함수의 타입을 정의해줘야하기 때문이다.
const InputComponent = forwardRef<HTMLInputElement, InputPropsType>(
(props: InputPropsType) => <input {...props} />
);
문제는 함수를 호출할 때는 제네릭을 통해 위처럼 함수 타입을 정의해줄 수가 없다는데 있다.
그래서 forwardRef를 통해 정의해주는 대신, 컴포넌트의 타입을 통해 직접 ref의 타입을 정의해주어야한다.
좀 더 구체적으로 적어보면,
function forwardRef<T, P = {}>(
render: ForwardRefRenderFunction<T, P>
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
forwardRef는 위와 같은 정의를 가진다. 여기서 T가 RefAttribute를 정의하는데 사용된다.
![](https://blog.kakaocdn.net/dn/cxOuUB/btspNP71A4u/2quOxXz6Gywwy8NcyQMwgK/img.png)
그런데 우리가 forwardRef에서 제네릭을 통해 함수 타입을 정의해주지 못하면서,
T가 unknown이 되어 이런 문제가 발생한다.
Reference의 글은 이 ref 타입을 forwardRef가 아닌 컴포넌트의 타입에서 정의해주어
ref에 대한 타입을 체크해주는 방식으로 문제를 해결하려는 것으로 보인다.
type TxtComponentType = <T extends React.ElementType = "span">( // TxtComponentType 타입 정의
props: TxtPropsType<T>
) => React.ReactNode | null;
const Txt: TxtComponentType = React.forwardRef( // TxtComponentType 타입 선언
...
);
먼저 Txt 컴포넌트의 타입을 정의 후 Txt 컴포넌트에 선언해준다.
TxtComponentType은 제네릭 T를 TxtPropsType에 넘겨주고, TxtPropsType은 개발자가 props를 통해 정의한다.
TS는 위에서 본 것처럼 타입 추론을 통해 이 T를 정할 것이다.
근데, ref를 사용하면 props에 ref라는 속성이 없다고 뜬다.
ref도 다른 props들과 똑같이 Txt에 적히는데, 정작 우리가 정의한 TxtPropsType에는 ref라는 속성이 없으니 발생하는 문제다.
이를 해결하기 위해서는 2가지 방법이 있다.
1. ComponentPropsWithRef 사용
type TxtPropsType<T extends React.ElementType> = {
as?: T;
} & Omit<React.ComponentPropsWithRef<T>, keyof AddProps> & //ComponentPropsWithRef 사용
AddProps;
기존에 사용했던 코드는 굳이 ref를 넣을 필요가 없어서 ComponentPropsWithoutRef가 사용되었다.
그런데 이제 TxtPropsType에 ref가 정의되어야하므로 ComponentPropsWithRef를 사용해준다.
2. ref를 별도로 선언 후 병합
type PolymorphicRefType<T extends React.ElementType> = { // ref 선언
ref?: React.ComponentPropsWithRef<T>["ref"];
};
type TxtPropsType<T extends React.ElementType> = {
as?: T;
} & Omit<React.ComponentPropsWithoutRef<T>, keyof AddProps> &
AddProps &
PolymorphicRefType<T>; // ref 병합
첫 번째 방법과 동일한데, ref를 따로 분리해준 것 뿐이다.
결국 TxtPropsType에 ref를 넣어주는 것은 같다.
이 두 방법 중 하나를 사용한다면 아래와 같이 ref 타입이 unknown이 아닌 태그의 타입으로 지정되어있는 것을 볼 수 있다.
위 방법 말고도 다양한 방법이 궁금하다면 여기를 참고하는 것도 좋다.
마무리
import { ComponentPropsWithRef, ElementType, forwardRef } from "react";
type TextBasePropsType = {
...
}
type TextPropsType<T extends ElementType> = {
as?: T;
} & Omit<ComponentPropsWithRef<T>, keyof TextBasePropsType>;
type TextComponentType = <C extends React.ElementType = "span">(
props: TextPropsType<C>
) => React.ReactNode | null;
const TextComponent = <T extends ElementType>(
{ children, as, ...props }: TextPropsType<T>,
ref: React.ComponentPropsWithRef<T>["ref"]
) => {
const TextComponent = as || "span";
return (
<TextComponent
ref={ref}
{...props}
>
{children}
</TextComponent>
);
};
const Text: TextComponentType = forwardRef(TextComponent);
export default Text;
이렇게 다형성 컴포넌트에 forwardRef 까지 적용해볼 수 있었다.
부록 - 재사용가능하게 만들고 싶다면
type AsPropType<T extends React.ElementType> = {
as?: T;
};
export type PolymorphicRefType<T extends React.ElementType> = React.ComponentPropsWithRef<T>["ref"];
export type PolymorphicPropsType<T extends React.ElementType, Props = object> = AsPropType<T> &
Props &
Omit<React.ComponentPropsWithoutRef<T>, keyof Props> & {
ref?: PolymorphicRefType<T>;
};
export type PolymorphicComponentType<TDefault extends React.ElementType, K = object> = <
T extends React.ElementType = TDefault
>(
props: PolymorphicPropsType<T, K>
) => React.ReactNode | null;
위와 같이 타입을 정의해주면, as와 ref를 제외한 컴포넌트 본연의 Props만을 설정해서
다형성 컴포넌트의 Props를 쉽게 만들수 있다.
사용은 아래와 같이 하면 된다.
const Text: PolymorphicComponentType<"span", TextPropsType> = forwardRef(
<T extends React.ElementType>(
{ children, as, ...props }: PolymorphicPropsType<T, TextPropsType>,
ref: PolymorphicRefType<T>
) => {
const TextComponent = as || "span";
return (
<TextComponent ref={ref} {...props}>
{children}
</TextComponent>
);
}
);
export default Text;
훨씬 간결해진 모습이 보인다.
Reference
Forwarding refs for a polymorphic React component in TypeScript | Ben Ilegbodu
How to properly type polymorphic React components using forwardRef in TypeScript
www.benmvp.com
Polymorphic한 React 컴포넌트 만들기
들어가기에 앞서 Polymorphism 은 한국어로 다형성이라고 부르는데, 여러 개의 형태를 가진다 라는 의미를 가진 그리스어에서 유래된 단어다. 그럼 이 글의 제목에 포함된 Polymorphic 은 다형의 혹은
kciter.so
'Frontend > React' 카테고리의 다른 글
Icon 컴포넌트 만들기(with SVGR, 동적 import) (0) | 2023.08.08 |
---|---|
Tailwind에서 재사용 가능한 컴포넌트로(with cva, tailwind-merge) (0) | 2023.08.05 |
무한스크롤 컴포넌트 구현하기 -1- (0) | 2023.04.25 |
프론트엔드에서의 TDD (0) | 2023.04.08 |
[React] useAxios (0) | 2022.02.03 |