Ch 02. 컴포넌트 Props 타이핑
React 컴포넌트를 TypeScript로 작성할 때 가장 먼저 마주치는 질문이 있습니다. "Props 타입을 어떻게 정의하지?" 그리고 바로 이어지는 질문도 있죠. "FC를 써야 하나, 일반 함수로 써야 하나?"
이 챕터에서는 그 질문들에 답하면서 TodoItem과 TodoList 컴포넌트를 만듭니다.
함수 컴포넌트를 작성하는 두 가지 방법
React.FC 방식
// 파일: 예시
import type { FC } from 'react';
interface Props {
title: string;
}
const MyComponent: FC<Props> = ({ title }) => {
return <div>{title}</div>;
};
React.FC(또는 FC)를 사용하면 반환 타입이 자동으로 JSX.Element | null로 추론됩니다. 하지만 실무에서는 이 방식을 점점 덜 씁니다. 이유가 몇 가지 있습니다.
첫째, FC는 암묵적으로 children prop을 포함하지 않습니다. React 18부터는 오히려 명시적으로 써야 합니다.
둘째, 제네릭 컴포넌트를 작성할 때 FC와 함께 쓰면 문법이 복잡해집니다.
셋째, 일반 함수 선언과 비교해 특별히 얻는 게 없습니다.
일반 함수 방식
// 파일: 예시
interface Props {
title: string;
}
function MyComponent({ title }: Props) {
return <div>{title}</div>;
}
간결하고 제네릭과도 자연스럽게 어울립니다. 이 PART에서는 일반 함수 방식을 사용합니다.
children 타이핑
컴포넌트가 자식 요소를 받을 때는 children prop을 명시적으로 선언합니다.
// 파일: 예시
import type { ReactNode } from 'react';
interface WrapperProps {
children: ReactNode;
className?: string;
}
function Wrapper({ children, className }: WrapperProps) {
return <div className={className}>{children}</div>;
}
ReactNode는 React가 렌더링할 수 있는 모든 것을 포함합니다. JSX.Element, string, number, null, undefined, 배열까지 모두 허용합니다. 가장 유연한 타입입니다.
좀 더 제한하고 싶을 때는 ReactElement를 씁니다. JSX.Element만 허용하고 null이나 string은 허용하지 않습니다.
TodoItem 컴포넌트 만들기
할 일 항목 하나를 표시하는 컴포넌트입니다.
// 새 파일: src/components/TodoItem.tsx
import type { Todo } from '../types/todo';
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`${todo.text} 완료 여부`}
/>
<span className="todo-text">{todo.text}</span>
<button
className="delete-btn"
onClick={() => onDelete(todo.id)}
aria-label={`${todo.text} 삭제`}
>
삭제
</button>
</li>
);
}
export default TodoItem;
TodoItemProps를 살펴보겠습니다.
todo: Todo는 앞서 정의한 Todo 인터페이스 타입입니다. 이 prop에는 id, text, completed, createdAt이 모두 들어 있습니다.
onToggle과 onDelete는 이벤트 핸들러입니다. 타입은 (id: number) => void입니다. void는 반환값을 사용하지 않겠다는 의미입니다. 핸들러 함수가 무언가를 반환해도 무시합니다.
함수 타입을 정의할 때 void를 쓰는 이유가 있습니다. undefined를 쓰면 반환문이 없는 함수만 허용하지만, void를 쓰면 반환값이 있어도 그냥 허용합니다. 콜백 함수에는 void가 더 자연스럽습니다.
TodoList 컴포넌트 만들기
할 일 목록 전체를 표시하는 컴포넌트입니다.
// 새 파일: src/components/TodoList.tsx
import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';
interface TodoListProps {
todos: Todo[];
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
if (todos.length === 0) {
return (
<div className="empty-state">
<p>할 일이 없습니다. 새로운 할 일을 추가해보세요.</p>
</div>
);
}
return (
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
export default TodoList;
todos: Todo[]는 Todo 배열입니다. Array<Todo>와 동일하지만 Todo[]가 더 짧고 가독성이 좋습니다.
map 안에서 todo의 타입은 자동으로 Todo로 추론됩니다. 타입을 따로 명시하지 않아도 됩니다.
App.tsx에서 컴포넌트 사용하기
방금 만든 컴포넌트들을 App.tsx에서 사용해 결과를 확인합니다. 지금은 상태 관리 없이 하드코딩한 데이터를 씁니다.
// 수정: src/App.tsx
import { useState } from 'react';
import type { Todo } from './types/todo';
import TodoList from './components/TodoList';
import './App.css';
const initialTodos: Todo[] = [
{
id: 1,
text: 'TypeScript 공부하기',
completed: false,
createdAt: new Date(),
},
{
id: 2,
text: 'React 컴포넌트 만들기',
completed: true,
createdAt: new Date(),
},
];
function App() {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const handleToggle = (id: number) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const handleDelete = (id: number) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
return (
<div className="app">
<h1>할 일 앱</h1>
<TodoList
todos={todos}
onToggle={handleToggle}
onDelete={handleDelete}
/>
</div>
);
}
export default App;
useState<Todo[]>(initialTodos)에서 제네릭 타입 인수를 명시했습니다. 초기값으로 타입을 추론할 수 있지만, 명시하면 setTodos에 잘못된 타입을 넘겼을 때 즉시 에러를 잡아줍니다.
Props 타이핑의 선택적 속성
Props 중 일부는 없어도 되는 경우가 있습니다. 이때 ?를 붙입니다.
// 파일: 예시
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
className?: string; // 있어도 되고 없어도 됨
testId?: string; // 테스트용 data-testid
}
선택적 속성은 string | undefined 타입이 됩니다. 사용할 때 undefined 가능성을 처리해야 합니다.
// 파일: 예시
function TodoItem({ todo, onToggle, onDelete, className = '' }: TodoItemProps) {
// className에 기본값을 주면 undefined 처리가 필요 없음
}
기본값을 구조 분해 할당에서 바로 설정하면 깔끔합니다.
읽기 전용 Props
외부에서 변경하면 안 되는 prop에는 readonly를 붙일 수 있습니다.
// 파일: 예시
interface TodoItemProps {
readonly todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
컴포넌트 내부에서 todo.completed = true처럼 직접 수정하면 타입 에러가 납니다. 상태는 항상 setState를 통해 변경해야 한다는 규칙을 타입으로 강제하는 셈입니다.
정리
이 챕터에서 만든 것과 배운 개념을 정리합니다.
TodoItem컴포넌트:todo: Todo,onToggle,onDeletePropsTodoList컴포넌트:todos: Todo[]배열 PropsFC대신 일반 함수를 쓰는 이유children: ReactNode로 자식 요소 타이핑- 선택적 속성(
?)과 기본값 패턴 - 이벤트 핸들러 타입의 반환값에
void를 쓰는 이유
다음 챕터에서는 지금 App.tsx에 뭉쳐 있는 상태 관리 로직을 useReducer와 Context로 분리합니다.