지금까지 Todo 앱의 모든 코드를 App.jsx 하나에 몰아넣었는데 이제 React다운 구조로 나눠보자.
현재 Todo 앱에는 서로 다른 역할들이 섞여 있다.
App
├─ Todo 입력
├─ Todo 목록 출력
├─ Todo 하나 출력
├─ 추가 기능
├─ 완료 기능
└─ 삭제 기능
이걸 이렇게 나눠보자.
App
├─ TodoForm
└─ TodoList
└─ TodoItem
역할은
App
→ todos State 관리
TodoForm
→ 새로운 Todo 입력
TodoList
→ Todo 목록 출력
TodoItem
→ Todo 하나 출력
이다.
여기서 중요한 것은 State를 무작정 각 컴포넌트로 옮기는 게 아니라는 점이다.
Step 1. 파일 만들기
src 폴더 안에 다음 파일을 만든다.
src/
├─ App.jsx
├─ TodoForm.jsx
├─ TodoList.jsx
└─ TodoItem.jsx
현재 학습 단계에서는 구조를 단순하게 유지하기위해 별도의 components 폴더까지는 만들지 않는다.
Step 2. TodoItem 분리
현재 src/App.jsx에는 이런 코드가 있다.
<li key={todo.id}>
{todo.completed ? "✅ " : "⬜️ "}
{todo.text}
<button onClick={() => handleToggle(todo.id)}>
{todo.completed ? "취소" : "완료"}
</button>
<button onClick={() => handleDelete(todo.id)}>삭제</button>
</li>
이건 Todo 하나를 보여주는 UI이다.
따라서 TodoItem으로 분리하기 좋다.
src/TodoItem.jsx
function TodoItem({ todo }) {
return (
<li>
{todo.completed ? "✅ " : "⬜️ "}
{todo.text}
</li>
);
}
export default TodoItem;
이제 App.jsx에서
<TodoItem todo={todo} />
라고 작성하여 사용할 수 있다.
아직 완료/삭제 기능은 연결하지 않았는데 그건 조금 뒤에서 연결할 것이다.
Step 3. TodoList 만들기
이번에는 Todo 배열 전체를 출력하는 부분이다.
기존 코드:
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
이 전체를 TodoList로 분리해보자.
src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoList;
여기서
function TodoList({ todos })
TodoList는 부모 컴포넌트 App에게서 todos를 props로 받는다.
그리고 TodoList는
todo={todo}
를 통해 다시 자식 컴포넌트인 TodoItem에게 todo를 하나씩 전달한다.
데이터 흐름은
App
│
│ todos
↓
TodoList
│
│ todo
↓
TodoItem
이다.
Step 4. App에서 TodoList 사용하기
src/App.jsx
import { useState } from "react";
import TodoList from "./TodoList";
function App() {
const [todos, setTodos] = useState([
{
id: 1,
text: "React 공부하기",
completed: false,
},
{
id: 2,
text: "운동하기",
completed: true,
},
]);
return (
<div>
<h1>Todo List</h1>
<TodoList todos={todos} />
</div>
);
}
export default App;
이제 구조를 보면
App
↓
todos 전달
↓
TodoList
↓
map()
↓
TodoItem
이다.
Step 5. 완료 기능은 어디에 둘까?
현재 todos State는 App에 있다.
const [todos, setTodos] = useState([]);
그렇다면 Todo를 수정하는 함수도 기본적으로 App에 두는 것이 자연스럽다.
function handleToggle(id) {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
}
그런데 버튼은 TodoItem 안에 있다.
App
→ State 있음
TodoItem
→ 버튼 있음
어떻게 연결할 수 있을까?
함수를 Props로 내려주면 된다.
Step 6. 함수도 Props로 전달할 수 있다
src/App.jsx
<TodoList todos={todos} onToggle={handleToggle} />
여기서
onToggle={handleToggle}
은 함수를 전달하는 것이다.
문자열이나 숫자만 Props가 될 수 있는 것이 아니다.
문자열, 숫자, boolean, 객체, 배열, 함수
모두 Props로 전달할 수 있다.
Step 7. TodoList가 함수를 다시 전달
src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onToggle }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
/>
))}
</ul>
);
}
export default TodoList;
이제 흐름은
App
│
│ handleToggle
↓
TodoList
│
│ onToggle
↓
TodoItem
이다.
TodoList는 그냥 함수를 전달만 하고 있다.
Step 8. TodoItem에서 함수 실행
src/TodoItem.jsx
function TodoItem({ todo, onToggle }) {
return (
<li>
{todo.completed ? "✅ " : "⬜️ "}
{todo.text}
<button onClick={() => onToggle(todo.id)}>
{todo.completed ? "취소" : "완료"}
</button>
</li>
);
}
export default TodoItem;
버튼을 누르면
onToggle(todo.id)
가 실행된다.
이 onToggle의 실제 정체는 handleToggle 함수이다.
즉, 흐름은
TodoItem 버튼 클릭
↓
onToggle(todo.id)
↓
App의 handleToggle(id)
↓
setTodos()
↓
State 변경
↓
다시 렌더링
이다.
이 패턴이 React에서 매우 중요하다.
Step 9. 삭제 기능도 똑같다
src/App.jsx
function handleDelete(id) {
setTodos((prevTodos) =>
prevTodos.filter((todo) => todo.id !== id)
);
}
그리고
<TodoList
todos={todos}
onToggle={handleToggle}
onDelete={handleDelete}
/>
src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onToggle, onDelete }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
export default TodoList;
src/TodoItem.jsx
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li>
{todo.completed ? "✅ " : "⬜️ "}
{todo.text}
<button onClick={() => onToggle(todo.id)}>
{todo.completed ? "취소" : "완료"}
</button>
<button onClick={() => onDelete(todo.id)}>
삭제
</button>
</li>
);
}
export default TodoItem;
Step 10. 이제 TodoForm 분리
이번에는 입력창 부분이다.
기존에는 App 컴포넌트에 이런 State가 있었다.
const [input, setInput] = useState("");
이 input State도 App에 있어야 할까?
한번 생각해보자.
input 값을 사용하는 곳은 사실 Todo 입력 폼뿐이다.
TodoForm
→ input 사용
→ setInput 사용
다른 컴포넌트는 input을 몰라도 된다.
따라서 이 input State는 TodoForm 안으로 옮겨도 된다.
이건 아주 중요한 설계 감각이다.
Step 11. TodoForm 만들기
src/TodoForm.jsx
import { useState } from "react";
function TodoForm({ onAdd }) {
const [input, setInput] = useState("");
function handleChange(event) {
setInput(event.target.value);
}
function handleSubmit(event) {
event.preventDefault();
if (input.trim() === "") {
return;
}
onAdd(input.trim());
setInput("");
}
return (
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleChange}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
여기서 중요한 변화가 있다.
TodoForm 컴포넌트는 직접 todos를 변경하지 않는다.
그냥
onAdd(input.trim());
를 통해 부모 컴포넌트에게 알려준다.
쉽게 말하면
"부모 컴포넌트야, 사용자가 이 내용을 추가하겠대."
라고 전달하는 것이다.
좀 더 설명하자면
부모 컴포넌트인 App이 자식 컴포넌트인 TodoForm에게 onAdd라는 이름의 prop으로 자기 함수를 전달한다.
onAdd는 실제로는 부모 컴포넌트에 있는 함수이다.
즉, State 끌어올리기는 부모가 자기 함수를 자식에게 내려주고, 자식이 필요한 시점에 그 함수를 호출하는 구조이다.
Step 12. 실제 Todo 추가는 App이 한다
src/App.jsx
function handleAdd(text) {
const newTodo = {
id: Date.now(),
text,
completed: false,
};
setTodos((prevTodos) => [...prevTodos, newTodo]);
}
그리고
<TodoForm onAdd={handleAdd} />
라고 전달한다.
흐름:
TodoForm
사용자가 "React 공부" 입력
↓
onAdd("React 공부")
↓
App의 handleAdd("React 공부")
↓
newTodo 생성
↓
setTodos()
↓
새 Todo 추가
Step 13. 왜 todos State를 TodoForm에 두지 않을까?
TodoForm뿐만 아니라 TodoList도 todos가 필요하다.
그런데 TodoForm에 todos State를 두는 경우에는 TodoList는 TodoForm의 자식 컴포넌트가 아니므로
todos State를 전달할 길이 없다.
따라서
App
todos
↙ ↘
TodoForm TodoList
처럼 TodoForm과 TodoList의 공통 부모인 App이 State를 가져야 한다.
이것이 바로 State 끌어올리기(Lifting State Up)이다.
Step 14. 최종 코드
src/App.jsx
import { useState } from "react";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";
function App() {
const [todos, setTodos] = useState([]);
function handleAdd(text) {
const newTodo = {
id: Date.now(),
text,
completed: false,
};
setTodos((prevTodos) => [...prevTodos, newTodo]);
}
function handleToggle(id) {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
}
function handleDelete(id) {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
}
const completedCount = todos.filter((todo) => todo.completed).length;
return (
<div>
<h1>Todo List</h1>
<p>
완료: {completedCount} / {todos.length}
</p>
<TodoForm onAdd={handleAdd} />
<TodoList todos={todos} onToggle={handleToggle} onDelete={handleDelete} />
</div>
);
}
export default App;
src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onToggle, onDelete }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
export default TodoList;
src/TodoItem.jsx
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li>
{todo.completed ? "✅ " : "⬜️ "}
{todo.text}
<button onClick={() => onToggle(todo.id)}>
{todo.completed ? "취소" : "완료"}
</button>
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
}
export default TodoItem;
src/TodoForm.jsx
import { useState } from "react";
function TodoForm({ onAdd }) {
const [input, setInput] = useState("");
function handleChange(event) {
setInput(event.target.value);
}
function handleSubmit(event) {
event.preventDefault();
if (input.trim() === "") {
return;
}
onAdd(input.trim());
setInput("");
}
return (
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleChange}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
Step 15. 지금 구조를 꼭 이해하자.
현재 Todo앱의 데이터 흐름이다.
App
todos State
│
┌──────────┴──────────┐
│ │
↓ ↓
TodoForm TodoList
│ │
│ onAdd │ todos
│ ↓
│ TodoItem
│ │
│ onToggle / onDelete
│ │
└──────────→ App ←────┘
좀 더 단순히 표현하면
데이터는 아래로
Props ↓
이벤트는 위로
함수 호출 ↑
React에서는 데이터는 부모 → 자식으로 내려가고, 자식의 행동은 부모가 내려준 함수를 호출해서 위에 알린다.
+
관례적으로
실제 처리 함수 이름은 handleSomething과 같이 사용하고,
함수를 Props로 전달하는 경우에는 onSomething과 같이 사용한다.
Step 16. State를 어디에 둘지 판단하는 기준
특정 State를
- 하나의 컴포넌트만 사용하는 경우 → 그 컴포넌트 안에 둔다.
- 여러 컴포넌트가 공유하는 경우 → 가장 가까운 공통 부모에 둔다.
이게 State 설계의 가장 기본적인 원칙이다.
실습
지금 구조에 전체 완료 버튼을 새로운 컴포넌트로 분리하여 추가해보자.
src/TodoActions.jsx
function TodoActions({ onCompleteAll }) {
return <button onClick={onCompleteAll}>전체 완료</button>;
}
export default TodoActions;
그리고 App.jsx에 함수 추가
function handleCompleteAll() {
setTodos((prevTodos) =>
prevTodos.map((todo) => ({
...todo,
completed: true,
})),
);
}
컴포넌트 사용:
<TodoActions onCompleteAll={handleCompleteAll} />
힌트:
여기서도 흐름은 똑같다.
TodoActions
↓
버튼 클릭
↓
onCompleteAll()
↓
App의 handleCompleteAll()
↓
setTodos()
핵심 정리
컴포넌트 분리는 단순히 파일을 여러 개 만드는 기술이 아니다.
핵심은 컴포넌트 역할 분리와 State 위치이다.
App
→ Todo 데이터 관리
TodoForm
→ 입력 책임
TodoList
→ 목록 책임
TodoItem
→ 한 항목의 UI 책임
그리고 가장 중요한 두 문장
- State는 필요한 곳들의 가장 가까운 공통 부모에 둔다.
- 데이터는 Props로 아래로 내려가고, 이벤트는 함수를 통해 부모에게 전달한다.
여기까지가 React의 기본 컴포넌트 설계 방식이다.
'개발 > React' 카테고리의 다른 글
| [React] 컴포넌트 합성과 children (0) | 2026.09.27 |
|---|---|
| [React] useEffect (0) | 2026.09.24 |
| [React] Todo List 만들기 실습 (2) [배열 항목 수정] (0) | 2026.09.20 |
| [React] Todo List 만들기 실습 (1) [배열 항목 추가와 삭제] (0) | 2026.09.17 |
| [React] 조건부 렌더링과 리스트 렌더링 (0) | 2026.09.15 |