React 타입 뜯어보기

DefinitelyTyped에 정의된 리액트 타입을 살펴봅니다

2023-10-01에 씀

React 라이브러리에서는 자바스크립트 파일만을 내보내고 있습니다. (react/packages.json)

1 "exports": {
2 ".": {
3 "react-server": "./react.shared-subset.js",
4 "default": "./index.js"
5 },
6 "./package.json": "./package.json",
7 "./jsx-runtime": "./jsx-runtime.js",
8 "./jsx-dev-runtime": "./jsx-dev-runtime.js",
9 "./src/*": "./src/*"
10 },

리액트를 타입스크립트 환경에서 잘 사용하기 위해서는 리액트에서 내보내는 모듈의 타입이 정의된 의존성을 새로 설치해야 합니다. npm install -D @types/react @types/react-dom 으로 리액트 개발에 필요한 타입 선언을 설치할 수 있습니다.

이렇게 @types로 시작하는 패키지는 DefinitelyTyped 저장소에 있습니다. 여기에는 리액트 외에도 node, koa 등 다양한 라이브러리의 타입이 선언되어 있습니다.

리액트의 타입을 보려면 types/react/ts5.0/index.d.ts 파일을 확인하면 됩니다. 여기에는 리액트 18 버전의 타입이 선언되어 있습니다. 위와 같이 @types/react를 설치하면 이 파일에 선언된 타입을 사용하게 됩니다.

이번 포스트에서는 @types/react에서 제공하는 타입에 대해 살펴보겠습니다.

JSX.Element VS ReactElement VS ReactNode

리액트에서 Element는 리액트 앱을 구성하는 가장 작은 블럭입니다. 공식 문서에서 말하는 리액트 엘리먼트는 아래와 같습니다. (공식 문서 링크)

JSX 태그도 트랜스파일링 과정을 거치면 createElement 호출로 변환되기 때문에, 결국 리액트 엘리먼트는 ~createElement 함수 호출을 통해 생성된 값~이라고 볼 수 있습니다. 타입 선언 파일에서 createElement 함수의 타입을 확인해 보면, 여덟 개의 오버로드를 찾을 수 있습니다.

1// DOM Elements
2// TODO: generalize this to everything in `keyof ReactHTML`, not just "input"
3function createElement(
4 type: "input",
5 props?:
6 | (InputHTMLAttributes<HTMLInputElement> &
7 ClassAttributes<HTMLInputElement>)
8 | null,
9 ...children: ReactNode[]
10): DetailedReactHTMLElement<
11 InputHTMLAttributes<HTMLInputElement>,
12 HTMLInputElement
13>;
14function createElement<P extends HTMLAttributes<T>, T extends HTMLElement>(
15 type: keyof ReactHTML,
16 props?: (ClassAttributes<T> & P) | null,
17 ...children: ReactNode[]
18): DetailedReactHTMLElement<P, T>;
19function createElement<P extends SVGAttributes<T>, T extends SVGElement>(
20 type: keyof ReactSVG,
21 props?: (ClassAttributes<T> & P) | null,
22 ...children: ReactNode[]
23): ReactSVGElement;
24function createElement<P extends DOMAttributes<T>, T extends Element>(
25 type: string,
26 props?: (ClassAttributes<T> & P) | null,
27 ...children: ReactNode[]
28): DOMElement<P, T>;
29
30// Custom components
31function createElement<P extends {}>(
32 type: FunctionComponent<P>,
33 props?: (Attributes & P) | null,
34 ...children: ReactNode[]
35): FunctionComponentElement<P>;
36function createElement<P extends {}>(
37 type: ClassType<
38 P,
39 ClassicComponent<P, ComponentState>,
40 ClassicComponentClass<P>
41 >,
42 props?: (ClassAttributes<ClassicComponent<P, ComponentState>> & P) | null,
43 ...children: ReactNode[]
44): CElement<P, ClassicComponent<P, ComponentState>>;
45function createElement<
46 P extends {},
47 T extends Component<P, ComponentState>,
48 C extends ComponentClass<P>
49>(
50 type: ClassType<P, T, C>,
51 props?: (ClassAttributes<T> & P) | null,
52 ...children: ReactNode[]
53): CElement<P, T>;
54function createElement<P extends {}>(
55 type: FunctionComponent<P> | ComponentClass<P> | string,
56 props?: (Attributes & P) | null,
57 ...children: ReactNode[]
58): ReactElement<P>;

createElement 함수가 하는 일을 요약하자면, 생성될 엘리먼트의 타입과 프로퍼티, 자식 노드들을 받아서 엘리먼트를 생성해 반환하는 것입니다. createElement에는 크게 두 종류가 있습니다.

