리액트 어플리케이션을 타입스크립트로 작성할 때, @types/react 패키지에 정의된 리액트 내장 타입을 사용해본 경험이 있을 것이다. 리액트 내장 타입 중에는 역할이 명확한 것도 있지만, 역할이 비슷해 보이는 타입도 존재하므로 어떤 것을 사용해야 할지 헷갈릴 때가 있다.
헷갈릴 수 있는 대표적인 리액트 컴포넌트 타입을 살펴보면서 상황에 따라 어떤 것을 사용하면 좋을지, 그리고 사용할 때의 유의점은 무엇인지 알아보자.
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> {}
클래스 컴넌트가 상속 받는 React.Component와 React.PureComponent의 타입 정의는 위와 같고, P와 S는 각각 props와 state를 의미한다. props와 state 타입을 제네릭으로 받고 있다는 것을 알 수 있다.
interface WelcomeProps {
name: string;
}
class Welcome extends React.Component<WelcomeProps> {
/* ... 생략 */
}
Welcome 컴포넌트의 props 타입을 지정해보면 위와 같다. 상태가 있는 컴포넌트일 때는 제네릭의 두 번째 인자로 타입을 넘겨주면 상태에 대한 타입을 지정할 수 있다.
// 함수 선언을 사용한 방식
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 => {};
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;
}
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;
}
FC는 FunctionComponent의 약자로, React.FC와 React.VFC는 리액트에서 함수 컴포넌트의 타입 지정을 위해 제공되는 타입이다. 먼저 React.FC가 등장하고 이후 @types/react 16.9.4 버전에서 React.VFC 타입이 추가 되었다.
둘 사이에는 children이라는 타입을 허용하는지 아닌지에 따른 차이를 보인다.
React.FC는 암묵적으로 children을 포함하고 있기 때문에 해당 컴포넌트에서 children을 사용하지 않더라도 children props를 허용한다. 따라서 children props가 필요하지 않은 컴포넌트에서는 더 정확한 타입 지정을 하기 위해 React.VFC를 많이 사용한다.
하지만 리액트 v18로 넘어오면서 React.VFC가 삭제되고 React.FC에서 children이 사라졌다. 그래서 앞으로는 React.VFC 대신 React.FC 또는 props 타입, 반환 타입을 직접 지정하는 형태로 타이핑해줘야한다.
나도 예전에 궁금해서 찾아봤던 것 : 관련 QnA 인프런 사이트
type PropsWithChildren<P> = P & { children?: ReactNode | undefined };
가장 보편적인 children 타입은 ReactNode | undefined가 된다. ReactNode는 ReactElement 외에도 boolean, number 등 여러 타입을 포함하고 있는 타입으로, 더 구체적으로 타이핑 하는 용도에는 적합하지 않다. 예를 들어, 특정 문자열만 허용하고 싶을 때는 children에 대해 추가로 타이핑 해줘야 한다. chldren에 대한 타입 지정은 다른 prop 타입 지정과 동일한 방식으로 지정할 수 있다.
//example 1
type WelcomeProps = {
children: "천생연분" | "더 귀한 분" | "귀한 분" | "고마운 분";
};
// example 2
type WelcomeProps = {
children: string;
};
// example 3
type WelcomeProps = {
children: ReactElement;
};
React.ReactElement, JSX.Element, React.ReactNode 타입은 쉽게 헷갈릴 수 있다.
함수 컴포넌트의 반환 타입인 ReactElement는 아래와 같이 정의 된다고 한다.
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이 아니라 가상의 DOM을 기반으로 렌더링하는데, 가상 DOM의 엘리먼트는 ReactElement 형태로 저장된다. 즉, ReactElement 타입은 리액트 컴포넌트를 객체 형태로 저장하기 위한 포맷이다.
declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> {}
}
}
JSX.Element 타입은 앞의 코드를 보면 알 수 있다시피 리액트의 ReactElement를 확장하고 있는 타입이고, 글로벌 네임스페이스에 정의되어 있어 외부 라이브러리에서 컴포넌트 타입을 재정의 할 수 있는 유연성을 제공한다. 이러한 특성으로 인해 컴포넌트 타입을 재정의하거나 변경하는 것이 용이해진다.
=> React 자체적으로 ReactElement라는 타입이 존재하지만, TypeScript에서는 이와 별도로 JSX.Element라는 타입을 정의한다고 한다. JSX.Element는 ReactElement의 역할을 하면서, TypeScript 프로젝트에서 컴포넌트 타입에 대한 재정의를 가능하게 한다. 예를 들어, 특정 라이브러리나 프로젝트에서 JSX 요소의 구조를 약간 다르게 사용하고 싶을 때, 글로벌 네임스페이스의 JSX 인터페이스를 커스터마이징할 수 있다고 함.
*글로벌 네임스페이스(Global Namespace) : 프로그래밍에서 식별자(변수, 함수 ,타입 등)가 정의되는 전역적인 범위를 말한다. 자바스크립트나 타입스크립트에서는 기본적으로 전역(글로벌) 스코프에서 선언된 변수나 함수 등은 글로벌 네임스페이스에 속한다. 즉, 어떠한 파일이든지 해당 스코프에서 선언된 식별자는 모든 곳에서 접근할 수 있다.
type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
type ReactFragment = {} | Iterable<ReactNode>;
type ReactNode =
| ReactChild
| ReactFragment
| ReactPortal
| boolean
| null
| undefined;
React.Node는 위와 같이 타입이 정의되어 있다. 단순히 ReactElement 외에도 boolean, string, number 등의 여러 타입을 포함하고 있다.
ReactNode, JSX.Element, ReactElement 사이의 포함 관계를 정리하면,
ReactNode > ReactElement > JSX.Element
ReactElement, ReactNode, JSX.Element는 모두 리액트의 요소를 나타내는 타입이다. 리액트의 요소를 나타내는데 왜 이렇게 많은 타입이 존재하는지 의문이 생길 수 있다. 3가지 타입의 차이점과 어떤 상황에서 어떤 타입을 사용해야 더 좋은 코드를 작성할 수 있을지 살펴보자.
먼저, @types/react 패키지에 정의된 타입을 살펴보면,
declare namespace React {
// ReactElement
interface ReactElement <
P = any,
T = extends string | JSXElementConstructor<any> =
| string
| JSXElementConstructor<any>
> {
type: T;
props: P;
key: Key | null;
}
// ReactNode
type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
type ReactFragment = {} | Iterable<ReactNode>;
type ReactNode =
| ReactChild
| ReactFragment
| ReactPortal
| boolean
| null
| undefined;
type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>;
}
// JSX.Element
declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> {
// ...
}
// ...
}
}
이처럼 정의되어 있다.
좀 더 자세히 살펴보자.
리액트 엘리먼트를 생성하는 createElement 메서드에 대해 들어본적이 있을 것이다. 리액트를 사용하면서 JSX라는 자바스크립트를 확장한 문법을 자주 접했을 텐데, JSX가 createElement 메서드를 호출하기 위한 문법이다. 즉, JSX는 리액트 엘리먼트를 생성하기 위한 문법이며, 트랜스파일러는 JSX 문법을 createElement 메서드 호출문으로 변환해 리액트 엘리먼트를 생성한다.
*JSX: 자바스크립트의 확장 문법으로 리액트에서 UI를 표현하는데 사용된다. XML과 비슷한 구조로 되어 있으며 리액트 컴포넌트를 선언하고 사용할 때 더욱 간결하고 가독성 있게 코드를 작성할 수 있도록 도와준다. 또한 HTML과 유사한 문법을 제공해 리액트 사용자에게 렌더링 로직(마크업)을 쉽게 만들 수 있게 해주고, 컴포넌트 구조와 계층 구조를 편리하게 표현할 수 있도록 해준다.
const element = React.crateElement(
"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> {
// ...
}
// ...
}
}
리액트는 이런 식으로 만들어진 리액트 엘리먼트 객체를 읽어서 DOM을 구성한다. 리액트에는 여러 개의 createElement 오버라이딩 메서드가 존재하는데, 이 메서드들이 반환하는 타입은 ReactElement 타입을 기반으로 한다.
정리하면 ReactElement 타입은 JSX의 createElement 메서드 호출로 생성된 리액트 엘리먼트를 나타내는 타입이라고 볼 수 있다.
ReactNode 타입에 대해 알아보기 전에 먼저 ReactChild 타입을 보면,
type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
ReactChild 타입은 ReactElement | string | number로 정의되어 ReactElement 보다는 좀 더 넓은 범위를 갖고 있다. 그럼 ReactNode 타입을 살펴보자.
type ReactFragment = {} | Iterable<ReactNode>; // ReactNode의 배열 형태
type ReactNode =
| ReactChild
| ReactFragment
| ReactPortal
| boolean
| null
| undefined;
ReactNode는 앞에서 설명한 ReactChild 외에도 boolean, null, undfined 등 훨씬 넓은 범주의 타입을 포함한다. 즉, ReactNode는 리액트의 render 함수가 반환할 수 있는 모든 형태를 담고 있다고 볼 수 있다.
JSX.Element 타입의 정의 코드를 다시 살펴보면,
declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> {
// ...
}
// ...
}
}
JSX.Element는 ReactElement의 제네릭으로 props와 타입 필드에 대해 any 타입을 가지도록 확장하고 있다. 즉, JSX.Element는 ReactElement의 특정 타입으로 props와 타입 필드를 any로 가지는 타입이라는 것을 알 수 있다.
ReactElement, ReactNode, JSX.Element 모두 리액트에서 제공하는 컴포넌트를 나타내는 것이 공통점이다. 어떤 상호아에서 어떤 타입을 사용하는게 좋을지 예시를 통해 알아보자.
리액트의 render 함수가 반환할 수 있는 모든 형태를 담고 있기 떄문에 리액트 컴포넌트가 가질 수 있는 모든 타입을 의미한다. 리액트의 Composition(합성) 모델을 활용하기 위해 prop으로 children을 많이 사용해봤을텐데, children을 포함하는 props 타입을 선언하면 아래와 같다.
interface MyComponentProps {
children?: React.ReactNode;
// ...
}
JSX 형태의 문법을 때로는 string, number, null, undefined 같이 어떤 타입이든 children prop으로 지정할 수 있게 하고 싶다면 ReactNode 타입으로 children을 선언하면 된다.
❗️ 리액트 내장 타입인 PropsWithChildren 타입도 ReactNode 타입으로 children을 선언하고 있다.
type PropsWithChildren<P = unknown> = P & {
children?: ReactNode | undefined;
};
interface MyProps {
// ...
}
type MyComponentProps = PropsWithChildren<MyProps>;
ReactNode는 prop으로 리액트 컴포넌트가 다양한 형태를 가질 수 있게 하고 싶을 때 유용하게 사용된다.
JSX.Element는 props와 타입 필드가 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}>} />
};
icon prop을 JSX.Element 타입으로 선언함으로써 해당 prop에는 JSX 문법만 삽입할 수 있다. 또한 icon.props에 접근하여 prop으로 넘겨받은 컴포넌트의 상세한 데이터를 가져올 수 있다.
JSX.Element 예시를 확장하여 추론 관점에서 더 유용하게 활용할 수 있는 방법은 JSX.Element 대신 ReactElement를 사용하는 것이다. 이때 원하는 컴포넌트의 props를 ReactElement의 제네릭으로 지정해줄 수 있다. 만약 JSX.Element가 ReactElement의 props 타입으로 any가 지정되었다면, 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>;
};
이처럼 코드를 작성하면 icon.props에 접근할 때 어떤 props가 있는지가 추론되어 IDE에 표시되는 것을 확인할 수 있다.
리액트를 사용하면서 HTML button 태그를 확장한 Button 컴포넌트를 만들어본 경험이 있을 것이다.
const SquareButton = () => <button>정사각형 버튼</button>;
새롭게 만든 Button 컴포넌트는 기존 HTML button과 같은 역할을 하면서도 새로운 기능이나 UI가 추가된 형태이다. 기존의 button 태그가 클릭 이벤트를 등록하기 위한 onClick 이벤트 핸들러를 지원하는 것처럼, 새롭게 만든 Button 컴포넌트도 onClick 이벤트 핸들러를 지원해야만 일관성과 편의성 모두 챙길 수 있다. 기존 HTML 태그의 속성 타입을 활용해 타입을 지정하는 방법에 대해 알아보자.
HTML 태그의 속성 타입을 활용하는 대표적인 2가지 방법은 리액트의 DetailedHTMLProps와 ComponentPropsWithoutRef 타입을 활용하는 것이다. 먼저, React.DetailedHTMLProps를 활용하는 경우에는 아래처럼 HTML 태그 속성과 호환되는 타입을 선언할 수 있다.
type NativeButtonProps = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
type ButtonProps = {
onClick?: NativeButtonProps["onClick"];
};
❗️ ButtonProps의 onClick 타입은 실제 HTML button 태그의 onClick 이벤트 핸들러 타입과 동일하게 할당되는 것을 확인할 수 있다.
(property) CommonButtonOptions.onClick?: MouseEventHandler<HTMLButtonElement> | undefined
onClick?: NativeButtonProps['onClick']
그리고 React.ComponentPropsWithoutRef 타입은 아래와 같이 활용할 수 있다.
type NativeButtonType = React.ComponentPropsWithoutRef<"button">;
type ButtonProps = {
onClick?: NativeButtonType["onClick"];
};
❗️ 마찬가지로 리액트의 button onClick 이벤트 핸들러에 대한 타입이 할당된 것을 볼 수 있다.
(property) onClick?: MouseEventHandler<HTMLButtonElement> | undefined
onClick?: NativeButtonProps['onClick']
이외에도 HTMLProps, ComponentPropsWithRef 등 HTML 태그의 속성을 지원하기 위한 다양한 타입이 있다. 여러 타입이 지원되다 보니 무엇을 사용해야할지 혼란스럽게 느껴질 수 있다. 이러한 타입들은 각기 다른 이유로 탄생했을테지만 컴포넌트의 props로서 HTML 태그 속성을 확장하고 싶을 때의 상황을 중심으로 보자.
HTML button 태그와 동일한 역할을 하지만 커스텀한 UI를 적용해 재사용성을 높이기 위한 Button 컴포넌트를 만든다고 가정해보면
const Button = () => {
return <button>버튼</button>;
};
먼저 HTML button 태그를 대체하는 역할이므로 아래처럼 기존 button 태그의 HTML 속성을 props로 받을 수 있게 지원해야 한다.
type NativeButtonProps = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
const Button = (props: NativeButtonProps) => {
return <button {...props}>버튼</button>;
};
여기까지 보면 HTMLButtonElement의 속성으 모두 props로 받아 button 태그에 전달했으므로 문제 없어 보인다. 하지만 ref를 props로 받을 경우 고려해야할 사항이 있다.
*ref: 생성된 DOM 노드나 리액트 엘리먼트에 접근하는 방법으로 아래와 같이 사용
// 클래스 컴포넌트
class Button extends React.Component {
constructor(props) {
super(props);
this.buttonRef = React.createRef();
}
render() {
return <button ref={this.buttonRef}>버튼</button>;
}
}
// 함수 컴포넌트
function Button(props) {
const buttonRef = useRef(null);
return <button ref={buttonRef}>버튼</button>;
}
컴포넌트 내에서 ref를 활용해 생성된 DOM 노드에 접근하는 것과 마찬가지로, 재사용할 수 있는 Button 컴포넌트 역시 props로 전달된 ref를 통해 button 태그에 접근하여 DOM 노드를 조작할 수 있을 것으로 예상 된다.
type NativeButtonProps = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
// 클래스 컴포넌트
class Button extends React.Component {
constructor(ref: NativeButtonProps["ref"]) {
this.buttonRef = ref;
}
render() {
return <button ref={this.buttonRef}>버튼</button>;
}
}
// 함수 컴포넌트
function Button(ref: NativeButtonProps["ref"]) {
const buttonRef = useRef(null);
return <button ref={buttonRef}>버튼</button>;
}
여기서 주목해야할 점은 클래스 컴포넌트와 함수 컴포넌트에서 ref를 props로 받아 전달하는 방식에 차이가 있다는 것이다.
// 클래스 컴포넌트로 만들어진 Button 컴포넌트를 사용할 때
class WrappedButton extends React.Component {
constructor() {
this.buttonRef = React.createRef();
}
render() {
return (
<div>
<Button ref={this.buttonRef}/>
</div>
)
}
}
클래스 컴포넌트로 만들어진 버튼은 컴포넌트 props로 전달된 ref가 Button 컴포넌트의 button 태그를 그대로 바라보게 된다.
// 함수 컴포넌트로 만들어진 Button 컴포넌트를 사용할 때
const WrappedButton = () => {
const buttonRef = useRef();
return (
<div>
<Button ref={buttonRef}/>
</div>
);
}
하지만 함수 컴포넌트의 경우 기대와 달리 전달받은 ref가 Button 컴포넌트의 button 태그를 바라보지 않는다. 클래스 컴포넌트에서 ref 객체는 마운트된 컴포넌트의 인스턴스를 current 속성값으로 가지지만, 함수 컴포넌트에서는 생성된 인스턴스가 없기 때문에 ref에 기대한 값이 할당되지 않는 것이다.
이러한 제약을 극복하고 함수 컴포넌트에서도 ref를 전달받을 수있도록 도와주는것이 React.forwardRef 메서드이다.
// 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>
);
};
forwardRef는 2개의 제네릭 인자를 받을 수 있는데, 첫 번째는 ref에 대한 타입 정보이며 두번째는 props에 대한 타입 정보이다. 앞 코드에서 Button 컴포넌트에 대한 forwardRef의 타입 선언은 어떻게 할까?
type NativeButtonType = React.ComponentPropsWithoutRef<"button">;
// forwardRef의 제네릭 인자를 통해 ref에 대한 타입으로 HTMLButtonElement를,
// props에 대한 타입으로 NativeButtonType을 정의했다.
const Button = forwardRef<HTMLButtonElement, NativeButtonType>(props, ref) => {
return(
<button ref={ref} {...props}>
버튼
</button>
)
}
앞의 코드를 보면 Button 컴포넌트의 props에 대한 타입인 NativeButtonType을 정의할 때 ComponentPropsWithoutRef 타입을 사용한 것을 알 수 있다. 이렇게 타입을 React.ComponentPropsWithoutRef<"button">로 작성하면 button 태그에 대한 HTML 속성을 모두 포함하지만, ref 속성은 제외된다. 이러한 특징 때문에 DetailedHTMLProps, HTMLProps, ComponentPropsWithRef와 같이 ref 속성을 포함하는 타입과는 다르다.
함수 컴포넌트의 props로 DetailedHTMLProps와 같이 ref를 포함하는 타입을 사용하게 되면, 실제로 동작하지 않는 ref를 받도록 타입이 지정되어 예기치 않은 에러가 발생할 수 있다. 따라서 HTML 속성을 확장하는 props를 설계할 때는 ComponentPropsWithoutRef 타입을 사용해 ref가 실제로 forwardRef와 함께 사용될 때만 props로 전달되도록 타입을 정의하는 것이 안전하다고 한다.