Part8_JSX_to_TSX
장경은
책 읽기

[8장] JSX에서 TSX로

8.1 리액트 컴포넌트의 타입

함수 컴포넌트 타입

// 함수 선언을 사용한 방식
function Welcome(props: WelcomeProps): JSX.Element {}
 
// 함수 표현식을 사용한 방식 - React.FC 사용
const Welcome: React.FC<WelcomeProps> = ({ name }) => {};
 
// 함수 표현식을 사용한 방식 - React.VFC 사용
const Welcome: React.VFC<WelcomeProps> = ({ name }) => {};
 
// 함수 표현식을 사용한 방식 - JSX.Element를 반환 타입으로 지정
const Welcome = ({ name }: WelcomeProps): JSX.Element => {};
  • 함수 표현식을 사용하여 함수 컴포넌트를 선언할 때 가장 많이 볼 수 있는 형태는 React.FC 혹은 React.VFC로 타입을 지정하는 것이다.
  • FC는 FunctionComponent의 약자이다.
  • 먼저 React.FC가 등장하고 이후 @types/react 16.9.4버전에서 React.VFC 타입이 추가되었다.
    • 둘 사이에는 children이라는 타입을 허용하는지 아닌지에 따른 차이를 보인다.
    • React.FC는 암묵적으로 children을 포함하고 있기 때문에 해당 컴포넌트에서 children을 사용하지 않더라도 children props를 허용한다.
    • 따라서 children props가 필요하지 않은 컴포넌트에서는 더 정확한 타입 지정을 하기 위해 React.FC보다 React.VFC를 많이 사용한다.
  • 하지만 리액트 v18로 넘어오면서 React.VFC가 삭제되고 React.FC에서 children이 사라졌다.
    • 그래서 앞으로는 React.VFC 대신 React.FC 또는 props 타입/반환 타입을 직접 지정하는 형태로 타이핑해줘야 한다.

Children props 타입 지정

  • 가장 보편적인 children 타입은 ReactNode | undefined가 된다.
  • ReactNode는 ReactElement 외에도 boolean, number 등 여러 타입을 포함하고 있는 타입으로, 더 구체적으로 타이핑 하려면 아래와 같이 해줄 수 있다.
// example 1
type WelcomeProps = {
    children: '천생연분' | '더 귀한 분' | '귀한 분' | '고마운 분';
};
 
// example 2
type WelcomeProps = { children: string };
 
// example 3
type WelcomeProps = { children: ReactElement };

render 메서드와 함수 컴포넌트의 반환 타입 - React.ReactElement vs JSX.Element vs React.ReactNode

interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
    type: T;
    props: P;
    key: Key | null;
}
  • React.createElement를 호출하는 형태의 구문으로 변환하면 React.createElement의 반환 타입은 ReactElement이다.

    • 리액트는 실제 DOM이 아니라 가상Virtual의 DOM을 기반으로 렌더링하는데 가상 DOM의 엘리먼트는 ReactElement 형태로 저장된다.
    • 즉, ReactElement 타입은 리액트 컴포넌트를 객체 형태로 저장하기 위한 포맷이다.
    • ReactElement 타입은 JSX의 createElement 메서드 호출로 생성된 리액트 엘리먼트를 나타내는 타입이라고 볼 수 있다.
    • ReactElement 타입은 다음과 같이 제네릭을 활용하여 Props 타입까지 명시해줄 수 있다.
interface IconProps {
    size: number;
}
 
interface Props {
    // ReactElement의 props 타입으로 IconProps 타입 지정
    icon: React.ReactElement<IconProps>;
}
 
const Item = ({ icon }: Props) => {
    // icon prop으로 받은 컴포넌트의 props에 접근하면, props의 목록이 추론된다
    const iconSize = icon.props.size;
 
    return <li>{icon}</li>;
};
declare global {
    namespace JSX {
        interface Element extends React.ReactElement<any, any> {}
    }
}
  • JSX.Element 타입은 앞의 코드를 보면 알 수 있다시피 리액트의 ReactElement를 확장하고 있는 타입이며, 글로벌 네임스페이스에 정의되어 있어 외부 라이브러리에서 컴포넌트 타입을 재정의할 수 있는 유연성을 제공한다.
    • 이러한 특성으로 인해 컴포넌트 타입을 재정의하거나 변경하는 것이 용이해진다.
    • JSX.Element는 ReactElement의 제네릭으로 props와 타입 필드에 대해 any 타입을 가지도록 확장하고 있다.
    • 즉, JSX.Element는 ReactElement의 특정 타입으로 props와 타입 필드를 any로 가지는 타입이라는 것을 알 수 있다.
    • JSX.Element 타입으로 선언하고 해당 Element의 props에 접근하는 것도 가능하다.
