React JSX (JavaScript XML)를 React TSX (TypeScript XML)로 변환해야 할 때, 알아야할 React의 핵심이론들을 정리해보았습니다.
간단한 Button 컴포넌트의 JSX 버전을 TSX로 변환할 때도, 아래와 같이 여러 개념에 의한 변환이 필요합니다.
// JSX 버전
const Button = (props) => {
return <button onClick={props.onClick}>클릭</button>
}
// TSX 버전
// 1. Props 타입 정의 - React.ComponentPropsWithoutRef 활용
type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
customProp?: string;
}
// 2. FC vs 일반 함수 선언 - 함수형 컴포넌트 타입 활용
const Button: React.FC<ButtonProps> = (props) => {
return <button onClick={props.onClick}>클릭</button>
}
JSX를 TSX로 변환하다보면 발생하는 주요 경우들을 정리하면, 아래와 같은 경우들이 있습니다.
1) 함수형 컴포넌트의 타입 지정
2) 컴포넌트 Props의 타입 지정
- HTML 엘리먼트의 기본 속성들을 타입으로 가져와야 할 때 ( 관련 개념 : ComponentPropsWithoutRef)
- children prop을 다룰 때 ( 관련 개념 : ReactNode 타입 등)
3) 이벤트 핸들러 타입을 지정할 때 ( 관련 개념 : ChangeEventHandler 등)
4) 재사용 가능한 제네릭 컴포넌트를 만들 때 ( 관련 개념 : (예) Select 컴포넌트)
5) 타입 유틸리티 활용
각각의 개념에 대해서 좀 더 상세히 정리하고 설명해보겠습니다.
1) 함수형 컴포넌트의 타입 지정
React.FC와 React.VFC는 리액트에서 함수 컴포넌트의 타입 지정을 위해 제공되는 타입 입니다.
React.FC(FunctionComponent)
암묵적으로 children을 포함하고 있으므로, children을 해당 컴포넌트에서 사용하지 않더라도 children props를 허용
React.VFC(VoidFunctionComponent)
children props가 필요하지 않은 컴포넌트의 더 정확한 타입 지정을 위해 사용
React.FC 등장 이후 @types/react 16.9.4 버전에서 React.VFC 타입이 추가되었지만, 리액트 v18로 넘어오면서 React.VFC가 삭제(deprecated)되고 React.FC에서 children이 사라졌습니다. 따라서, 현재는 React.FC를 사용하되 children을 명시적으로 지정하는 방식 또는 props 타입과 반환 타입을 직접 지정하는 형태로 타이핑 해주어야 합니다.
React.FC vs 직접 타입 지정
최근에는 직접 타입 지정이 좀 더 명시적이고 명확하며, 반환타입 지정에 대한 유연성이 좋기 때문에 좀 더 선호되고 있다고 합니다.
두 방식 코드 비교해보기
// React.FC 사용
interface MyComponentProps {
name: string;
age: number;
}
const MyComponent: React.FC<MyComponentProps> = ({ name, age }) => {
return (
<div>
{name} is {age} years old
</div>
);
};
// =========================================================================
// 직접 타입 지정
interface MyComponentProps {
name: string;
age: number;
}
const MyComponent = ({ name, age }: MyComponentProps): JSX.Element => {
return (
<div>
{name} is {age} years old
</div>
);
};
2) Props의 타입 지정과 관련된 주요 개념
1) children prop의 타입 지정
children은 createElement 함수에서 특별히 처리되는 prop 입니다.
PropsWithChildren
React에서 제공하는 타입 유틸리티로, 기존 props 타입에 children 속성을 추가해주는 역할을 합니다.
interface BoxProps {
color: string;
width: number;
}
// BoxProps와 children을 함께 사용
type Props = PropsWithChildren<BoxProps>;
const Box = ({ children, color, width }: Props) => {
return (
<div style={{ color, width }}>
{children}
</div>
);
};
// 사용
<Box color="red" width={100}>
<span>자식 요소</span>
</Box>
@types/react 내부엔 PropsWithChildren이 아래와 같이 정의되어 있습니다.
type PropsWithChildren<P = unknown> = P & {
children?: ReactNode | undefined;
};
ReactNode에는 ReactElement, boolean, number 등 여러 타입을 포함하고 있기 때문에 구체적인 타이핑을 위해선 children에 대해서만 따로 타이핑을 해주어야 합니다. 방법은 아래 코드처럼 다른 prop 타입 지정과 동일한 방식으로 할 수 있습니다.
// example 1
type WelcomeProps = {
children: “천생연분” | “더 귀한 분” | “귀한 분” | “고마운 분”;
};
// example 2
type WelcomeProps = {
children: string;
};
// example 3
type WelcomeProps = {
children: ReactElement;
};
2) 함수 컴포넌트의 반환 타입 : React.ReactElement / JSX.Element / React.ReactNode
위 3가지 타입은 함수 컴포넌트의 반환 타입으로, 각각의 타입이 children으로 허용하는 값의 범위가 다르기 때문에 children prop과 연관되어 중요한 개념입니다.
실무에서는 대부분의 경우 가장 유연한 React.ReactNode를 사용하며, 특정 타입의 컴포넌트만 자식으로 받고 싶을 땐 React.ReactElement 사용하고, 정확한 타입 제한이 필요한 경우에는 더 구체적인 타입을 사용합니다.
JSX.Element의 경우 너무 제한적인 반환 타입 범위와 children prop 타입 안전성이 낮아 현재로서는 잘 사용되지 않는 편이라고 합니다.
React.ReactElement
// ReactElement의 정의
interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
type: T;
props: P;
key: Key | null;
}
리액트 컴포넌트를 객체 형태로 저장하기 위한 포맷으로, JSX의 createElement 메서드 호출로 생성된 리액트 엘리먼트를 나타내는 타입 입니다.
JSX.Element 대신 ReactElement를 사용하면, 아래 코드와 같이 원하는 컴포넌트의 props를 ReactElement의 제네릭으로 지정해줄 수 있습니다. 이 방식을 사용하면 Props의 타입 안정성이 확보되고, IDE의 자동완성도 지원되어 보다 편리한 코드 작성이 가능합니다.
// 1. JSX.Element 사용
interface Props {
icon: JSX.Element;
}
const Component = ({ icon }: Props) => {
// ❌ icon.props의 타입이 'any'
const iconSize = icon.props.size; // 타입 안정성 없음
return <div>{icon}</div>;
};
// 2. ReactElement 사용 - 제네릭으로 props 타입 지정
interface IconProps {
size: number;
color: string;
}
interface Props {
icon: React.ReactElement<IconProps>;
}
const Component = ({ icon }: Props) => {
// ✅ icon.props의 타입이 IconProps
const iconSize = icon.props.size; // 타입 추론 가능
const iconColor = icon.props.color; // 자동완성 지원
return <div>{icon}</div>;
};
// 사용
<Component icon={<Icon size={24} color="red" />} />
JSX.Element
// JSX.Element의 정의
declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> { }
}
}
ReactElement의 특정 타입으로, props와 타입 필드를 any 타입으로 가지는 ReactElement 입니다. 때문에 ReactElement를 prop으로 전달받아서 render props 패턴*으로 컴포넌트를 구현할 때 유용합니다.
render props 패턴은 컴포넌트 간에 값을 공유하기 위해 함수를 props로 전달하는 기법입니다.
// 1. 기본적인 render props 패턴
interface MouseTrackerProps {
render: (position: { x: number; y: number }) => JSX.Element;
}
const MouseTracker = ({ render }: MouseTrackerProps) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (event: React.MouseEvent) => {
setPosition({
x: event.clientX,
y: event.clientY
});
};
return (
<div onMouseMove={handleMouseMove}>
{render(position)}
</div>
);
};
// 사용 예시
<MouseTracker
render={({ x, y }) => (
<div>
마우스 위치 - x: {x}, y: {y}
</div>
)}
/>
React.ReactNode
ReactElement 외에도 boolean, string, number 등의 여러 타입 포함. 리액트의 render 함수*가 반환할 수 있는 모든 형태를 담고 있다고 볼 수 있습니다.
따라서, 어떤 타입이든 children prop으로 지정할 수 있게 하고 싶다면 ReactNode 타입으로 children을 선언해주면 됩니다.
// ReactNode로 children을 정의하면 다음 모든 경우가 가능합니다
interface ComponentProps {
children: React.ReactNode;
}
const Component = ({ children }: ComponentProps) => {
return <div>{children}</div>;
};
// 이렇게 다양한 타입을 children으로 전달할 수 있습니다
<Component>Hello World</Component> // string
<Component>{42}</Component> // number
<Component>{null}</Component> // null
<Component>{undefined}</Component> // undefined
<Component><div>JSX Element</div></Component> // JSX
<Component>{true}</Component> // boolean
<Component>{[1, 2, 3]}</Component> // array
※ 참고로 ReactNode에 포함된 타입 중, 현재 ReactText, ReactChild, ReactFragment는 deprecated 되었다고 합니다.
(deprecated : 아직 사용은 가능하나, 사용하지 않는 것이 권장되며, 추후 버전에서 완전히 제거될 수 있는 기능)
리액트의 내장 타입에 해당하는 PropsWithChildren 타입도 ReactNode 타입으로 children을 선언하고 있습니다.
type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined;
};
※ render 함수란 ?
클래스 컴포넌트에서 UI를 정의하는 필수 메서드 입니다.
이제는 함수형 컴포넌트가 주로 사용되기 때문에, render 메서드를 직접 사용하는 경우는 감소하고 있다고 합니다.
class MyComponent extends React.Component {
render() {
return (
<div>
클래스 컴포넌트의 UI는 render 메서드에서 정의됩니다
</div>
);
}
}
함수형 컴포넌트에서는 함수 자체가 render 역할을 하므로 불필요합니다.
function MyComponent() {
return (
<div>
함수형 컴포넌트는 함수 자체가 render 역할을 합니다
</div>
);
}
3) HTML 기본 속성 타입 활용하기 : ComponentPropsWithoutRef
리액트에서 HTML 태그의 속성 타입을 활용하는 대표적인 방법은 DetailedHTMLProps와 ComponentPropsWithoutRef 타입을 활용하는 것입니다. 하지만, DetailedHTMLProps는 ref속성을 포함하고 있기 때문에, 이 타입을 사용하게 되면 실제로는 동작하지 않는 ref를 받도록 타입이 지정되면서 예기치 않은 에러가 발생할 확률이 높아진다는 단점이 있습니다.
따라서, 상황에 따라서 DetailedHTMLProps를 활용하는 것도 좋지만, HTML 속성을 확장하는 props 설계 시에는 ComponentPropsWithoutRef를 사용하고, ref 속성은 필요에 따라 forwardRef 를 사용해 전달하는 것이 안전합니다.
// ⚠️ 잠재적 문제가 있는 방식
type ButtonProps = React.DetailedHTMLProps
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
const Button = (props: ButtonProps) => {
return <button {...props} />;
};
// 사용시
<Button ref={someRef} /> // 타입상으로는 허용되지만 실제로는 ref가 동작하지 않음
// ✅ 안전한 방식
type ButtonProps = React.ComponentPropsWithoutRef<"button">;
const Button = (props: ButtonProps) => {
return <button {...props} />;
};
// ref가 필요한 경우 forwardRef 사용
const ButtonWithRef = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <button ref={ref} {...props} />;
});
3) 이벤트 핸들러 타입
React에서 이벤트 핸들러 타입은 사용자의 상호작용(클릭, 입력 등)을 처리하는 함수의 타입을 정의하는 방법 입니다. React.ChangeEventHandler, React.MouseEventHandler 등과 같은 타입을 사용하여 이벤트 함수의 타입 안전성을 보장하고, 특히 e.target의 타입을 올바르게 추론할 수 있게 해줍니다.
(1) 기본적인 주요 이벤트 핸들러 타입
// 기본적인 주요 이벤트 핸들러 타입
// 1. Change 이벤트
type ChangeEventHandler = React.ChangeEventHandler<HTMLInputElement>;
// 2. Click 이벤트
type ClickEventHandler = React.MouseEventHandler<HTMLButtonElement>;
// 3. Form 이벤트
type SubmitEventHandler = React.FormEventHandler<HTMLFormElement>;
// 4. Focus 이벤트
type FocusEventHandler = React.FocusEventHandler<HTMLInputElement>;
// 예시 몇 가지 살펴보기
// 1. onChange 이벤트
interface InputProps {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
}
const Input = ({ value, onChange }: InputProps) => {
return <input value={value} onChange={onChange} />;
};
// 사용 예시
<Input
value="hello"
onChange={(e) => {
// e.target.value는 자동으로 string으로 추론됨
console.log(e.target.value);
}}
/>
// 2. onClick 이벤트
interface ButtonProps {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
const Button = ({ onClick }: ButtonProps) => {
return <button onClick={onClick}>클릭</button>;
};
(2) 커스텀 이벤트 핸들러
// 제네릭을 활용한 커스텀 이벤트 핸들러
type CustomChangeHandler<T> = (value: T) => void;
interface SelectProps<T> {
value: T;
onChange: CustomChangeHandler<T>;
}
const Select = <T,>({ value, onChange }: SelectProps<T>) => {
// 구현...
};
4) 제네릭을 활용한 재사용 가능한 컴포넌트 타입
컴포넌트를 정의할 때 구체적인 타입 대신 제네릭(타입 변수)를 사용함으로써, 컴포넌트를 사용하는 시점에 실제 타입을 결정할 수 있습니다. 이에 따라 하나의 컴포넌트 타입으로 다양한 타입의 데이터를 처리하며 타입 안정성과 동시에 컴포넌트 타입의 재사용성을 높일 수 있습니다.
주로 리스트나 Select 컴포넌트 등에 활용되는데 그 예시 코드는 아래와 같습니다.
예시 1) 다양한 데이터 타입에 적용 가능한 리스트 컴포넌트 구현
// 제네릭 컴포넌트의 상세한 예시
interface DataListProps<T> {
// T는 어떤 타입이든 될 수 있는 제네릭 타입
items: T[]; // 데이터 배열
renderItem: (item: T) => React.ReactNode; // 각 항목을 렌더링하는 함수
keyExtractor: (item: T) => string | number; // 각 항목의 고유 키를 추출하는 함수
onItemClick?: (item: T) => void; // 항목 클릭 핸들러 (선택적)
listTitle?: string; // 리스트 제목 (선택적)
}
// 제네릭 컴포넌트 구현
const DataList = <T,>({
items,
renderItem,
keyExtractor,
onItemClick,
listTitle
}: DataListProps<T>) => {
return (
<div className="data-list">
{/* 제목이 있는 경우에만 렌더링 */}
{listTitle && <h2>{listTitle}</h2>}
{/* 데이터 리스트 렌더링 */}
<ul>
{items.map(item => (
<li
key={keyExtractor(item)}
onClick={() => onItemClick?.(item)} // 선택적 체이닝으로 안전하게 호출
>
{renderItem(item)}
</li>
))}
</ul>
</div>
);
};
// 사용 예시 1: 문자열 리스트
const StringList = () => {
const strings = ["apple", "banana", "orange"];
return (
<DataList<string>
items={strings}
renderItem={(item) => <span>{item}</span>}
keyExtractor={(item) => item}
listTitle="과일 목록"
/>
);
};
// 사용 예시 2: 사용자 객체 리스트
interface User {
id: number;
name: string;
email: string;
}
const UserList = () => {
const users: User[] = [
{ id: 1, name: "John", email: "john@example.com" },
{ id: 2, name: "Jane", email: "jane@example.com" }
];
const handleUserClick = (user: User) => {
console.log(`Selected user: ${user.name}`);
};
return (
<DataList<User>
items={users}
renderItem={(user) => (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)}
keyExtractor={(user) => user.id}
onItemClick={handleUserClick}
listTitle="사용자 목록"
/>
);
};
예시 2) select 컴포넌트 구현
※ Record는 TypeScript의 유틸리티 타입으로, 키-값 쌍의 타입을 정의할 때 사용합니다.
interface SelectProps<T extends Record<string, string>> {
options: T;
value?: keyof T;
onChange?: (value: keyof T) => void;
placeholder?: string;
}
const Select = <T extends Record<string, string>>({
options,
value,
onChange,
placeholder
}: SelectProps<T>) => {
return (
<select
value={value as string}
onChange={e => {
const selectedKey = Object.entries(options)
.find(([_, val]) => val === e.target.value)?.[0];
onChange?.(selectedKey);
}}
>
{placeholder && (
<option value="">{placeholder}</option>
)}
{Object.entries(options).map(([key, value]) => (
<option key={key} value={value}>
{value}
</option>
))}
</select>
);
};
// 사용 예시
const fruits = {
apple: "사과",
banana: "바나나",
orange: "오렌지"
} as const;
function App() {
const [selectedFruit, setSelectedFruit] = useState<keyof typeof fruits>();
return (
<Select
options={fruits}
value={selectedFruit}
onChange={setSelectedFruit}
placeholder="과일을 선택하세요"
/>
);
}
5) 주요 타입 유틸리티
1) Record<K,T>: 객체의 키-값 쌍의 타입을 정의할 때 사용하는 유틸리티 타입입니다. K는 키의 타입, T는 값의 타입을 나타냅니다.
const fruits: Record<string, number> = {
apple: 1,
banana: 2
// 모든 키는 string, 모든 값은 number
};
2) keyof typeof: 객체의 키들을 유니온 타입으로 추출합니다. 객체의 타입을 그 객체의 키들의 유니온으로 변환합니다.
// 객체의 키들을 유니온 타입으로 추출
const theme = {
color: 'red',
size: 'small'
};
type ThemeKeys = keyof typeof theme;
// 결과: 'color' | 'size'
3) Partial<T>: 타입 T의 모든 속성을 선택적(optional)으로 만드는 유틸리티 타입입니다. 기존 타입의 모든 필드가 optional이 됩니다.
interface User {
name: string;
age: number;
}
type PartialUser = Partial<User>;
// 결과: { name?: string; age?: number; }
4) Pick<T,K>: 타입 T에서 특정 속성 K만을 선택하여 새로운 타입을 만드는 유틸리티 타입입니다. 원하는 속성만 뽑아서 사용할 수 있습니다.
interface User {
name: string;
age: number;
email: string;
}
type UserBasicInfo = Pick<User, 'name' | 'age'>;
// 결과: { name: string; age: number; }