React 제어 input의 state와 제출된 목록의 state는 어디에 두나요?
제어 input은 value로 state를 보여주고 onChange로 입력값을 바꿉니다. 작성 중인 문자열은 자식 폼에, 제출한 목록은 폼과 목록이 함께 쓰는 부모에 둘 수 있습니다.
적용 조건·범위: 모든 state를 무조건 가장 위에 둘 필요는 없습니다. 필요한 범위만큼만 올리는 것이 핵심입니다.
독립 실습: 자식 폼의 입력을 부모 목록에 추가하기
입력 후 제출하여 제어 입력과 제출 결과를 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState, type FormEvent } from 'react';
function EntryForm({ onAdd }: { onAdd: (title: string) => void }) {
const [title, setTitle] = useState('');
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!title.trim()) return;
onAdd(title.trim());
setTitle('');
}
return (
<form onSubmit={submit}>
<input aria-label="제목" value={title} onChange={e => setTitle(e.target.value)} />
<button type="submit">추가</button>
</form>
);
}
export default function App() {
const [items, setItems] = useState<string[]>([]);
return (
<main>
<EntryForm onAdd={title => setItems(previous => [...previous, title])} />
<p>추가한 제목: {items.join(', ')}</p>
</main>
);
}
예상 결과와 직접 확인: 산책 입력 후 Enter를 누르면 부모의 제목 목록에 한 번 추가되고 입력값이 비워집니다. 공백은 추가되지 않습니다.
변형 과제: 입력 중 값과 제출된 목록 중 어느 상태를 부모가 소유하는지 설명합니다.
제어 컴포넌트란
입력값을 React state가 제어하는 input을 제어 컴포넌트라고 부릅니다.
const [title, setTitle] = useState('');
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
input에 보이는 값은 title state이고, 사용자가 입력하면 onChange가 state를 바꿉니다.
폼 제출
form의 submit 이벤트를 사용하면 Enter 키 제출도 자연스럽게 처리할 수 있습니다.
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// 저장 처리
};
기본 HTML form 제출은 페이지 이동을 발생시키므로 SPA 예제에서는 preventDefault로 기본 동작을 막습니다.
상태 끌어올리기
입력 중인 title은 TaskForm 안에서만 필요하므로 TaskForm 로컬 state에 둘 수 있습니다.
하지만 실제 tasks 배열은 목록과 여러 컴포넌트가 함께 사용하므로 App이 소유합니다.
TaskForm은 새 작업의 제목만 부모에게 전달합니다.
TaskForm
title state
↓ onAdd(title)
App
tasks state
모든 state를 무조건 가장 위에 둘 필요는 없습니다. 필요한 범위만큼만 올리는 것이 핵심입니다.
자주 발생하는 오류와 해결
input이 입력되지 않습니다
value는 있지만 onChange가 없는지 확인합니다.
submit할 때 화면이 새로고침됩니다
form submit 이벤트에서 preventDefault를 호출합니다.
같은 값을 부모와 자식 state에 둘 다 저장합니다
한 값을 여러 곳에서 원본처럼 관리하면 동기화 문제가 생깁니다. 누가 원본을 소유하는지 하나로 정합니다.
학습 완료 체크리스트
- input을 state와 연결할 수 있습니다.
- form submit을 처리할 수 있습니다.
- 로컬 state와 끌어올린 state를 구분할 수 있습니다.
- 입력 상태와 실제 작업 목록 상태의 책임을 나눌 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
샘플 작업 버튼을 제거하고 사용자가 직접 제목을 입력해 작업을 추가하는 TaskForm을 만듭니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 11단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React state 보존 초기화 key로 컴포넌트 상태 리셋하기의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskForm.tsx, src/components/TaskList.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/components/TaskForm.tsx에 제어 input과 submit을 작성하고 App.tsx의 onAdd에 제목을 전달합니다. 목록의 원본 tasks는 App에 둡니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 입력 폼의 텍스트는 어디에 보관해야 목록 추가와 입력 초기화가 함께 동작하나요?
컴포넌트 실습: step-11-controlled-form에서 input에 새 제목을 입력하고 Enter로 제출합니다. 같은 과정을 추가 버튼으로 반복한 뒤 공백만 입력해 제출해 봅니다.
예상 화면·상태: 유효한 제목은 목록에 한 번씩 추가되고 제출 후 input이 비어야 합니다. 공백 입력의 처리 기준은 본문의 trim·검증 코드와 비교하며, 이번 편집에서 실행 결과를 확정하지 않았습니다. 이 내용은 독자가 확인할 기준이며 이번 편집에서 앱을 실행하거나 브라우저 화면을 관찰한 결과가 아닙니다.
오류 점검: 입력은 보이는데 목록에 없으면 폼의 submit 이벤트와 부모 tasks 업데이트를, 빈 값으로 돌아가지 않으면 input의 value와 setTitle을 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 12회에서 제출 후 input 포커스를 useRef로 되돌립니다.
완료 코드 ZIP 다운로드 — step-11-controlled-form
지금까지 TaskFlow는 고정된 제목의 샘플 작업만 추가했습니다. 실제 사용자가 원하는 작업을 입력하려면 input의 값을 React state와 연결해야 합니다.
이전 체크포인트
step-10-state-reset
이번 목표
샘플 작업 버튼을 제거하고 사용자가 직접 제목을 입력해 작업을 추가하는 TaskForm을 만듭니다.
실행 확인
- input에 원하는 작업을 입력합니다.
- 추가 버튼 또는 Enter로 제출합니다.
- 목록에 새 작업이 추가됩니다.
- 제출 후 input이 빈 문자열로 초기화됩니다.
완료 체크포인트:
step-11-controlled-form
전체 코드 뷰어의 모든 파일은 다운로드 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-11-controlled-form",
"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 StateResetDemo from './components/StateResetDemo';
import { useState } from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import type { Task } from './types/task';
const initialTasks: Task[] = [
{
id: 'task-1',
title: '제어 컴포넌트 익히기',
completed: false,
},
];
function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const handleAdd = (title: string) => {
setTasks((previousTasks) => [
...previousTasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
]);
};
const handleToggle = (id: string) => {
setTasks((previousTasks) =>
previousTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
};
const handleDelete = (id: string) => {
setTasks((previousTasks) =>
previousTasks.filter((task) => task.id !== id),
);
};
return (
<main className="app">
<Header />
<TaskForm onAdd={handleAdd} />
<TaskList
tasks={tasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
<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/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 {
useState,
type FormEvent,
} from 'react';
type TaskFormProps = {
onAdd: (title: string) => void;
};
export default function TaskForm({
onAdd,
}: TaskFormProps) {
const [title, setTitle] = useState('');
const handleSubmit = (
event: FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
return;
}
onAdd(trimmedTitle);
setTitle('');
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">
새 작업
</label>
<input
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
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 (
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
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 · 11/32 · TaskFlow 누적 실습
React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
React · 11 / 32 · 필수 학습 · 폼·상태 위치
제어 input은 value로 state를 표시하고 onChange로 입력 state를 갱신합니다. 입력 중인 문자열은 자식 폼에 두고, 제출된 목록은 폼과 목록이 함께 쓰는 부모에 둡니다. 자식이 onAdd로 제목을 전달하는 독립 예제에서 상태를 필요한 범위까지 끌어올리는 기준을 확인합니다.
이번 글에서 배우는 내용
- controlled input
- value와 onChange 연결
- form submit 처리
- preventDefault
- 상태를 어느 컴포넌트에 둘지
- 부모가 데이터 원본을 소유하는 구조
목차
다음 학습
다음 글에서는 작업 추가가 끝난 뒤 input으로 포커스를 되돌리면서 useRef와 state의 역할 차이를 확인합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.