type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
type ReactFragment = {} | Iterable<ReactNode>;
 
type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;
  • ReactNode타입은 단순히 ReactElement 외에도 boolean, string, number 등의 여러 타입을 포함하고 있다.
    • 즉, ReactNode는 리액트의 render 함수가 반환할 수 있는 모든 형태를 담고 있다고 볼 수 있다.
    • children에 자주 사용하게 되며, 리액트 내장 타입인 PropsWithChildren 타입도 ReactNode 타입으로 children을 선언하고 있다.
스크린샷 2024-05-18 오후 11 20 11

리액트에서 기본 HTML 요소 타입 활용하기

  • HTML 태그의 속성 타입을 활용하는 대표적인 2가지 방법은 리액트의 DetailedHTMLProps와 ComponentPropsWithoutRef 타입을 활용하는 것이다.

  • React.DetailedHTMLProps를 사용하는 경우는 다음과 같이 타입을 선언할 수 있다.

type NativeButtonProps = React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
 
type ButtonProps = {
    onClick?: NativeButtonProps['onClick'];
};
 
// 또는 아래와 같이도 할 수 있다.
 
const Button = (props: NativeButtonProps) => {
    return <button {...props}>버튼</button>;
};
  • React.ComponentPropsWithoutRef 타입은 아래와 같이 활용할 수 있다.
type NativeButtonType = React.ComponentPropsWithoutRef<'button'>;
type ButtonProps = {
    onClick?: NativeButtonType['onClick'];
};
  • 컴포넌트에 props로 ref를 전달할 때는 클래스 컴포넌트와 함수 컴포넌트가 다르게 작동한다.
    • 클래스 컴포넌트에서 ref 객체는 마운트된 컴포넌트의 인스턴스를 current 속성값으로 가지지만, 함수 컴포넌트에서는 생성된 인스턴스가 없기 때문에 ref에 기대한 값이 할당되지 않는 것이다.
    • 이러한 제약을 극복하고 함수 컴포넌트에서도 ref를 전달받을 수 있도록 도와주는 것이 Re-act.forwardRef 메서드이다.
// 함수 컴포넌트, ref에 기대한 값이 할당되지 않음
function Button(ref: NativeButtonProps['ref']) {
    const buttonRef = useRef(null);
 
    return <button ref={buttonRef}>버튼</button>;
}
// forwardRef를 사용해 ref를 전달받을 수 있도록 구현
const Button = forwardRef((props, ref) => {
    return (
        <button ref={ref} {...props}>
            버튼
        </button>
    );
});
 
// buttonRef가 Button 컴포넌트의 button 태그를 바라볼 수 있다
const WrappedButton = () => {
    const buttonRef = useRef();
 
    return (
        <div>
            <Button ref={buttonRef} />
        </div>
    );
};
  • 여기서 forawrdRef는 2개의 제네릭을 인자로 받을 수 있는데, 첫 번째는 ref에 대한 타입 정보이며 두 번째는 props에 대한 타입 정보이다.
type NativeButtonType = React.ComponentPropsWithoutRef<'button'>;
 
// forwardRef의 제네릭 인자를 통해 ref에 대한 타입으로 HTMLButtonElement를, props에 대한 타입으로 NativeButtonType을 정의했다
const Button = forwardRef<HTMLButtonElement, NativeButtonType>((props, ref) => {
    return (
        <button ref={ref} {...props}>
            버튼
        </button>
    );
});

8.2 타입스크립트로 리액트 컴포넌트 만들기

JSDocs로 일부 타입 지정하기

  • 컴포넌트의 속성 타입을 명시하기 위해 JSDocs를 사용할 수 있다.
/**
* Select 컴포넌트
* @param {Object} props - Select 컴포넌트로 넘겨주는 속성
* @param {Object} props.options - { [key: string]: string } 형식으로 이루어진 option 객체
* @param {string | undefined} props.selectedOption - 현재 선택된 option의 key값 (optional)
* @param {function} props.onChange - select 값이 변경되었을 때 불리는 callBack 함수 (optional)
* @returns {JSX.Element}
*/
const Select = //...

