지금까지 배운 개념들을 실전 프로젝트에 적용해볼 차례다. Todo 앱을 만들기 전에 요구사항 정의, 컴포넌트 트리 설계, 그리고 상태를 어디에 둘지 미리 계획하는 과정을 정리했다.
코드를 작성하기 전에, 우리가 만들 Todo 앱이 정확히 어떤 기능을 가져야 하는지부터 정리한다.
| 기능 | 설명 |
|---|---|
| 추가 | 입력창에 할 일을 적고 등록하면 목록에 새 항목이 추가된다 |
| 완료 체크 | 항목의 checkbox를 클릭하면 완료 상태로 표시된다 (취소선 등) |
| 삭제 | 항목의 삭제 버튼을 클릭하면 목록에서 사라진다 |
이 세 가지 기능을 하나씩 구현하기 전에 먼저 전체 구조를 설계해본다.
화면을 어떤 컴포넌트로 나눌지 미리 정해두면, 나중에 코드를 작성할 때 헤매지 않는다. Todo 앱은 아래와 같은 컴포넌트 트리로 나눈다.
App
├─ TodoForm (할 일을 입력하고 추가하는 부분)
└─ TodoList (전체 목록을 보여주는 부분)
└─ TodoItem (목록 안의 항목 하나하나, 완료 체크·삭제 버튼 포함)
| 컴포넌트 | 역할 |
|---|---|
| App | 전체 todos 데이터를 가지고 있는 최상위 컴포넌트 |
| TodoForm | 새 할 일을 입력받아 App에 추가를 요청하는 컴포넌트 |
| TodoList | todos 배열을 map()으로 순회하며 TodoItem을 렌더링하는 컴포넌트 |
| TodoItem | 할 일 하나의 텍스트, 완료 체크박스, 삭제 버튼을 보여주는 컴포넌트 |
TodoForm은 입력창의 값을, TodoList는 전체 목록을, TodoItem은 완료 여부를 다뤄야 한다. 그런데 실제로 "할 일 목록(todos) 자체"는 어느 한 컴포넌트만의 것이 아니라, TodoForm(추가), TodoList(표시), TodoItem(삭제·완료 체크)이 전부 함께 다뤄야 하는 데이터다.
이럴 때는 여러 컴포넌트가 공통으로 접근해야 하는 state를, 그 컴포넌트들을 모두 감싸고 있는 가장 가까운 부모 컴포넌트에 둔다. 이 방식을 상태 끌어올리기(Lifting State Up)라고 부른다.
todos를 왜 TodoList가 아니라 App에 둘까? TodoForm은 TodoList의 자식이 아니라 형제 컴포넌트다. 만약 todos를 TodoList 안에 두면, TodoForm에서 새 할 일을 추가하고 싶어도 TodoList의 state에는 접근할 방법이 없다. 두 컴포넌트가 함께 다뤄야 하는 데이터는, 둘을 동시에 감싸고 있는 공통 부모(App)로 끌어올려야 양쪽 모두에게 props로 내려줄 수 있다.
todos는 할 일 객체들을 담은 배열로 만든다. 각 할 일 객체는 아래 세 가지 값을 가진다.
| 필드 | 타입 | 의미 |
|---|---|---|
| id | 숫자 | 각 할 일을 구분하는 고유 값 (key로 사용) |
| text | 문자열 | 할 일 내용 |
| completed | boolean | 완료 여부 |
const todos = [
{ id: 1, text: "React 공부하기", completed: false },
{ id: 2, text: "장보기", completed: true },
{ id: 3, text: "운동하기", completed: false },
];
지금까지 정의한 세 가지 기능(추가/완료 체크/삭제) 외에, 나만의 Todo 앱에 추가하고 싶은 기능이 있다면 한 가지 적어본다. (예: 수정 기능, 마감일 표시 등 — 지금 당장 구현하지 않아도 된다.)
App → TodoForm, TodoList → TodoItem 트리를 직접 그려본다.src/App.jsx에 useState로 todos state를 만들고, id/text/completed를 가진 할 일 객체 3개를 초기값으로 넣는다. 아직 TodoForm, TodoList는 만들지 않아도 된다.
import { useState } from "react";
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "React 공부하기", completed: false },
{ id: 2, text: "장보기", completed: true },
{ id: 3, text: "운동하기", completed: false },
]);
return (
<div>
<h1>Todo 리스트</h1>
{/* TodoForm, TodoList 구현 예정 */}
</div>
);
}
export default App;
id(구분용), text(내용), completed(완료 여부) 세 필드를 가진 객체 배열로 설계하면, 이후 map()으로 렌더링하거나 id 기준으로 삭제/수정하기 편하다.앞서 설계한 Todo 앱을 실제로 파일 단위로 쪼개서 구현해봤다. export/import로 컴포넌트를 분리하는 방법부터 TodoItem, TodoList를 만들고 App에서 연결하는 과정, 기본 레이아웃까지 다룬다.
지금까지는 한 파일(App.jsx) 안에 여러 컴포넌트를 함께 작성했다. 이제부터는 컴포넌트마다 별도의 파일로 나누고, export/import로 서로 연결한다.
// src/Greeting.jsx
function Greeting() {
return <h2>안녕하세요!</h2>;
}
export default Greeting;
// src/App.jsx
import Greeting from "./Greeting";
function App() {
return <Greeting />;
}
export default App;
Greeting.jsx에서 export default Greeting은 "이 컴포넌트를 다른 파일에서 가져다 쓸 수 있게 내보낸다"는 뜻이고, App.jsx에서 import Greeting from "./Greeting"은 "같은 폴더에 있는 Greeting.jsx 파일에서 내보낸 걸 가져온다"는 뜻이다.
import 경로에는 왜 ./를 붙이고, 확장자(.jsx)는 왜 안 써도 될까? ./는 "지금 이 파일과 같은 폴더에서 찾는다"는 뜻이다. ./가 없으면 React는 내가 만든 파일이 아니라 node_modules에 설치된 라이브러리를 찾으려고 한다. 확장자는 Vite가 .jsx, .js 등 몇 가지 정해진 확장자를 자동으로 붙여서 찾아주기 때문에 생략해도 된다.
목록 안의 할 일 하나를 보여주는 컴포넌트다. 지금 단계에서는 완료 여부에 따라 취소선만 표시해본다.
// src/TodoItem.jsx
function TodoItem({ todo }) {
return (
<li style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</li>
);
}
export default TodoItem;
todo라는 객체 하나를 props로 통째로 받아서, todo.text, todo.completed처럼 필요한 값을 꺼내 쓰고 있다.
todos 배열 전체를 받아서, map()으로 TodoItem을 여러 개 렌더링하는 컴포넌트다.
// 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;
TodoList는 TodoItem.jsx를 import해서 사용하고 있다. 컴포넌트도 서로 다른 파일에 있을 뿐, 사용하는 방법은 지금까지와 똑같다.
// 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 },
{ id: 3, text: "운동하기", completed: false },
]);
return (
<div>
<h1>Todo 리스트</h1>
<TodoList todos={todos} />
</div>
);
}
export default App;
App이 가지고 있는 todos state를 TodoList에게 props로 내려주고, TodoList는 그걸 다시 TodoItem에게 하나씩 내려준다. 앞서 배운 "부모→자식 데이터 전달"이 파일이 나뉜 상태에서도 똑같이 적용된다.
// src/App.jsx
function App() {
// ...state는 그대로...
return (
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoList todos={todos} />
</div>
);
}
maxWidth와 margin: "0 auto"로 화면 가운데에 적당한 너비로 정렬해서, 이후 입력창과 버튼을 추가했을 때도 보기 좋은 레이아웃을 미리 잡아둔다.
src 폴더 안에 TodoItem.jsx 파일을 새로 만들고, todo props를 받아서 텍스트를 <li>로 보여주는 컴포넌트를 작성한 뒤 export default로 내보낸다.
// src/TodoItem.jsx
function TodoItem({ todo }) {
return (
<li style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</li>
);
}
export default TodoItem;
src 폴더 안에 TodoList.jsx 파일을 새로 만들고, TodoItem을 import해서 todos props를 map()으로 순회하며 렌더링한다.
// 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;
App.jsx에서 TodoList를 import하고, todos state를 props로 전달해서 화면에 할 일 3개가 리스트로 나타나는지 확인한다.
// 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 },
{ id: 3, text: "운동하기", completed: false },
]);
return (
<div>
<h1>Todo 리스트</h1>
<TodoList todos={todos} />
</div>
);
}
export default App;
App의 최상위 <div>에 style을 추가해서 적당한 너비로 가운데 정렬되도록 꾸며본다. (선택) TodoItem의 <li>에도 여백이나 글자 크기를 자유롭게 꾸며본다.
// src/App.jsx
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoList todos={todos} />
</div>
export default 컴포넌트명으로 내보내고 import 컴포넌트명 from "./경로"로 가져오는 패턴이 파일 분리의 기본이다.App → TodoList → TodoItem처럼 props가 여러 단계를 거쳐 내려가도, 부모→자식 전달 방식 자체는 하나의 파일에서 쓰던 것과 동일하다.Todo 앱의 첫 번째 기능인 "추가"를 구현했다. 자식 컴포넌트에서 부모에게 데이터를 요청하는 콜백 패턴, 고유 id 생성, 그리고 React에서 배열 state를 다룰 때 반드시 지켜야 할 불변성 개념까지 다룬다.
새 할 일을 입력받는 TodoForm 컴포넌트를 만든다. 앞에서 배운 Controlled Component 방식으로, input의 값을 state와 연결한다.
// src/TodoForm.jsx
import { useState } from "react";
function TodoForm() {
const [text, setText] = useState("");
return (
<form>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
text state는 TodoForm 자신이 가지고 있지만, 진짜 할 일 목록(todos)은 부모인 App이 가지고 있다. 그래서 TodoForm은 새 할 일을 직접 추가할 수 없고, App으로부터 "추가해줘"라고 요청할 함수를 props로 받아서 호출해야 한다.
// src/TodoForm.jsx
import { useState } from "react";
function TodoForm({ onAdd }) {
const [text, setText] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
onAdd(text);
setText("");
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
제출 후에는 setText("")로 입력창을 다시 비워준다.
App은 onAdd로 전달받은 텍스트를 가지고, id/text/completed를 갖춘 새로운 할 일 객체를 만들어야 한다. id가 서로 겹치면 안 되기 때문에, 현재 시각을 밀리초로 돌려주는 Date.now()를 자주 사용한다.
const newTodo = { id: Date.now(), text: text, completed: false };
왜 id를 1, 2, 3 ... 처럼 직접 정하지 않고 Date.now()를 쓸까? 할 일을 추가했다가 삭제하는 상황이 반복되면, 단순히 1, 2, 3 ... 순서로 번호를 매기는 방식은 항목끼리 번호가 겹치기 쉽다. Date.now()는 "지금 이 순간의 시각"을 숫자로 돌려주는데, 같은 밀리초에 두 개를 동시에 만들지 않는 한 항상 서로 다른 값이 나오기 때문에 간단하면서도 안전하게 고유한 id를 만들 수 있다.
배열에 값을 추가할 때 자바스크립트의 push()를 바로 떠올리기 쉽지만, React의 state를 다룰 때는 push()를 사용하면 안 된다.
// ❌ 이렇게 하면 안 됩니다
todos.push(newTodo);
setTodos(todos);
// ✅ 스프레드 연산자로 새 배열을 만들어야 합니다
setTodos([...todos, newTodo]);
push()는 기존 배열 자체를 그 자리에서 바꿔버린다. React는 state가 바뀌었는지 확인할 때 "이전 배열과 새 배열이 완전히 다른 객체인지"를 비교하는데, push()를 쓰면 배열의 내용은 바뀌어도 배열 자체(메모리 주소)는 그대로이기 때문에 React가 변화를 감지하지 못해 화면이 갱신되지 않을 수 있다.
[...todos, newTodo]처럼 스프레드 연산자(...)를 사용하면, 기존 todos의 내용을 그대로 복사한 완전히 새로운 배열을 만들어서 setTodos에 전달하기 때문에 React가 변화를 확실하게 감지한다.
⚠️ 이 원칙(불변성)은 배열뿐 아니라 객체 state를 다룰 때도 똑같이 적용된다. React state는 직접 수정하지 말고 항상 "새로운 값을 만들어서" 교체해야 한다.
// src/App.jsx
import { useState } from "react";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "React 공부하기", completed: false },
{ id: 2, text: "장보기", completed: true },
{ id: 3, text: "운동하기", completed: false },
]);
const handleAdd = (text) => {
const newTodo = { id: Date.now(), text: text, completed: false };
setTodos([...todos, newTodo]);
};
return (
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoForm onAdd={handleAdd} />
<TodoList todos={todos} />
</div>
);
}
export default App;
src/TodoForm.jsx 파일을 만들고, text state와 input을 연결한 Controlled Component로 작성한다.
// src/TodoForm.jsx
import { useState } from "react";
function TodoForm() {
const [text, setText] = useState("");
return (
<form>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
TodoForm이 onAdd라는 props를 받도록 수정하고, 제출 시 onAdd(text)를 호출한 뒤 입력창을 비운다. App.jsx에서 TodoForm에 onAdd props를 전달한다. (아직 handleAdd는 콘솔 로그만 찍어도 된다.)
// src/TodoForm.jsx
import { useState } from "react";
function TodoForm({ onAdd }) {
const [text, setText] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
onAdd(text);
setText("");
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
App.jsx에 handleAdd 함수를 작성해서, 새 할 일 객체를 만들고 스프레드 연산자로 todos에 추가한다. 입력창에 할 일을 적고 "추가" 버튼을 눌러, 목록에 실제로 항목이 늘어나는지 확인한다.
// src/App.jsx
const handleAdd = (text) => {
const newTodo = { id: Date.now(), text: text, completed: false };
setTodos([...todos, newTodo]);
};
입력창이 비어있는 상태에서 "추가" 버튼을 누르면 빈 항목이 추가되지 않도록, text.trim()이 빈 문자열이면 함수를 그대로 종료(return)하도록 만든다.
// src/TodoForm.jsx
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim() === "") {
return;
}
onAdd(text);
setText("");
};
onAdd(text))하는 콜백 패턴을 사용한다.Date.now()는 간단하면서도 겹치지 않는 고유 id를 만드는 실용적인 방법이다.[...todos, newTodo])로 항상 새로운 값을 만들어서 교체해야 한다. 이게 바로 "불변성을 지킨다"는 의미다.