티스토리 뷰
React 다형성 컴포넌트 만들기 (범용성 높은 컴포넌트, Polymorphic, Typescript,Styled-Components), (ReactElement, ReactNode 에러)
YG - 96년생 , 강아지 있음, 개발자 희망 2023. 11. 1. 16:50개발자가 사용할 때 자유도가 높은 컴포넌트를 npm에 배포하고 싶었습니다.
원하는 동작은 아래와 같았습니다.
1. styled-component의 as 기능을 지원하고 싶었습니다.
2. forwardRef를 통해 ref를 받도록 지원하고 싶었습니다.
3. 추가적인 스타일을 사용하고 싶을 때, 인라인 스타일을 지원하고 싶었습니다.
1. as를 지원하기
styled-component에서 as 기능을 그대로 사용하여 구현하였습니다.
예시) as를 사용하는 예시, div를 button으로 사용하고 싶을 때
import styled from 'styled-components';
export default function Test() {
return <Container as="button">Test</Container>;
}
const Container = styled.div``;
1-1. as 속성에서 html 태그가 자동완성 기능
간단하게 구현하자면 이렇게 할 수 있습니다.
import { PropsWithChildren } from 'react';
import styled from 'styled-components';
interface TestProps extends PropsWithChildren {
/**
* HTML 태그를 문자열로 입력해 원하는 HTML 태그로 사용할 수 있습니다.
*/
as?: string;
}
export default function Test({ as, children }: TestProps) {
return <Container as={as}>{children}</Container>;
}
const Container = styled.div``;
하지만 아래의 사진과 같이 html 태그 이름을 자동 완성을 하고 싶은데요.
ElementType으로 as로 html 태그 자동 완성을 지원할 수 있습니다.
import { ElementType, PropsWithChildren } from 'react';
import styled from 'styled-components';
interface TestProps extends PropsWithChildren {
/**
* HTML 태그를 문자열로 입력해 원하는 HTML 태그로 사용할 수 있습니다.
*/
as?: ElementType;
}
export default function Test({ as, children }: TestProps) {
return <Container as={as}>{children}</Container>;
}
const Container = styled.div``;
1-2. as를 사용하여 태그를 변경했을 때 해당 html 태그의 attribute 속성들이 자동완성
ComponentPropsWidthoutRef <T> 타입을 이용하면 T에 해당하는 엘리먼트의 props에서 ref만 제외한 속성들을 타입으로 이용할 수 있습니다.
import {
ComponentPropsWithoutRef,
ElementType,
PropsWithChildren,
} from 'react';
import styled from 'styled-components';
interface TestProps<T extends ElementType> extends PropsWithChildren {
/**
* HTML 태그를 문자열로 입력해 원하는 HTML 태그로 사용할 수 있습니다.
*/
as?: T;
}
export type PolymorphicComponentProps<T extends ElementType> =
ComponentPropsWithoutRef<T> & TestProps<T>;
export default function Test<T extends ElementType = 'div'>({
as,
children,
...rest
}: PolymorphicComponentProps<T>) {
return (
<Container as={as} {...rest}>
{children}
</Container>
);
}
const Container = styled.div``;
2. forwardRef를 통해 ref를 받도록 지원하고 싶었습니다.
ref를 받을 때 forwardRef를 이용했는데요. ref가 필수가 아니고, 부모 컴포넌트에서 ref를 제어할 수 있다는 점에서 사용했습니다.
2.1 코드를 forwardRef를 사용하는 코드로 변경
ref를 해당 html 태그에 맞는 것을 사용했을 때만 사용될 수 있게 반환 타입을 작성했습니다.
export type PolymorphicComponentProps<T extends ElementType> =
ComponentPropsWithoutRef<T> &
TestProps<T> & {
ref?: PolymorphicRef<T>;
};
type TestComponent = <T extends ElementType>(
props: PolymorphicComponentProps<T>
) => ReactElement | null;
import {
ComponentPropsWithRef,
ComponentPropsWithoutRef,
ElementType,
PropsWithChildren,
ReactElement,
forwardRef,
} from 'react';
import styled from 'styled-components';
interface TestProps<T extends ElementType> extends PropsWithChildren {
/**
* HTML 태그를 문자열로 입력해 원하는 HTML 태그로 사용할 수 있습니다.
*/
as?: T;
}
export type PolymorphicRef<T extends ElementType> =
ComponentPropsWithRef<T>['ref'];
export type PolymorphicComponentProps<T extends ElementType> =
ComponentPropsWithoutRef<T> &
TestProps<T> & {
ref?: PolymorphicRef<T>;
};
type TestComponent = <T extends ElementType>(
props: PolymorphicComponentProps<T>
) => ReactElement | null;
const Test: TestComponent = forwardRef(function Test<
T extends ElementType = 'div'
>(
{ as, children, ...rest }: PolymorphicComponentProps<T>,
ref: ComponentPropsWithRef<T>['ref']
) {
return (
<Container ref={ref} as={as} {...rest}>
{children}
</Container>
);
});
export default Test;
const Container = styled.div``;
3. 추가적인 스타일을 사용하고 싶을 때, 인라인 스타일을 지원
CSSProperties 타입을 이용해서 인라인 CSS 속성을 자동 완성 되도록 할 수 있습니다.
interface TestProps<T extends ElementType> extends PropsWithChildren {
/**
* HTML 태그를 문자열로 입력해 원하는 HTML 태그로 사용할 수 있습니다.
*/
as?: T;
/**
* 디테일 한 CSS 속성을 지정해야 할 경우 직접 CSS를 입력할 수 있습니다.
*/
css?: CSSProperties;
}
const Test: TestComponent = forwardRef(function Test<
T extends ElementType = 'div'
>(
{ as, children, css, ...rest }: PolymorphicComponentProps<T>,
ref: ComponentPropsWithRef<T>['ref']
) {
return (
<Container ref={ref} as={as} style={css} {...rest}>
{children}
</Container>
);
});
공통 타입과 공통 스타일 정의하기
컴포넌트를 한 개 만들 때마다 매번 타입을 정의하기엔 비효율적입니다.
공통 타입과 공통 스타일을 지정해 두고 사용한다면 편하게 새로운 컴포넌트를 만들 수 있습니다.
공통으로 사용할 스타일 props들과 기본이 될 스타일드 컴포넌트 코드를 작성했습니다.
style/common.ts
m과 mx, px 등 컴포넌트에서 사용할 props들을 정의해 주었습니다.
import { CSSProperties } from 'react';
import styled from 'styled-components';
export interface CommonStyleProps {
/**
* 컴포넌트의 너비를 조정할 수 있습니다.
*/
width?: string;
/**
* true로 지정하면 화면의 전체의 너비를 차지합니다. (100vw)
*/
fullScreen?: boolean;
/**
* margin 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
m?: string;
/**
* margin에서 가로 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
mx?: string;
/**
* margin에서 세로 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
my?: string;
/**
* margin-left 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
ml?: string;
/**
* margin-right 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
mr?: string;
/**
* margin-bottom 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
mb?: string;
/**
* margin-top 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
mt?: string;
/**
* margin에서 가로 옵션을 auto로 조정할 수 있습니다.
*/
mxAuto?: boolean;
/**
* margin에서 세로 옵션을 auto로 조정할 수 있습니다.
*/
myAuto?: boolean;
/**
* padding 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
p?: string;
/**
* padding에서 가로 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
px?: string;
/**
* padding에서 세로 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
py?: string;
/**
* padding-left 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
pl?: string;
/**
* padding-right 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
pr?: string;
/**
* padding-bottom 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
pb?: string;
/**
* padding-top 옵션을 조정할 수 있습니다. EX) 10px, 10%
*/
pt?: string;
fontSize?: string;
fontWeight?: CSSProperties['fontWeight'];
textAlign?: CSSProperties['textAlign'] | string;
border?: string;
borderRadius?: string;
color?: string;
bgColor?: string;
}
const getMarginProperty = ({
m,
mx,
my,
mxAuto,
myAuto,
}: {
m?: string;
mx?: string;
my?: string;
mxAuto?: boolean;
myAuto?: boolean;
}) => {
if (m) {
return m;
}
if (mxAuto && myAuto) {
return 'auto auto';
}
if (mxAuto && my) {
return `${my} auto`;
}
if (myAuto && mx) {
return `auto ${mx}`;
}
if (mx && my) {
return `${my} ${mx}`;
}
if (mx) {
return `0 ${mx}`;
}
return `${my} 0`;
};
const getPaddingProperty = ({
p,
px,
py,
}: {
p?: string;
px?: string;
py?: string;
}) => {
if (p) {
return p;
}
if (px && py) {
return `${py} ${px}`;
}
if (px) {
return `0 ${px}`;
}
return `${py} 0`;
};
const getWidthProperty = ({
width,
fullScreen,
}: {
width?: string;
fullScreen?: boolean;
}) => {
if (fullScreen) {
return '100vw';
}
if (width) {
return width;
}
return '100%';
};
export const CommonTag = styled.div<CommonStyleProps>`
width: ${({ width, fullScreen }) => getWidthProperty({ width, fullScreen })};
padding: ${({ p, px, py }) => getPaddingProperty({ p, px, py })};
padding-left: ${({ pl }) => pl};
padding-right: ${({ pr }) => pr};
padding-top: ${({ pt }) => pt};
padding-bottom: ${({ pb }) => pb};
margin: ${({ m, mx, my, mxAuto, myAuto }) =>
getMarginProperty({ m, mx, mxAuto, my, myAuto })};
margin-top: ${({ mt }) => mt};
margin-bottom: ${({ mb }) => mb};
margin-right: ${({ mr }) => mr};
margin-left: ${({ ml }) => ml};
font-size: ${({ fontSize }) => fontSize};
font-weight: ${({ fontWeight }) => fontWeight};
text-align: ${({ textAlign }) => textAlign};
border: ${({ border }) => border};
border-radius: ${({ borderRadius }) => borderRadius};
color: ${({ color }) => color};
background-color: ${({ bgColor }) => bgColor};
`;
types/common.ts
Common이라는 타입에서 PropsWidthChildren과 공통으로 사용할 CSS Props 타입인 CommonStyleProps을 추가해 주고, 위에서 사용한 타입을 기반으로 작성해 줍니다.
import {
CSSProperties,
ComponentPropsWithRef,
ComponentPropsWithoutRef,
ElementType,
PropsWithChildren,
} from 'react';
import { CommonStyleProps } from '../style/common';
interface Common<T extends ElementType>
extends CommonStyleProps,
PropsWithChildren {
/**
* HTML 태그를 문자열로 입력해 원하는 HTML 태그로 사용할 수 있습니다.
*/
as?: T;
/**
* 디테일 한 CSS 속성을 지정해야 할 경우 직접 CSS를 입력할 수 있습니다.
*/
css?: CSSProperties;
}
export type PolymorphicRef<T extends ElementType> =
ComponentPropsWithRef<T>['ref'];
export type PolymorphicComponentProps<
T extends ElementType,
Props
> = Common<T> &
ComponentPropsWithoutRef<T> &
Props & {
ref?: PolymorphicRef<T>;
};
사용 예시
공통으로 사용하는 Props가 있지만, 각 컴포넌트마다 사용되는 Props가 다른 경우가 대부분입니다. 그래서 해당 컴포넌트에서 사용할 Props를 정의해 주고, PolymorphicComponentProps 타입을 통해 T와 해당 컴포넌트 Props를 인자로 줘서 사용했습니다.
Container.tsx
import { ElementType, ReactElement, forwardRef } from 'react';
import { PolymorphicComponentProps, PolymorphicRef } from '../types/common';
import * as S from './style';
export interface _ContainerProps {
/**
* 컴포넌트의 최소 너비를 지정하는 옵션입니다. 500px, 50%와 같이 문자열로 사용할 수 있습니다.
*/
minWidth?: string;
/**
* 컴포넌트의 최대 너비를 지정하는 옵션입니다. 500px, 50%와 같이 문자열로 사용할 수 있습니다.
*/
maxWidth?: string;
}
export type ContainerProps<T extends ElementType> = PolymorphicComponentProps<
T,
_ContainerProps
>;
type ContainerComponent = <T extends ElementType>(
props: ContainerProps<T>
) => ReactElement | null;
const Container: ContainerComponent = forwardRef(function Container<
T extends ElementType = 'div'
>({ css, as, children, ...rest }: ContainerProps<T>, ref: PolymorphicRef<T>) {
return (
<>
<S.Component ref={ref} as={as} style={{ ...css }} {...rest}>
{children}
</S.Component>
</>
);
});
export default Container;
Container.style.ts
컨테이너 컴포넌트에서 사용한 props와 공통으로 사용하는 props를 유니온 타입으로 만들어 사용했습니다.
그리고 공통으로 사용되는 CommonTag를 이용하여 컴포넌트를 만들었습니다.
import { styled } from 'styled-components';
import { CommonStyleProps, CommonTag } from '../style/common';
import { _ContainerProps } from '.';
type ComponentProps = _ContainerProps & CommonStyleProps;
export const Component = styled(CommonTag)<ComponentProps>`
min-width: ${({ minWidth }) => minWidth};
max-width: ${({ maxWidth }) => maxWidth};
`;
반환값 ReactElement와 ReactNode에 대한 오류 (ExoticComponent 타입), @types/react 에러
ReactElement로 컴포넌트의 반환값을 설정 후 다른 프로젝트에서 npm 패키지를 설치해서 사용했습니다. 하지만 오류가 났었고 이유를 알 수 없어 답답했었는데요. 이를 해결하기 위해 @types/react의 파일에서 해당 타입의 반환값 타입을 직접 변경해서 사용하기도 했었는데 이유를 알게 되어서 적게 되었습니다.
오류 예시)
const Grid << 이 부분에서 타입 에러가 납니다. 에러는 ForwardRefExoticComponent의 반환 값이 ReactNode에 해당하지 않아서 에러가 나는 것이였습니다.
```tsx
type GridComponent = <T extends ElementType>(
props: GridProps<T>
) => ReactElement | null;
const Grid : GridComponent = forwardRef(function Grid<
```
```
Type 'ForwardRefExoticComponent<Omit<GridProps<ElementType>, "ref"> & RefAttributes<unknown>>' is not assignable to type 'GridComponent'.
Type 'ReactNode' is not assignable to type 'ReactElement<any, string | JSXElementConstructor<any>> | null'.ts(2322)
```
문제가 되는 타입리엑트 17 버전의 ExoticComponent의 타입은 ReactElement | null로 되어있습니다.
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/v17/index.d.ts
그리고 23년 5~6월쯤 해당 타입 관련된 PR이 머지가 되면서 타입이 변경되었습니다.
해당 타입을 변경하게 된 이유는 아래와 같은데요. 이해하진 못했습니다.
결론
ReactElement를 반환값으로 하여 npm을 배포했을 때 @types/react 버전이 낮은 경우 타입 에러가 납니다. 반면에 ReactNode로 할 경우 최신 버전에서 에러가 나게 되어서 이러한 사실을 npm 패키지에 설명해 주시면 좋을 것 같아요
해당 코드가 사용된 레포지토리입니다
참고자료
'react' 카테고리의 다른 글
무한스크롤 웹 접근성 챙기기 (1) | 2023.11.21 |
---|---|
Eslint Import/Order (import 순서) 설정하기 (2) | 2023.05.12 |
CRA(create-react-app)로 만든 프로젝트 / Storybook 7 버전에서 절대 경로를 설정하는 방법 (with Typescript) / jest 절대 경로 설정하기 (0) | 2023.05.04 |
React에서 ApolloGraphQL을 사용한 graphQL 사용 방법 (5) | 2023.04.17 |
No routes matched location "/" 에러 해결 방법, error ,react-router-dom (0) | 2022.02.25 |
- Total
- Today
- Yesterday
- createPortal
- CLASS
- 원티드
- 우아한테크코스
- TopLayer
- 위코드
- 북클럽
- javascript
- env
- 스토리 북
- nextjs
- 아차산
- Storybook
- WSL2
- electron
- import/order
- error
- nodejs
- 초보
- 윤성우 열혈C프로그래밍
- 노개북
- 노마드코더
- NextApiRequest
- 프리온보딩
- NextRequest
- jest
- React
- 프론트앤드
- C언어
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |