React 컴포넌트 props 타입 지정 부모 자식 값 구조

··설명 약 6분·작성: 해비·블로그 소개

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 부모 컴포넌트에서 자식 컴포넌트로 props를 전달하는 타입 구조

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개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기