React children 사용법 컴포넌트 합성과 재사용 UI 설계

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

React children으로 공통 UI 틀과 내부 내용을 어떻게 합성하나요?

children은 컴포넌트 태그 사이에 넣은 UI를 자식 컴포넌트가 표시하도록 전달하는 값입니다. 공통 카드 틀은 유지하고 내부 문단·버튼·목록만 바꿀 때 유용합니다.

본문 근거: React Passing Props to a Component · React ReactNode

독립 실습: children으로 서로 다른 내부 UI 조합하기

children으로 전달한 내용을 Panel 안에서 확인하세요.

예제 크게 보기 (새 탭)

JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.

src/App.tsx 전체 코드

import type { ReactNode } from 'react';
function Panel({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}
export default function App() {
  return (
    <main>
      <Panel title="소개"><p>책 읽는 시간입니다.</p></Panel>
      <Panel title="동작">
        <button onClick={() => console.log('읽기')}>시작</button>
      </Panel>
    </main>
  );
}

직접 확인: 같은 Panel 틀 안에 한쪽은 문단, 다른 쪽은 버튼이 표시됩니다. 버튼을 누르면 읽기 로그가 나옵니다.

변형 과제: Panel 구현을 바꾸지 않고 두 번째 패널에 목록을 넣어 보세요.

children이란

React 컴포넌트 태그 사이에 넣은 JSX는 children으로 전달됩니다.

type CardProps = {
  children: React.ReactNode;
};

function Card({ children }: CardProps) {
  return <section className="card">{children}</section>;
}

export default function Example() {
  return (
    <Card>
      <h2>오늘 할 일</h2>
      <p>React 공부하기</p>
    </Card>
  );
}

Card는 내부에 어떤 제목과 본문이 들어오는지 알 필요가 없습니다. 바깥에서 넣은 JSX를 정해진 틀 안에 렌더링하기만 합니다.

데이터 props와 children은 역할이 다릅니다

데이터를 전달할 때는 일반 props가 자연스럽습니다.

<TaskItem task={task} />

반면 카드의 내부 UI 자체를 외부에서 조합하고 싶다면 children이 더 자연스럽습니다.

<TaskCard>
  <TaskCardHeader>오늘 할 일</TaskCardHeader>
  <TaskCardContent>{task.title}</TaskCardContent>
</TaskCard>

children을 쓴다고 모든 props를 없애야 하는 것은 아닙니다.

  • task, id, disabled처럼 컴포넌트 동작에 필요한 값 → 일반 props
  • 내부에 어떤 UI를 배치할지 → children
  • 클릭 시 어떤 일을 요청할지 → 함수 props

이렇게 역할을 나누는 편이 읽기 쉽습니다.

wrapper 컴포넌트

공통 여백이나 테두리를 여러 곳에서 반복한다면 wrapper 컴포넌트를 만들 수 있습니다.

type PanelProps = {
  title: string;
  children: React.ReactNode;
};

export default function Panel({
  title,
  children,
}: PanelProps) {
  return (
    <section>
      <h2>{title}</h2>
      <div>{children}</div>
    </section>
  );
}

사용하는 곳에서는 실제 내용을 조합합니다.

<Panel title="진행 중 작업">
  <TaskList />
</Panel>

컴포넌트 합성의 기준

컴포넌트를 나누는 목적은 파일 수를 늘리는 것이 아닙니다.

다음 질문을 기준으로 판단합니다.

  • 여러 화면에서 같은 UI 틀을 사용하는가?
  • 내부 내용만 달라지는가?
  • 하나의 컴포넌트가 너무 많은 표시 책임을 갖고 있는가?
  • 작은 컴포넌트를 조합했을 때 사용 코드가 더 읽기 쉬워지는가?

한 번만 쓰이고 분리했을 때 오히려 흐름을 찾기 어려워진다면 굳이 나누지 않아도 됩니다.

자주 발생하는 오류

모든 내용을 props 문자열로 만듭니다

다음처럼 카드의 모든 UI를 props로 만들면 옵션이 계속 늘어날 수 있습니다.

<Card
  title="작업"
  description="React 공부"
  footerText="수정"
/>

구조 자체가 바뀌어야 한다면 children이나 작은 하위 컴포넌트 합성이 더 유연합니다.

children 안의 state 소유권을 wrapper가 가진다고 생각합니다

children은 JSX를 전달하는 방법입니다. 그 안의 state가 자동으로 wrapper 소유가 되는 것은 아닙니다.

컴포넌트를 지나치게 잘게 나눕니다

한 줄짜리 JSX마다 컴포넌트를 만들 필요는 없습니다. 이름을 붙였을 때 역할이 명확해지는 단위로 나눕니다.

학습 완료 체크리스트

  • children이 무엇인지 설명할 수 있다
  • 데이터 props와 UI 합성의 차이를 설명할 수 있다
  • wrapper 컴포넌트를 만들 수 있다
  • 합성이 오히려 복잡해지는 경우를 구분할 수 있다
선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.

이번 단계에서는 TaskCard라는 공통 틀을 만들고 내부 영역을 조합합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 6단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React props 사용법 부모에서 자식으로 데이터 전달하는 구조의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json, src/components/TaskCard.tsx, src/components/TaskItem.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

변경 순서: src/components/TaskCard.tsx에 children과 actions 영역을 만들고 TaskItem.tsx의 공통 틀을 이 컴포넌트로 교체합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 공통 카드 틀은 유지하고 제목·버튼만 children으로 바꾸려면?

step-06-children의 TaskCard 사용 부분에서 children으로 전달하는 작업 제목과 버튼의 순서를 바꾸어 봅니다. 공통 바깥 구조는 그대로 두고 내부 콘텐츠만 바뀌는지 npm run dev에서 확인합니다. 이후 코드의 순서를 복구합니다. 05회의 함수 props 전달 경로는 유지합니다.

예상 화면·상태: 바깥 카드 틀은 그대로이고 안쪽 제목과 버튼만 전달 순서대로 나타납니다. 버튼은 여전히 부모로 작업 id를 전달하지만 이 회차에서 실제 완료 상태는 변경하지 않습니다. 이는 독자 확인 기준이며 이번 편집에서 실제 화면을 관측하지 않았습니다.

children이 비어 보이면 TaskCard의 props 타입과 {children} 렌더링 위치를 확인합니다. 클릭 로그가 사라지면 TaskItem → TaskCard 합성 과정에서 버튼의 이벤트 연결이 보존됐는지 살펴봅니다. 빌드와 브라우저 검증은 미확인입니다.

다음 학습: 07회 useState

완료 코드 ZIP 다운로드 — step-06-composition

이전 체크포인트:

step-05-props

지금 TaskItem은 task 데이터와 이벤트 함수를 받아 한 컴포넌트 안에서 모든 구조를 직접 렌더링합니다.

이번 단계에서는 TaskCard라는 공통 틀을 만들고 내부 영역을 조합합니다.

변경 파일

src/
├─ components/
│  ├─ Header.tsx
│  ├─ TaskCard.tsx
│  ├─ TaskItem.tsx
│  └─ TaskList.tsx
├─ App.tsx
└─ types/task.ts

Before

<li>
  <button>완료</button>
  <span>{task.title}</span>
  <button>삭제</button>
</li>

After

<TaskCard
  actions={
    <>
      <button type="button" onClick={() => onToggle(task.id)}>
        {task.completed ? '완료 취소' : '완료'}
      </button>
      <button type="button" onClick={() => onDelete(task.id)}>
        삭제
      </button>
    </>
  }
>
  <span>{task.title}</span>
</TaskCard>

TaskCard는 공통 구조만 담당하고 TaskItem은 task 데이터와 이벤트 의미를 담당합니다.

TaskList와 App의 데이터 흐름은 이전 단계와 동일합니다.

실행 확인

npm run build
  • 작업 제목이 기존처럼 표시되는지 확인합니다.
  • 완료/삭제 버튼 이벤트가 그대로 동작하는지 확인합니다.
  • TaskCard가 task의 데이터 처리 로직을 알지 않아도 되는지 확인합니다.

완료 체크포인트:

step-06-composition

전체 코드 뷰어의 모든 파일은 다운로드 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-06-composition",
  "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 Header from './components/Header';
import TaskList from './components/TaskList';
import type { Task } from './types/task';

const tasks: Task[] = [
  { id: 'task-1', title: 'props 구조 익히기', completed: true },
  { id: 'task-2', title: '이벤트 함수 전달하기', completed: false },
];

function App() {
  const handleToggle = (id: string) => {
    console.log('toggle', id);
  };

  const handleDelete = (id: string) => {
    console.log('delete', id);
  };

  return (
    <main className="app">
      <Header />
      <TaskList
        tasks={tasks}
        onToggle={handleToggle}
        onDelete={handleDelete}
      />
    </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/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/TaskItem.tsx

import type { Task } from '../types/task';
import TaskCard from './TaskCard';

type TaskItemProps = {
  task: Task;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
};

export default function TaskItem({
  task,
  onToggle,
  onDelete,
}: TaskItemProps) {
  return (
    <TaskCard
      actions={
        <>
          <button
            type="button"
            onClick={() => onToggle(task.id)}
          >
            {task.completed ? '완료 취소' : '완료'}
          </button>
          <button
            type="button"
            onClick={() => onDelete(task.id)}
          >
            삭제
          </button>
        </>
      }
    >
      <span>{task.title}</span>
    </TaskCard>
  );
}

src/components/TaskList.tsx

import TaskItem from './TaskItem';
import type { Task } from '../types/task';

type TaskListProps = {
  tasks: Task[];
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
};

export default function TaskList({
  tasks,
  onToggle,
  onDelete,
}: TaskListProps) {
  if (tasks.length === 0) {
    return <p>등록된 작업이 없습니다.</p>;
  }

  return (
    <section>
      <h2>작업 목록</h2>
      <ul>
        {tasks.map((task) => (
          <TaskItem
            key={task.id}
            task={task}
            onToggle={onToggle}
            onDelete={onDelete}
          />
        ))}
      </ul>
    </section>
  );
}

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';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/types/task.ts

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};

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 children 사용법 컴포넌트 합성과 재사용 UI 설계 학습 표지

React · 6/32 · TaskFlow 누적 실습

React children 사용법 컴포넌트 합성과 재사용 UI 설계

React · 6 / 32 · 필수 학습 · children·컴포넌트 합성

props로 값과 이벤트를 전달할 수 있게 되면 다음 단계는 “컴포넌트를 어떻게 조합할 것인가”입니다. 모든 UI를 하나의 거대한 props 객체로 제어하면 재사용하기 어렵고 API도 복잡해집니다.

이 글에서는 children을 이용해 컴포넌트 안에 다른 UI를 넣는 방법과, 데이터를 전달하는 props와 UI를 조합하는 children의 역할 차이를 설명합니다. 하단에서는 TaskFlow의 TaskItem을 TaskCard 구조로 정리합니다.

이번 글에서 배우는 내용

  • children의 의미
  • 데이터 props와 UI 합성의 차이
  • wrapper 컴포넌트
  • 여러 작은 컴포넌트를 조합하는 방법
  • 재사용 가능한 컴포넌트 API 설계 기준
  • 과도한 props를 줄이는 방법

목차

다음 학습

다음 글에서는 TaskFlow 작업 배열을 useState로 옮겨 작업 추가·완료·삭제가 실제 화면 상태를 변경하도록 만듭니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 33개 · 필수 27개

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기