React 부모·자식 사이의 값과 callback을 props 타입으로 어떻게 지정하나요?
props 타입은 자식이 꼭 받아야 할 값, 생략 가능한 값, 부모에 요청할 함수의 인자를 정합니다. 숫자 value와 선택 label, onConfirm 함수, children을 가진 작은 컴포넌트에서 올바른 호출과 오류를 비교합니다.
본문 근거: TypeScript with React
독립 실습: 필수·선택·함수 props의 계약 확인하기
타입으로 정한 Panel props와 이벤트 동작을 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState, type ReactNode } from 'react';
type PanelProps = { value: number; label?: string; onConfirm: () => void; children: ReactNode };
function Panel({ value, label = '수량', onConfirm, children }: PanelProps) {
return (
<section>
<p>{label}: {value}</p>
{children}
<button onClick={onConfirm}>확인</button>
</section>
);
}
export default function App() {
const [message, setMessage] = useState('');
return (
<Panel value={2} onConfirm={() => setMessage('확인됨')}>
<p>{message}</p>
</Panel>
);
}
예상 결과와 직접 확인: label을 생략해도 수량: 2가 표시됩니다. 확인 버튼은 확인됨 문구를 출력합니다.
변형 과제: value를 생략하거나 문자열로 바꿔 npm run build에서 타입 오류를 확인하고 숫자 prop으로 복구합니다.
props 타입은 컴포넌트 API입니다
type TaskItemProps = {
task: Task;
};
TaskItem이 task 하나만 필요하다면 tasks 배열 전체나 dispatch를 props로 전달할 필요가 없습니다.
타입만 봐도 이 컴포넌트가 무엇을 필요로 하는지 알 수 있어야 합니다.
optional props
값이 없어도 정상 동작할 수 있을 때 optional을 사용합니다.
type TaskSummaryProps = {
label?: string;
value: number;
};
하지만 실제로 반드시 필요한 값을 습관적으로 optional로 만들면 컴포넌트 내부에서 불필요한 undefined 처리가 늘어납니다.
함수 props
type ConfirmButtonProps = {
onConfirm: () => void;
};
DOM click event 자체가 필요하지 않다면 이벤트 객체를 외부 API에 노출하지 않는 편이 더 단순합니다.
다음처럼 컴포넌트 내부에서 DOM 이벤트를 처리하고 부모에는 도메인 이벤트만 전달할 수 있습니다.
<button onClick={onConfirm}>
확인
</button>
children 타입
일반적인 React 노드를 받는 래퍼라면 ReactNode를 사용할 수 있습니다.
import type { ReactNode } from 'react';
type PanelProps = {
children: ReactNode;
};
타입은 어디에 둘까
컴포넌트 하나에서만 사용하는 Props 타입:
– 컴포넌트 파일 가까이에 둡니다.
여러 기능에서 공유하는 도메인 타입:
– types/task.ts 같은 공통 위치를 사용할 수 있습니다.
모든 타입을 한 파일에 모으는 것도, 모든 타입을 무조건 각 파일에 흩어 놓는 것도 정답은 아닙니다.
자주 발생하는 오류와 해결
- optional인데 undefined 처리를 하지 않음
- 함수 props의 매개변수 타입 불일치
- 이벤트 객체를 필요 이상으로 상위 컴포넌트까지 전달
- children 타입을 string처럼 너무 좁게 지정
- 컴포넌트가 필요하지 않은 거대한 객체를 통째로 props로 받음
학습 완료 체크리스트
- 필수/선택 props를 구분할 수 있습니다.
- 함수 props의 계약을 작성할 수 있습니다.
- children에 ReactNode를 사용할 수 있습니다.
- 도메인 타입과 컴포넌트 전용 Props 타입 위치를 구분할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
기능을 새로 추가하기보다 TaskFlow의 컴포넌트 API를 점검합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 20단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React 커스텀 훅 설계 상태 로직 컴포넌트 분리 기준의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskSummary.tsx, src/types/task.ts. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/types/task.ts에는 공유 Task 타입을 두고 src/components/TaskSummary.tsx에는 표시 전용 Props 타입을 둡니다. 실제 호출부에서 필수 숫자와 선택 label을 대조합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 작업 수를 표시하는 TaskSummary에서 필수 숫자와 선택 label을 어떤 타입으로 구분할까요?
컴포넌트 실습: step-20-props-types에서 TaskSummary의 Props 선언과 App의 호출 위치를 대조하고, 기존 TaskCard의 children 타입도 함께 확인합니다. 필수 데이터, 선택적 표시 옵션, 함수 props, children을 나누고 각각 한 사용 예를 확인합니다.
예상 화면·상태: 필수 prop을 빠뜨리면 타입 오류가 나고 선택적 prop을 생략해도 기본 UI는 표시되어야 합니다. 타입이 런타임의 빈 값 검증을 대신하지는 않습니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 호출부 오류가 나면 optional prop 기본값, 함수 인자 타입, ReactNode children 사용 여부를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 21회에서 React Hook Form을 설치해 기존 TaskForm을 연결합니다.
완료 코드 ZIP 다운로드 — step-20-props-type-design
이전 체크포인트
step-19-custom-hook
이번 목표
기능을 새로 추가하기보다 TaskFlow의 컴포넌트 API를 점검합니다.
Before
일부 타입이 기능 구현 과정에 맞춰 임시로 넓게 정의될 수 있습니다.
After
각 컴포넌트는 실제 필요한 값만 받으며 도메인 타입과 Props 타입의 위치가 구분됩니다.
최종 타입 구조
이 단계에서는 기능보다 타입 계약이 더 명확해지는 것이 중요합니다.
완료 체크포인트:
step-20-props-type-design
전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
index.html
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json
{
"name": "taskflow-step-20-props-type-design",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2"
}
}
src/App.tsx
import TaskSummary from './components/TaskSummary';
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import { useEffect, useState } from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import { useTaskStats } from './hooks/useTaskStats';
function App() {
const {
totalCount,
completedCount,
remainingCount,
} = useTaskStats();
useEffect(() => {
document.title = `TaskFlow (${remainingCount})`;
}, [remainingCount]);
return (
<main className="app">
<Header />
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskList />
<StateResetDemo />
</main>
);
}
export default App;
src/components/Header.tsx
export default function Header() {
return (
<header>
<p className="eyebrow">React Learning Project</p>
<h1>TaskFlow</h1>
<p>오늘 할 일을 하나씩 정리해 보세요.</p>
</header>
);
}
src/components/NetworkStatus.tsx
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/StateResetDemo.tsx
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/TaskCard.tsx
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="task-card">
<div className="task-card__content">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskForm.tsx
import {
useRef,
useState,
type FormEvent,
} from 'react';
import { useTaskContext } from '../context/TaskContext';
export default function TaskForm() {
const [title, setTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const { dispatch } = useTaskContext();
const handleSubmit = (
event: FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
inputRef.current?.focus();
return;
}
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title: trimmedTitle,
completed: false,
},
});
setTitle('');
inputRef.current?.focus();
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">새 작업</label>
<input
ref={inputRef}
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
/>
<button type="submit">추가</button>
</form>
);
}
src/components/TaskItem.tsx
import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const { dispatch } = useTaskContext();
return (
<TaskCard>
<button
type="button"
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
<button
type="button"
onClick={() =>
dispatch({
type: 'task/deleted',
id: task.id,
})
}
>
삭제
</button>
</TaskCard>
);
}
src/components/TaskList.tsx
import { useTaskContext } from '../context/TaskContext';
import TaskItem from './TaskItem';
export default function TaskList() {
const { tasks } = useTaskContext();
if (tasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
src/components/TaskSummary.tsx
type TaskSummaryProps = {
total: number;
completed: number;
remaining: number;
label?: string;
};
export default function TaskSummary({
total,
completed,
remaining,
label = '작업 현황',
}: TaskSummaryProps) {
return (
<section aria-label={label}>
<p>
전체 {total} · 완료 {completed} · 남음 {remaining}
</p>
</section>
);
}
src/context/TaskContext.tsx
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
Task,
TaskAction,
} from '../types/task';
type TaskContextValue = {
tasks: Task[];
dispatch: Dispatch<TaskAction>;
};
const TaskContext = createContext<
TaskContextValue | undefined
>(undefined);
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'Reducer와 Context 연결하기',
completed: false,
},
];
export function TaskProvider({
children,
}: {
children: ReactNode;
}) {
const [tasks, dispatch] = useReducer(
taskReducer,
initialTasks,
);
return (
<TaskContext.Provider
value={{ tasks, dispatch }}
>
{children}
</TaskContext.Provider>
);
}
export function useTaskContext() {
const value = useContext(TaskContext);
if (!value) {
throw new Error(
'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
);
}
return value;
}
src/hooks/useNetworkStatus.ts
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/hooks/useTaskStats.ts
import { useTaskContext } from '../context/TaskContext';
export function useTaskStats() {
const { tasks } = useTaskContext();
const totalCount = tasks.length;
const completedCount = tasks.filter(
(task) => task.completed,
).length;
const remainingCount =
totalCount - completedCount;
return {
totalCount,
completedCount,
remainingCount,
};
}
src/index.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
.app {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
}
.eyebrow {
color: #666;
font-size: 14px;
}
ul {
padding-left: 20px;
}
button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TaskProvider>
<App />
</TaskProvider>
</StrictMode>,
);
src/reducers/taskReducer.ts
import type {
Task,
TaskAction,
} from '../types/task';
export function taskReducer(
tasks: Task[],
action: TaskAction,
): Task[] {
switch (action.type) {
case 'task/added':
return [...tasks, action.task];
case 'task/toggled':
return tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task,
);
case 'task/deleted':
return tasks.filter(
(task) => task.id !== action.id,
);
default:
return tasks;
}
}
src/types/task.ts
export type TaskId = string;
export type Task = {
id: TaskId;
title: string;
completed: boolean;
};
export type TaskAction =
| {
type: 'task/added';
task: Task;
}
| {
type: 'task/toggled';
id: TaskId;
}
| {
type: 'task/deleted';
id: TaskId;
};
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });
글 소개·학습 준비·전체 목차

React · 20/32 · TaskFlow 누적 실습
React 컴포넌트 props 타입 지정 부모 자식 값 구조
React · 20 / 32 · 필수 학습 · TypeScript props 설계
props 타입은 자식이 반드시 받아야 할 값, 생략할 수 있는 값, 부모에 요청할 함수의 인자를 정의합니다. 필수 숫자 value, 선택 label, 함수 onConfirm, ReactNode children을 가진 독립 컴포넌트에서 올바른 호출과 타입 오류를 비교합니다.
이번 글에서 배우는 내용
- required와 optional props
- 함수 props 타입
- DOM 이벤트 타입과 도메인 callback 타입
- ReactNode와 children
- 타입을 컴포넌트 옆에 둘지 공통 파일로 분리할지
- props가 너무 넓을 때 생기는 결합도
목차
다음 학습
다음 글에서는 외부 라이브러리인 React Hook Form을 처음 설치합니다. 설치부터 최소 폼 동작까지 상단에서 독립적으로 배우고, 하단에서는 기존 TaskForm을 React Hook Form 기반으로 교체합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.