본문으로 건너뛰기
Advertisement

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부터 제거❌ 명시적으로 추가
반환 타입 자동 추론✅ `ReactElementnull`
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'];
Advertisement