Todo 앱의 두 번째 기능인 "삭제"를 구현했다. TodoItem에서 TodoList를 거쳐 App까지 콜백 함수를 전달하는 props drilling, 그리고 filter()로 안전하게 배열에서 항목을 제거하는 방법을 다룬다.
할 일을 삭제하려면, 먼저 TodoItem에 삭제 버튼을 추가하고 클릭 이벤트를 연결해야 한다. 실제로 배열에서 항목을 지우는 로직은 todos를 가지고 있는 App에서 처리해야 하므로, TodoItem은 onDelete라는 콜백 함수를 props로 받아서 자신의 id와 함께 호출한다.
// src/TodoItem.jsx
function TodoItem({ todo, onDelete }) {
return (
<li style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
}
export default TodoItem;
onDelete(todo.id)처럼 "어떤 항목을 지울지" id를 함께 넘겨줘야, App이 정확히 그 항목만 삭제할 수 있다.
onDelete는 App이 가지고 있지만, 실제로 버튼을 클릭하는 곳은 TodoItem이다. 그 사이에 있는 TodoList는 onDelete를 직접 사용하지 않으면서도, props drilling처럼 그대로 한 단계 아래로 전달해줘야 한다.
// src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onDelete }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={onDelete} />
))}
</ul>
);
}
export default TodoList;
App에서는 삭제할 id를 전달받아, filter()로 그 id를 제외한 나머지 항목들만 남긴 새 배열을 만든다.
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
filter()는 정확히 어떻게 동작할까? filter()는 배열의 각 항목을 하나씩 확인하면서, 괄호 안 조건이 true인 것들만 골라 새 배열로 돌려준다. todo.id !== id는 "삭제하려는 id와 다른 항목만 남긴다"는 뜻이므로, 결과적으로 삭제 대상 하나만 빠진 새 배열이 만들어진다.
💡 앞서
push()를 쓰면 안 됐던 것과 같은 이유로,filter()도 원본 배열을 건드리지 않고 항상 새 배열을 만들어주기 때문에 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]);
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoForm onAdd={handleAdd} />
<TodoList todos={todos} onDelete={handleDelete} />
</div>
);
}
export default App;
TodoItem이 onDelete props를 받도록 수정하고, 삭제 버튼을 추가한다. 버튼 클릭 시 onDelete(todo.id)를 호출하도록 만든다.
// src/TodoItem.jsx
function TodoItem({ todo, onDelete }) {
return (
<li style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
}
export default TodoItem;
TodoList가 onDelete props를 받아서, 각 TodoItem에게 그대로 전달하도록 수정한다.
// src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onDelete }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={onDelete} />
))}
</ul>
);
}
export default TodoList;
App.jsx에 handleDelete 함수를 작성해서, 전달받은 id를 제외한 새 배열로 todos를 업데이트한다. TodoList에 onDelete props로 handleDelete를 전달하고, 삭제 버튼을 눌러 항목이 실제로 사라지는지 확인한다.
// src/App.jsx
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
App이 가진 데이터를 여러 단계 아래의 자식 컴포넌트가 다뤄야 할 때는, 콜백 함수를 props로 계속 아래로 전달하는 props drilling 패턴을 쓴다. 중간의 TodoList는 로직을 몰라도 그대로 전달만 해주면 된다.splice()처럼 원본을 직접 바꾸는 방식 대신, filter()로 조건에 맞는 항목만 남긴 새 배열을 만드는 게 React의 불변성 원칙에 맞다.Todo 앱의 세 번째 기능인 "완료 체크"를 구현했다. checkbox 연결부터, filter()와는 다르게 map()으로 특정 항목만 값을 바꾸는 방법, 그리고 배열 안 객체 하나하나의 불변성까지 챙기는 법을 다룬다.
할 일을 완료 처리하려면, TodoItem에 checkbox를 추가하고 todo.completed 값과 연결해야 한다. checkbox는 value 대신 checked를 사용한다.
// src/TodoItem.jsx
function TodoItem({ todo, onDelete, onToggle }) {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
}
export default TodoItem;
checkbox를 클릭하면 onToggle(todo.id)가 호출되어, "이 id를 가진 항목의 완료 여부를 바꿔달라"고 부모에게 요청한다.
삭제 기능 때와 마찬가지로, TodoList는 onToggle을 그대로 한 단계 아래로 전달해준다.
// src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onDelete, onToggle }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</ul>
);
}
export default TodoList;
삭제할 때는 filter()로 항목 하나를 배열에서 아예 제거했지만, 완료 체크는 항목 개수는 그대로 두고 그 항목의 completed 값만 바꿔야 한다. 이럴 때는 map()을 사용한다.
const handleToggle = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
map()은 배열의 모든 항목을 하나씩 확인하면서, 클릭한 항목의 id와 같으면 completed를 반전시킨 새 객체로 바꾸고, 다르면 원래 항목을 그대로 둔 새 배열을 만들어 돌려준다.
왜 todo.completed = !todo.completed처럼 바로 바꾸지 않고 { ...todo, completed: !todo.completed }처럼 새 객체를 만들까? 배열뿐 아니라 배열 안의 객체 하나하나도 state의 일부이기 때문에, 똑같이 불변성을 지켜야 한다. todo.completed = !todo.completed처럼 기존 객체를 직접 수정하면, 배열 자체는 map()으로 새로 만들어졌더라도 그 안에 들어있는 객체는 이전과 동일한 객체(메모리 주소)이기 때문에 React가 변화를 제대로 감지하지 못할 수 있다.
{ ...todo, completed: !todo.completed }는 "todo의 모든 값을 복사하되, completed만 새 값으로 덮어쓴 완전히 새로운 객체"를 만드는 코드다.
💡 불변성 정리: 배열을 바꿀 땐 새 배열(
map,filter,[...배열])을, 그 안의 객체를 바꿀 땐 새 객체({ ...객체, 바뀐값 })를 만든다. 이 두 원칙이 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]);
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const handleToggle = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
return (
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoForm onAdd={handleAdd} />
<TodoList todos={todos} onDelete={handleDelete} onToggle={handleToggle} />
</div>
);
}
export default App;
이제 todos state 하나로 추가(handleAdd), 삭제(handleDelete), 토글(handleToggle) 세 기능이 전부 연결되어, 요구사항 정의 단계에서 정했던 세 가지 기능이 모두 완성됐다.
TodoItem이 onToggle props를 받도록 수정하고, todo.completed와 연결된 checkbox를 추가한다.
// src/TodoItem.jsx
function TodoItem({ todo, onDelete, onToggle }) {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
}
export default TodoItem;
TodoList가 onToggle props를 받아서, 각 TodoItem에게 그대로 전달하도록 수정한다.
// src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onDelete, onToggle }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</ul>
);
}
export default TodoList;
App.jsx에 handleToggle 함수를 작성해서, 클릭한 id의 completed 값만 반전시킨다. checkbox를 클릭할 때마다 해당 항목에만 취소선이 생겼다 사라지는지 확인한다.
// src/App.jsx
const handleToggle = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
map(), 항목 자체를 제거할 땐 filter()로 구분해서 기억하면 좋다.{ ...todo, completed: !todo.completed }처럼 스프레드 연산자로 새 객체를 만들어야, React가 변화를 확실히 감지한다.Todo 앱에 "전체 / 미완료 / 완료" 필터 기능을 추가했다. 원본 데이터(todos)와 화면에 보여줄 데이터(filteredTodos)를 분리하는 이유, 남은 할 일 개수 계산, 조건부 스타일로 활성 버튼 강조까지 다룬다.
지금 "전체 / 완료 / 미완료" 중 어떤 걸 보고 있는지는 todos 배열과는 다른, 별도의 정보다. todos는 "무엇이 있는가"를 담고, 필터는 "그중 무엇을 보여줄 것인가"를 담기 때문에, 새로운 state로 따로 관리한다.
const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"
이벤트 처리를 활용해서, 버튼 3개로 filter 값을 바꾼다.
<div>
<button onClick={() => setFilter("all")}>전체</button>
<button onClick={() => setFilter("active")}>미완료</button>
<button onClick={() => setFilter("completed")}>완료</button>
</div>
화면에는 todos 전체가 아니라, filter 값에 맞게 걸러진 목록을 보여줘야 한다.
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true; // "all"인 경우 전부 보여줌
});
이렇게 만든 filteredTodos를 todos 대신 TodoList에 전달한다.
<TodoList todos={filteredTodos} onDelete={handleDelete} onToggle={handleToggle} />
원본 todos는 그대로 두고, 왜 filteredTodos라는 새 변수를 따로 만들까? filter 값이 "completed"일 때 todos 자체를 완료된 항목만 남기고 지워버리면, 나중에 "전체"를 눌렀을 때 미완료 항목들이 이미 사라져서 되돌릴 수 없다. todos는 항상 전체 데이터를 그대로 가지고 있고, 화면에 "무엇을 보여줄지"만 filteredTodos로 따로 계산하기 때문에 필터를 아무리 바꿔도 원본 데이터는 안전하게 유지된다.
💡 이렇게 원본 state에서 "지금 보여줄 값"만 별도로 계산해내는 걸 파생 데이터(derived data)라고 부른다. 파생 데이터는 별도의 state로 만들지 않고, 렌더링할 때마다 원본으로부터 계산하는 게 일반적이다.
const activeCount = todos.filter((todo) => !todo.completed).length;
<p>{activeCount}개 남음</p>
activeCount는 filter 값과 상관없이, 항상 todos 전체를 기준으로 "완료되지 않은 항목이 몇 개인지" 계산한다.
지금 선택된 필터가 무엇인지 시각적으로 보여주면 사용성이 좋아진다. 조건부 스타일을 활용한다.
<button
onClick={() => setFilter("all")}
style={{ fontWeight: filter === "all" ? "bold" : "normal" }}
>
전체
</button>
filter === "all"이 참일 때만 글자를 굵게 만들어서, 지금 어떤 필터가 선택되어 있는지 한눈에 알 수 있게 한다.
// 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 [filter, setFilter] = useState("all");
const handleAdd = (text) => {
const newTodo = { id: Date.now(), text: text, completed: false };
setTodos([...todos, newTodo]);
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const handleToggle = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
const activeCount = todos.filter((todo) => !todo.completed).length;
return (
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoForm onAdd={handleAdd} />
<div>
<button
onClick={() => setFilter("all")}
style={{ fontWeight: filter === "all" ? "bold" : "normal" }}
>
전체
</button>
<button
onClick={() => setFilter("active")}
style={{ fontWeight: filter === "active" ? "bold" : "normal" }}
>
미완료
</button>
<button
onClick={() => setFilter("completed")}
style={{ fontWeight: filter === "completed" ? "bold" : "normal" }}
>
완료
</button>
</div>
<p>{activeCount}개 남음</p>
<TodoList todos={filteredTodos} onDelete={handleDelete} onToggle={handleToggle} />
</div>
);
}
export default App;
filter라는 state를 "all"로 초기화하고, "전체", "미완료", "완료" 버튼 3개를 만들어 각각 filter 값을 바꾸도록 연결한다.
const [filter, setFilter] = useState("all");
// ...
<div>
<button onClick={() => setFilter("all")}>전체</button>
<button onClick={() => setFilter("active")}>미완료</button>
<button onClick={() => setFilter("completed")}>완료</button>
</div>
filter 값에 따라 todos를 걸러낸 filteredTodos를 만들고, TodoList에 todos 대신 전달해서 버튼을 누를 때마다 목록이 바뀌는지 확인한다.
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
todos 중 완료되지 않은 항목 개수를 계산해서 화면에 "N개 남음"으로 표시한다.
const activeCount = todos.filter((todo) => !todo.completed).length;
// ...
<p>{activeCount}개 남음</p>
현재 선택된 filter와 일치하는 버튼만 글자가 굵게 보이도록 조건부 스타일을 적용한다.
<button
onClick={() => setFilter("all")}
style={{ fontWeight: filter === "all" ? "bold" : "normal" }}
>
전체
</button>
todos)와 "지금 화면에 보여줄 데이터"(filteredTodos)를 분리하는 게 핵심이다. 필터는 원본을 지우는 게 아니라, 매번 원본으로부터 새로 계산해서 보여주는 것뿐이다.activeCount처럼 filter와 무관하게 항상 전체 todos 기준으로 계산해야 하는 값은, filteredTodos가 아니라 todos를 기준으로 따로 계산해야 한다.