리액트 이벤트

  • 리액트 이벤트는 브라우저의 고유한 이벤트와 완전히 동일하게 동작하지는 않는다.
    • 예를 들어 리액트 이벤트 핸들러는 이벤트 버블링 단계에서 호출된다.
    • 이벤트 캡처 단계에서 이벤트 핸들러를 등록하기 위해서는 onClickCapture, onChangeCapture와 같이 일반 이벤트 리스너 이름 뒤에 Capture를 붙여야 한다.

제네릭 컴포넌트 만들기

  • Select 컴포넌트를 만들 때 제한된 key와 value를 가지게 하려면 다음과 같이 할 수 있다.
interface SelectProps<OptionType extends Record<string, string>> {
    options: OptionType;
    selectedOption?: keyof OptionType;
    onChange?: (selected?: keyof OptionType) => void;
}
 
const Select = <OptionType extends Record<string, string>>({
    options,
    selectedOption,
    onChange
}: SelectProps<OptionType>) => {
    // Select component implementation
};
 
const fruits = { apple: '사과', banana: '바나나', blueberry: '블루베리' };
 
const FruitSelect: VFC = () => {
    // ...
    // <Select<Fruit> ... />으로 작성해도 되지만, 넘겨주는 props의 타입으로 타입 추론을 해줍니다
    // Type Error - Type "orange" is not assignable to type "apple" | "banana" | "blueberry" | undefined
 
    return <Select options={fruits} onChange={changeFruit} selectedOption='orange' />;
};

HTMLAttributes, ReactProps 적용하기

  • className, id와 같은 리액트 컴포넌트의 기본 props를 추가하려면 SelectProps에 직접 className?: string; id?: string;을 넣어도 되지만 아래처럼 리액트에서 제공하는 타입을 사용하면 더 정확한 타입을 설정할 수 있다.
type ReactSelectProps = React.ComponentPropsWithoutRef<'select'>;
 
interface SelectProps<OptionType extends Record<string, string>> {
    id?: ReactSelectProps['id'];
    className?: ReactSelectProps['className'];
    // ...
}

공변성과 반공변성

  • 타입 A가 B의 서브타입일 때, T<A>T<B>의 서브타입이 된다면 공변성을 띠고 있다고 말한다.

  • 일반적인 타입들은 공변성을 가지고 있어서 좁은 타입에서 넓은 타입으로 할당이 가능하다.

    • 하지만 제네릭 타입을 지닌 함수는 반공변성을 가진다.
    • 즉, T<B>T<A>의 서브타입이 되어, 좁은 타입 T<A>의 함수를 넓은 타입 T<B>의 함수에 적용할 수 없다는 것을 의미한다.
// 모든 유저(회원, 비회원)은 id를 갖고 있음
interface User {
    id: string;
}
 
interface Member extends User {
    nickName: string;
}
 
let users: Array<User> = [];
let members: Array<Member> = [];
 
users = members; // OK
members = users; // Error
 
type PrintUserInfo<U extends User> = (user: U) => void;
 
let printUser: PrintUserInfo<User> = user => console.log(user.id);
 
let printMember: PrintUserInfo<Member> = user => {
    console.log(user.id, user.nickName);
};
 
printMember = printUser; // OK.
printUser = printMember; // Error - Property 'nickName' is missing in type 'User' but required in type 'Member'.
  • printUser는 PrintUserInfo<User> 타입으로 정의되어 있어서 Member 타입을 매개변수로 받을 수 없는 상황이다.

    • 따라서 printMember 함수를 printUser 변수에 할당할 수 없다.
  • 따라서 아래 예시에서 onChangeA같이 함수 타입을 화살표 표기법으로 작성한다면 반공변성을 띠게 된다.

    • 또한 onChangeB와 같이 함수 타입을 지정하면 공변성과 반공변성을 모두 가지는 이변성을 띠게 된다.
    • 안전한 타입 가드를 위해서는 특수한 경우를 제외하고는 일반적으로 반공변적인 함수 타입을 설정하는 것이 권장된다.
interface Props<T extends string> {
    onChangeA?: (selected: T) => void;
    onChangeB?(selected: T): void;
}