DOM Element를 만들 경우 엘리먼트의 타입은 DetailedReactHTMLElement, ReactSVGElement, DOMElement 타입을 가지게 되고, 커스텀 컴포넌트의 경우 FunctionComponentElement, CElement, ReactElement 타입을 가지게 됩니다. 이들은 모두 공통적으로 ReactElement를 상속받는 타입입니다.

ReactElement 인터페이스의 정의는 아래와 같습니다.

1interface ReactElement<
2 P = any,
3 T extends string | JSXElementConstructor<any> =
4 | string
5 | JSXElementConstructor<any>
6> {
7 type: T;
8 props: P;
9 key: Key | null;
10}

ReactElement는 세 가지 필드를 가지게 됩니다.

React DOM Element

DetailedReactHTMLElement와 ReactSVGElement의 인터페이스를 살펴보면 아래와 같습니다.

1interface DetailedReactHTMLElement<
2 P extends HTMLAttributes<T>,
3 T extends HTMLElement
4> extends DOMElement<P, T> {
5 type: keyof ReactHTML;
6}
7
8// ReactSVG for ReactSVGElement
9interface ReactSVGElement
10 extends DOMElement<SVGAttributes<SVGElement>, SVGElement> {
11 type: keyof ReactSVG;
12}

둘의 type 값이 각각 ReactHTML, ReactSVG의 키 중 하나로 정해져 있는 것을 확인할 수 있습니다.

ReactHTML과 ReactSVG는 모두 인터페이스입니다. HTML 혹은 SVG 노드 이름이 키가 되고, 그 Attribute와 HTML 엘리먼트 쌍이 키의 타입으로 들어가 있습니다. 타입 정의를 위해 필요한 값을 맵처럼 정의해 둔 인터페이스인 것 같습니다. 이 둘을 합쳐둔 JSX.IntrinsicElements도 정의되어 있으며, 비슷한 용도로 사용됩니다.

ReactElement 정리

JSX.Element

간혹 JSX.Element를 타입으로 사용하는 경우도 있는데, JSX.Element는 ReactElement와 완전히 동일합니다.

1declare global {
2 namespace JSX {
3 interface Element extends React.ReactElement<any, any> {}
4 interface ElementClass extends React.Component<any> {
5 render(): React.ReactNode;
6 }
7 }
8}

JSX 문법은 자바스크립트에서 지원하지 않는 문법이고, 그래서 이에 대한 타입을 정의하기가 어려웠다고 합니다. 따라서 확장자가 .(jsx|tsx) 인 파일에서는 이런 JSX 문법을 마주쳤을 때, 글로벌 JSX 네임스페이스에 정의된 Element 타입을 사용합니다.

그래서 글로벌 네임스페이스에 JSX.Element를 정의해주면 그 인터페이스의 타입을 따라갑니다.

ReactNode

가장 포괄적인 타입입니다. React의 JSX 트리 안에서 노드로 사용될 수 있는 모든 값을 포함합니다. 여기에는 ReactElement 뿐만 아니라 string, number, boolean, undefined, ReactFragment와 ReactPortal까지 포함됩니다. ReactElement가 가질 수 있는 children의 타입이 이 ReactNode 타입입니다.

ComponentProps

리액트 엘리먼트는 렌더링을 위해 필요한 값을 바깥으로부터 Prop을 통해 받아올 수 있습니다. 이 Prop의 타입은 ComponentProps 타입으로 표현됩니다.

1type ComponentProps<
2 T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>
3> = T extends JSXElementConstructor<infer P>
4 ? P
5 : T extends keyof JSX.IntrinsicElements
6 ? JSX.IntrinsicElements[T]
7 : {};

ComponentProps는 제네릭으로 컴포넌트의 타입을 받고, 이 타입에 따라 Prop이 가지는 값이 달라집니다.

기본적인 ComponentProps에 더해 children이나 ref가 포함된 타입도 정의되어 있습니다.

1type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };
2type ComponentPropsWithRef<T extends ElementType> = T extends new (
3 props: infer P
4) => Component<any, any>
5 ? PropsWithoutRef<P> & RefAttributes<InstanceType<T>>
6 : PropsWithRef<ComponentProps<T>>;
7type ComponentPropsWithoutRef<T extends ElementType> = PropsWithoutRef<
8 ComponentProps<T>
9>;

Ref

React에서 컴포넌트가 ‘값’을 기억하는 방법은 state와 ref 두 가지가 있습니다. state로 값을 저장할 경우, state가 변경될 때마다 컴포넌트가 다시 렌더링되는 반면, ref에 저장된 값은 변경되어도 렌더링을 발생시키지 않습니다. ref를 생성하기 위해서는 useRef 훅을 호출하면 됩니다. useRef의 타입 정의는 아래와 같습니다.

