8.1 리액트 컴포넌트의 타입
1. 클래스 컴포넌트 타입
// P는 props, S는 state
interface Component<P = {}, S = {}, SS = any> extends ComponentLifecycle<P, S, SS> {}
class Component<P, S> {}
class PureComponent<P = {}, S = {}, SS = any> extends Component<P, S, SS> {}
2. 함수 컴포넌트 타입
// 함수 선언을 사용한 방식
function Welcome(props: WelcomeProps): JSX.Element {}
// 함수 표현식을 사용한 방식 - JSX.Element를 반환 타입으로 지정
const Welcome = ({ name }: WelcomeProps): JSX.Element => {};
// 함수 표현식을 사용한 방식 - React.FC 사용
const Welcome: React.FC<WelcomeProps> = ({ name }) => {};
type FC<P = {}> = FunctionComponent<P>;
interface FunctionComponent<P = {}> {
// props에 children을 (직접) 추가
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P> | undefined;
contextTypes?: ValidationMap<any> | undefined;
defaultProps?: Partial<P> | undefined;
displayName?: string | undefined;
}
// 함수 표현식을 사용한 방식 - React.VFC 사용
// @types/react 16.9.4에 도입 후 v18에서 삭제
const Welcome: React.VFC<WelcomeProps> = ({ name }) => {};
type VFC<P = {}> = VoidFunctionComponent<P>;
interface VoidFunctionComponent<P = {}> {
// children 없음
(props: P, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P> | undefined;
contextTypes?: ValidationMap<any> | undefined;
defaultProps?: Partial<P> | undefined;
displayName?: string | undefined;
}
3. Children props 타입 지정
4~6. React.ReactElement
vs. JSX.Element
vs. React.ReactNode
모두 리액트의 요소를 나타내는 타입
React.ReactElement
JSX의 createElement 메서드 호출로 생성된 리액트 엘리먼트를 나타내는 타입 가상 DOM의 엘리먼트, 즉 리액트 컴포넌트를 객체 형태로 저장하기 위한 포맷
-
코드
interface ReactElement< P = any, T extends string | JSXElementConstructor<any> = | string | JSXElementConstructor<any> > { type: T; props: P; key: Key | null; }
-
코드
const element = React.createElement( "h1", { className: "greeting" }, "Hello, world!" ); // 주의: 다음 구조는 단순화되었다 const element = { type: "h1", props: { className: "greeting", children: "Hello, world!", }, }; declare global { namespace JSX { interface Element extends React.ReactElement<any, any> { // ... } // ... } }
-
JSX.Element
보다 추론 관점에서 더 유용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>; };
JSX.Element
리액트의 ReactElement
를 확장하고 있는 타입
ReactElement
의 제네릭으로 props와 타입 필드에 대해 any 타입을 가지도록 확장
글로벌 네임스페이스에 정의되어 있어 외부 라이브러리에서 컴포넌트 타입을 재정의 할 수 잇는 유연성 제공
-
코드
// 8.1.4-2.d.ts declare global { namespace JSX { interface Element extends React.ReactElement<any, any> {} } }
-
리액트 엘리먼트를 prop으로 전달받아 render props 패턴으로 컴포넌트를 구현할 때
interface Props { icon: JSX.Element; } const Item = ({ icon }: Props) => { // prop으로 받은 컴포넌트의 props에 접근할 수 있다 const iconSize = icon.props.size; return <li>{icon}</li>; }; // icon prop에는 JSX.Element 타입을 가진 요소만 할당할 수 있다 const App = () => { return <Item icon={<Icon size={14} />} />; };
React.ReactNode
ReactElement
외에도 boolean, string, number 등의 여러 타입 포함
리액트의 render 함수가 반환할 수 잇는 모든 형태를 담고 있음
-
코드
type ReactText = string | number; type ReactChild = ReactElement | ReactText; type ReactFragment = {} | Iterable<ReactNode>; type ReactNode = | ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;
-
리액트의 Composition 모델을 활용할 때 (children을 포함하는 props 타입)
type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined; }; interface MyProps { // ... } type MyComponentProps = PropsWithChildren<MyProps>;
7. 리액트에서 기본 HTML 요소 타입 활용하기
HTML button 태그를 확장한 Button 컴포넌트를 만들 때
HTML 태그의 속성 활용 대표적인 두 가지 방법 - DetailedHTMLProps
, ComponentWithoutRef
DetailedHTMLProps
type NativeButtonProps = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
type ButtonProps = {
onClick?: NativeButtonProps["onClick"];
};
ComponentWithoutRef
type NativeButtonType = React.ComponentPropsWithoutRef<"button">;
type ButtonProps = {
onClick?: NativeButtonType["onClick"];
};
컴포넌트의 props로서 HTML 태그 속성을 확장하고 싶을 때
ex. 커스텀 UI를 적용하여 재사용성 높이기 위한 버튼 컴포넌트
-
기존 button 태그의 HTML 속성을 props로 받을 수 있게 하기
type NativeButtonProps = React.DetailedHTMLProps< React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement >; const Button = (props: NativeButtonProps) => { return <button {...props}>버튼</button>; };
-
ref를 props로 받을 경우 고려하기
클래스 컴포넌트로 만들어진 버튼(마운트시 생성된 인스턴스를 유지하므로)은 컴포넌트 props로 전달된 ref가 Button 컴포넌트의 button 태그를 그대로 바라보지만, 함수 컴포넌트는 React.forwardRef를 사용해야한다
// 클래스 컴포넌트로 만들어진 Button 컴포넌트를 사용할 때
class WrappedButton extends React.Component {
constructor() {
this.buttonRef = React.createRef();
}
render() {
return (
<div>
<Button ref={this.buttonRef} />
</div>
);
}
}
// 함수 컴포넌트로 만들어진 Button 컴포넌트를 사용할 때
// forwardRef를 사용해 ref를 전달받을 수 있도록 구현
type NativeButtonType = React.ComponentPropsWithoutRef<"button">;
// forwardRef의 제네릭 인자를 통해 ref에 대한 타입으로 HTMLButtonElement를, props에 대한 타입으로 NativeButtonType을 정의했다
const Button = forwardRef<HTMLButtonElement, NativeButtonType>((props, ref) => {
return (
<button ref={ref} {...props}>
버튼
</button>
);
});
// buttonRef가 Button 컴포넌트의 button 태그를 바라볼 수 있다
const WrappedButton = () => {
const buttonRef = useRef();
return (
<div>
<Button ref={buttonRef} />
</div>
);
};
ComponentPropsWithoutRef<"태그명">
- 태그에 대한 HTML 속성을 모두 포함하지만, ref 속성은 제외
DetailedHTMLProps
,HTMLProps
,ComponentPropsWithRef
와 같이 ref 속성을 포함하는 타입과는 다름- FC props로 ref 속성을 포함하는 타입을 사용하게 되면, 실제로는 동작하지 않는 ref를 받도록 타입이 지정되어 예기치 않은 에러가 발생할 수 있다
- 따라서 HTML속성을 확장하는 props를 설계할 때는
ComponentPropsWithoutRef
를 사용하여 ref가 실제로 forwardRef와 함께 사용될 때만 props로 전달되도록 타입을 정의하는 것이 안전하다.
8.2 타입스크립트로 리액트 컴포넌트 만들기
1. JSX로 구현된 Select 컴포넌트
2. JSDocs로 일부 타입 지정하기
3. props 인터페이스 적용하기
4. 리액트 이벤트
리액트는 가상 DOM을 다루면서 이벤트로 별도로 관리한다. → 브라우저의 고유한 이벤트와 완전히 동일하게 동작하지 않음
- 리액트 이벤트 핸들러는 ‘이벤트 버블링 단계’에서 호출 이벤트 캡쳐 단계에서 이벤트 핸들러를 등록하기 위해서는 onClickCapture, onChangeCapture와 같이 뒤에 Capture를 붙여야 함
- 또한 브라우저 이벤트를 합성한 합성 이벤트(SyntheticEvent)를 제공함
type EventHandler<Event extends React.SyntheticEvent> = ( e: Event ) => void | null; type ChangeEventHandler = EventHandler<ChangeEvent<HTMLSelectElement>>; const eventHandler1: GlobalEventHandlers["onchange"] = (e) => { e.target; // 일반 Event는 target이 없음 }; const eventHandler2: ChangeEventHandler = (e) => { e.target; // 리액트 이벤트(합성 이벤트)는 target이 있음 };
5. 훅에 타입 추가하기
keyof typeof obj - 해당 객체의 키값을 유니온 타입으로 추출하는 패턴으로 자주 사용됨
type Fruit = keyof typeof fruits; // 'apple' | 'banana' | 'blueberry';
const [fruit, changeFruit] = useState<Fruit | undefined>("apple");
// 에러 발생
const func = () => {
changeFruit("orange");
};
6. 제네릭 컴포넌트 만들기
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
};
7. HTMLAttributes, ReactProps 적용하기
type ReactSelectProps = React.ComponentPropsWithoutRef<"select">;
interface SelectProps<OptionType extends Record<string, string>> {
id?: ReactSelectProps["id"];
className?: ReactSelectProps["className"];
// ...
}
interface SelectProps<OptionType extends Record<string, string>>
extends Pick<ReactSelectProps, "id" | "key" | /* ... */> {
// ...
}
8. styled-components를 활용한 스타일 정의
9. 공변성과 반공변성
객체의 메서드 타입을 정의하는 방법 두 가지
interface Props<T extends string> {
onChangeA?: (selected: T) => void; // 1.
onChangeB?(selected: T): void; // 2.
}
const Component = () => {
const changeToPineApple = (selectedApple: "apple") => {
console.log("this is pine" + selectedApple);
};
return (
<Select
// Error
// onChangeA={changeToPineApple}
// OK
onChangeB={changeToPineApple}
/>
);
};
-
타입 A가 B의 서브타입일 때,
T<A>
가T<B>
의 서브타입이 된다면 공변성을 띠고 있다일반적인 타입들은 공변성을 가지고 있어서 좁은 타입에서 넓은 타입으로 할당이 가능하다.
-
하지만 제네릭 타입을 지닌 함수는 반공변성을 가진다.
즉,
T<B>
가T<A>
의 서브타입이 되어, 좁은 타입의 함수를 넓은 타입의 함수에 적용할 수 없다는 것을 의미한다.
onChangeA
같이 함수 타입을 화살표 표기법으로 작성한다면 → 반공변성
onChangeB
같이 함수 타입을 지정하면 → 공변성과 반공변성을 모두 가지는 이변성을 띠게 됨
따라서 안전한 타입 가드를 위해 일반적으로 반공변적인 함수 타입을 설정하는 것이 권장됨
8.3 정리
-
최종 Select 컴포넌트
import React, { useState } from "react"; import styled from "styled-components"; const theme = { fontSize: { default: "16px", small: "14px", large: "18px", }, color: { white: "#FFFFFF", black: "#000000", }, }; type Theme = typeof theme; type FontSize = keyof Theme["fontSize"]; type Color = keyof Theme["color"]; interface SelectStyleProps { color: Color; fontSize: FontSize; } const StyledSelect = styled.select<SelectStyleProps>` color: ${({ color }) => theme.color[color]}; font-size: ${({ fontSize }) => theme.fontSize[fontSize]}; `; type ReactSelectProps = React.ComponentPropsWithoutRef<"select">; interface SelectProps<OptionType extends Record<string, string>> extends Partial<SelectStyleProps> { id?: ReactSelectProps["id"]; className?: ReactSelectProps["className"]; options: OptionType; selectedOption?: keyof OptionType; onChange?: (selected?: keyof OptionType) => void; } const Select = <OptionType extends Record<string, string>>({ className, id, options, onChange, selectedOption, fontSize = "default", color = "black", }: SelectProps<OptionType>) => { const handleChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => { const selected = Object.entries(options).find( ([_, value]) => value === e.target.value )?.[0]; onChange?.(selected); }; return ( <StyledSelect id={id} className={className} fontSize={fontSize} color={color} onChange={handleChange} value={selectedOption && options[selectedOption]} > {Object.entries(options).map(([key, value]) => ( <option key={key} value={value}> {value} </option> ))} </StyledSelect> ); };
const fruits = { apple: "사과", banana: "바나나", blueberry: "블루베리" }; type Fruit = keyof typeof fruits; const FruitSelect = () => { const [fruit, changeFruit] = useState<Fruit | undefined>(); return ( <Select className="fruitSelectBox" options={fruits} onChange={changeFruit} selectedOption={fruit} fontSize="large" /> ); }; export default FruitSelect;