티스토리 뷰

개발자가 사용할 때 자유도가 높은 컴포넌트를 npm에 배포하고 싶었습니다.

 

원하는 동작은 아래와 같았습니다.

 

1. styled-component의  as 기능을 지원하고 싶었습니다.

2. forwardRef를 통해 ref를 받도록 지원하고 싶었습니다.

3. 추가적인 스타일을 사용하고 싶을 때, 인라인 스타일을 지원하고 싶었습니다.

 

1. as를 지원하기

 

styled-component에서 as 기능을 그대로 사용하여 구현하였습니다.

 

 

styled-components: API Reference

API Reference of styled-components

styled-components.com

 

예시) 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``;

 

 

input에서는 max, maxLength가 보임
button에서는 max, maxLength가 보이지 않음

 


 

2. forwardRef를 통해 ref를 받도록 지원하고 싶었습니다.

 

ref를 받을 때  forwardRef를 이용했는데요. ref가 필수가 아니고, 부모 컴포넌트에서 ref를 제어할 수 있다는 점에서 사용했습니다.

 

 

 

forwardRef – React

The library for web and native user interfaces

react-ko.dev

 

 

 

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``;

 

 

ref는 a태그, Test 컴포넌트는 button일 때 에러가 납니다.
일치한다면 에러가 나지 않습니다.

 

 

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이 머지가 되면서 타입이 변경되었습니다.

 

 

 

 

🤖 Merge PR #65135 [react] Allow returning ReactNode from function com… · DefinitelyTyped/DefinitelyTyped@443451c

…ponents by @eps1lon * [react] Allow returning ReactNode from function components * [react] Ignore statics from element type checking would require a lot of work to fix the issues in the consumi...

github.com

 

 

해당 타입을 변경하게 된 이유는 아래와 같은데요. 이해하진 못했습니다.

 

 

 

 

[react] Allow returning ReactNode from function components by eps1lon · Pull Request #65135 · DefinitelyTyped/DefinitelyTyped

Note: This change only applies to TypeScript 5.1 and later Adds a new ElementType under the JSX namespace that is used by TypeScript 5.1 to determine if an element type is valid. This will allow f...

github.com

 

 

결론

 

ReactElement를 반환값으로 하여 npm을 배포했을 때 @types/react 버전이 낮은 경우 타입 에러가 납니다. 반면에 ReactNode로 할 경우 최신 버전에서 에러가 나게 되어서 이러한 사실을 npm 패키지에 설명해 주시면 좋을 것 같아요

 

 



 

해당 코드가 사용된 레포지토리입니다

 

 

GitHub - Gilpop8663/layout-component

Contribute to Gilpop8663/layout-component development by creating an account on GitHub.

github.com

 

 

참고자료

 

 

Polymorphic한 React 컴포넌트 만들기

들어가기에 앞서 Polymorphism 은 한국어로 다형성이라고 부르는데, 여러 개의 형태를 가진다 라는 의미를 가진 그리스어에서 유래된 단어다. 그럼 이 글의 제목에 포함된 Polymorphic 은 다형의 혹은

kciter.so

 

 

TypeScript로 React 다형성 컴포넌트 만들기

해당 글은 Iskander Samatov님의 React polymorphic components with TypeScript를 번역한 글입니다. 오역은 댓글로 남겨주시면 바로 반영하도록 하겠습니다.다형성 컴포넌트는 널리 사용되는 React 패턴입니다. 들

velog.io

 

 

React polymorphic components with TypeScript

Polymorphic component is a powerful React pattern for controlling how your components render in DOM.

itnext.io

 

 

Build strongly typed polymorphic components with React and TypeScript - LogRocket Blog

Learn how to build strongly typed polymorphic React components with TypeScript, using familiar Chakra UI and MUI component props as guides.

blog.logrocket.com

 

 

타입스크립트와 함께 컴포넌트를 단계 별로 추상화해보자

최근 필자가 활동하고 있는 루비콘 팀에서는 멘토링 프로젝트를 함께 진행했던 멘티 분들과 함께 lubycon-ui-kit이라는 작은 프로젝트를 시작했다. 뭐 시작한지 얼마 안 되어서 아직 아무 것도 없지

evan-moon.github.io

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
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
글 보관함