1/**
2 * 변경 가능한 ref 오브젝트를 반환한다. 이 오브젝트는 컴포넌트의 생명주기 동안 지속된다.
3 */
4function useRef<T>(initialValue: T): MutableRefObject<T>;
5function useRef<T = undefined>(): MutableRefObject<T | undefined>;
6/**
7 * 변경 불가능한 ref 오브젝트를 반환한다. 보통 React가 생성하는 DOM을 조작하기 위해 사용된다.
8 */
9function useRef<T>(initialValue: T | null): RefObject<T>;

useRef를 통해 반환되는 Ref의 타입은 MutableRefObjectRefObject 두 가지가 있습니다. initialValue로 null을 넘겨주면 변경 불가능한 RefObject로, 그렇지 않으면 변경 가능한 MutableRefObject로 타입이 추론됩니다.

1interface RefObject<T> {
2 readonly current: T | null;
3}
4interface MutableRefObject<T> {
5 current: T;
6}

위와 같이, RefObject의 current는 readonly로 설정되어 있습니다. 그래서 useRef에 최초 값으로 null을 주면 변경이 불가능합니다.

이처럼 current 값을 변경할 수 없는 RefObject리액트에 의해 관리되는 DOM 오브젝트에 접근하기 위해 사용됩니다. 리액트 엘리먼트가 만드는 DOM 노드에 접근이 필요한 경우, 아래와 같이 엘리먼트에 ref 프로퍼티를 넘겨주어 DOM 노드에 접근할 수 있습니다.

1const Component = () => {
2 const ref = useRef(null);
3 return <div ref={ref}>hello world!</div>;
4};

리액트는 업데이트 시점에 두 과정을 거칩니다.

리액트는 업데이트가 발생하기 직전에 ref.current 값을 null로 변경하고, 커밋 시점에 ref.current 값을 설정해 줍니다. ref의 값을 설정하는 책임을 리액트가 가질 수 있게 하기 위해서 current를 readonly로 설정한 것으로 보입니다.

리액트 엘리먼트의 ref 프로퍼티로 객체 뿐만이 아니라 함수도 넘겨줄 수 있습니다.

리액트 엘리먼트 Ref 프로퍼티의 타입은 LegacyRef로, 함수도 받을 수 있게 되어 있습니다.

1// Bivariance hack for consistent unsoundness with RefObject
2type RefCallback<T> = {
3 bivarianceHack(instance: T | null): void;
4}["bivarianceHack"];
5type Ref<T> = RefCallback<T> | RefObject<T> | null;
6type LegacyRef<T> = string | Ref<T>;

LegacyRef에는 string 타입이 포함되어 있는데, ref에 string을 사용하는 것은 deprecated된 상태입니다.

아주 과거 리액트 클래스 컴포넌트에서는 아래와 같이 ref에 string을 넘겨 사용할 수 있었습니다.

1class App extends React.Component {
2 componentDidUpdate() {
3 console.log(this.refs.hi.getBoundingClientRect());
4 }
5
6 render() {
7 return <div ref="hi" />;
8 }
9}

이는 다양한 이유로 deprecated 되었고, 현재에는 콜백 또는 객체만 ref에 전달하는 것이 권장되고 있습니다.

RefCallback을 보면 bivarianceHack을 사용해 타입을 정의한 것을 볼 수 있습니다.

1type RefCallback<T> = {
2 bivarianceHack(instance: T | null): void;
3}["bivarianceHack"];

Bivariance는 이변성이라고 번역되는데, 공변성과 반공변성을 모두 가지는 구조입니다. 즉, A가 B의 서브타입이면, 제네릭 T<A>도 T<B>의 서브타입이고, T<B>도 T<A>의 서브타입인 경우를 말합니다. 서브타입이라는 것은 A를 B에 할당 가능하다는 의미입니다.

타입스크립트 함수는 인자를 다루는 과정에서 이변성을 가지게 됩니다. 그러나 논리적으로는 반공변적으로 동작하는 것이 타당하므로, 함수가 반공변적으로 동작하게 하기 위해서 타입스크립트의 strictFunctionTypes 옵션을 true로 사용합니다.

Ref 타입에서는 RefCallback과 RefObject를 함께 사용해야 하는데, 이를 위해 RefCallback에 bivarianceHack을 적용해 준 것입니다.

관련해서 참고할만한 글

프로필 사진

조예진

이전 포스트
2023년 상반기 회고
다음 포스트
SSR 서버 만들기 (1) - React Tree를 렌더링해 응답하는 서버 만들기