10.1 컴포넌트 타입 — 함수형 컴포넌트 시그니처
React 컴포넌트의 타입 선언 방식
TypeScript에서 React 함수형 컴포넌트를 작성하는 방법은 두 가지입니다.
방법 1: FC (FunctionComponent) 타입
import React, { FC } from 'react';
interface GreetingProps {
name: string;
age?: number;
}
const Greeting: FC<GreetingProps> = ({ name, age }) => {
return (
<div>
<h1>Hello, {name}!</h1>
{age && <p>Age: {age}</p>}
</div>
);
};
방법 2: 직접 Props 타입 선언 (권장)
interface GreetingProps {
name: string;
age?: number;
}
function Greeting({ name, age }: GreetingProps) {
return (
<div>
<h1>Hello, {name}!</h1>
{age && <p>Age: {age}</p>}
</div>
);
}
// 화살표 함수 버전
const Greeting = ({ name, age }: GreetingProps) => (
<div>
<h1>Hello, {name}!</h1>
{age && <p>Age: {age}</p>}
</div>
);
FC vs 직접 Props 타입 선언 비교
| 항목 | FC<Props> | 직접 선언 (props: Props) |
|---|---|---|
children 자동 포함 | ❌ React 18부터 제거 | ❌ 명시적으로 추가 |
| 반환 타입 자동 추론 | ✅ `ReactElement | null` |
| displayName 지원 | ✅ | ✅ (별도 설정) |
| 제네릭 컴포넌트 | 어색함 | ✅ 자연스러움 |
| 커뮤니티 권장 | 의견 분분 | ✅ 점점 선호됨 |
React 18 이후 FC에서 children이 자동 포함되지 않으므로 명시적 선언이 더 명확합니다.
ReactNode vs ReactElement vs JSX.Element
import React, { ReactNode, ReactElement } from 'react';
// ReactNode: 가장 넓은 타입 (문자열, 숫자, null, ReactElement 등 모두 포함)
interface ContainerProps {
children: ReactNode; // 권장: 모든 렌더링 가능한 값
}
// ReactElement: React 엘리먼트만 허용 (문자열/숫자 불가)
interface WrapperProps {
child: ReactElement; // 오직 JSX 엘리먼트만
}
// JSX.Element: ReactElement<any, any>의 별칭 (덜 엄격)
interface IconProps {
icon: JSX.Element; // JSX만 허용
}
// 사용 예시
function Container({ children }: { children: ReactNode }) {
return <div className="container">{children}</div>;
}
// ✅ 모두 허용
<Container>텍스트</Container>
<Container>{42}</Container>
<Container><span>JSX</span></Container>
<Container>{null}</Container>
반환 타입 명시
대부분의 경우 TypeScript가 자동으로 추론하지만, 명시적으로 선언할 수 있습니다.
import { ReactElement, ReactNode } from 'react';
// 반환 타입 명시 (선택적)
function Button({ label }: { label: string }): ReactElement {
return <button>{label}</button>;
}
// null 반환 가능한 컴포넌트
function ConditionalComponent({ show }: { show: boolean }): ReactElement | null {
if (!show) return null;
return <div>표시됨</div>;
}
컴포넌트 타입 패턴 모음
기본 컴포넌트
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick?: () => void;
}
function Button({
label,
variant = 'primary',
size = 'md',
disabled = false,
onClick,
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
}
children을 받는 컴포넌트
import { ReactNode } from 'react';
interface CardProps {
title: string;
children: ReactNode;
footer?: ReactNode;
}
function Card({ title, children, footer }: CardProps) {
return (
<div className="card">
<div className="card-header">
<h2>{title}</h2>
</div>
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// 사용
<Card title="사용자 정보" footer={<button>저장</button>}>
<p>내용이 여기 들어갑니다.</p>
</Card>
다형성 컴포넌트 (as prop)
다양한 HTML 태그로 렌더링될 수 있는 컴포넌트입니다.
import { ComponentPropsWithoutRef, ElementType } from 'react';
type TextProps<T extends ElementType> = {
as?: T;
children: React.ReactNode;
} & ComponentPropsWithoutRef<T>;
function Text<T extends ElementType = 'p'>({
as,
children,
...props
}: TextProps<T>) {
const Component = as ?? 'p';
return <Component {...props}>{children}</Component>;
}
// 사용 — 각 태그에 맞는 props 타입 자동 적용
<Text>기본 문단</Text> // <p>
<Text as="h1">제목</Text> // <h1>
<Text as="a" href="/about">링크</Text> // <a href="/about">
<Text as="button" onClick={() => {}}>버튼</Text> // <button>
컴파운드 컴포넌트 패턴
import { createContext, useContext, ReactNode } from 'react';
interface TabsContextValue {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) throw new Error('useTabs must be used within Tabs');
return context;
}
// 메인 컴포넌트
interface TabsProps {
defaultTab: string;
children: ReactNode;
}
function Tabs({ defaultTab, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 서브 컴포넌트
Tabs.Tab = function Tab({ id, label }: { id: string; label: string }) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
className={activeTab === id ? 'active' : ''}
onClick={() => setActiveTab(id)}
>
{label}
</button>
);
};
Tabs.Panel = function Panel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return <div className="tab-panel">{children}</div>;
};
// 사용
<Tabs defaultTab="profile">
<Tabs.Tab id="profile" label="프로필" />
<Tabs.Tab id="settings" label="설정" />
<Tabs.Panel id="profile"><ProfileContent /></Tabs.Panel>
<Tabs.Panel id="settings"><SettingsContent /></Tabs.Panel>
</Tabs>
고수 팁
1. ComponentProps로 기존 컴포넌트 타입 확장
import { ComponentProps } from 'react';
// button의 모든 props + 추가 props
type EnhancedButtonProps = ComponentProps<'button'> & {
isLoading?: boolean;
loadingText?: string;
};
function EnhancedButton({ isLoading, loadingText = '로딩 중...', children, ...props }: EnhancedButtonProps) {
return (
<button disabled={isLoading} {...props}>
{isLoading ? loadingText : children}
</button>
);
}
2. ComponentProps<typeof Component>로 커스텀 컴포넌트 타입 추출
// Button 컴포넌트의 Props 타입 재사용
type ButtonProps = ComponentProps<typeof Button>;
// 특정 prop 추출
type ButtonVariant = ComponentProps<typeof Button>['variant'];