지난 시간에는 추가/삭제까지 동작하는 Todo 앱의 골격을 만들었습니다. 이번에는 각 항목에 체크박스로 완료 표시를 하고, 남은 개수 / 전체 개수를 보여주는 통계 영역을 추가해보겠습니다.
이번 단계 목표
- 항목 옆 체크박스 클릭 → 완료/미완료 토글
- 완료된 항목은 시각적으로 구분 (회색, 취소선)
- 화면 어딘가에 "전체 N개 / 남은 M개" 표시
토글 핸들러 추가
데이터 흐름은 지난 글과 같습니다. state는 TodoApp에, 변경은 콜백으로 부모에 알림. 토글 핸들러를 TodoApp에 추가합니다.
src/TodoApp.jsx:
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
import TodoStats from './TodoStats';
function TodoApp() {
const [todos, setTodos] = useState([]);
function addTodo(text) {
const newTodo = {
id: crypto.randomUUID(),
text,
completed: false,
};
setTodos(prev => [newTodo, ...prev]);
}
function deleteTodo(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
function toggleTodo(id) {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
<h1>Todo</h1>
<TodoForm onAdd={addTodo} />
<TodoStats todos={todos} />
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
</div>
);
}
export default TodoApp;추가된 핵심:
function toggleTodo(id) {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}이 패턴은 자주 등장하니 외워두면 좋습니다.
map으로 새 배열을 만들면서- 일치하는 항목은 새 객체로 교체 (
{ ...todo, completed: !todo.completed }) - 그 외는 그대로 둠
#5에서 배운 불변 업데이트의 표준 형태예요. 절대로 todo.completed = !todo.completed 같이 직접 변경하지 않습니다 — 같은 객체 참조라서 리액트가 변화를 감지하지 못합니다.
TodoItem에 체크박스 달기
TodoItem에 체크박스를 추가하고, 완료 상태일 때 시각적으로 구분합니다.
src/TodoItem.jsx:
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
borderBottom: '1px solid #eee',
opacity: todo.completed ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
}}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>
삭제
</button>
</li>
);
}
export default TodoItem;checked={todo.completed}와onChange한 쌍으로 체크박스를 controlled 컴포넌트로 만듦 (#9)- 완료된 항목은
opacity: 0.5(반투명)와text-decoration: line-through(취소선)으로 구분 - 인라인 스타일을 조건에 따라 분기하는 흔한 패턴
TodoList에 핸들러 전달
TodoList는 받은 onToggle을 그대로 자식에 내려보냅니다.
src/TodoList.jsx:
import TodoItem from './TodoItem';
function TodoList({ todos, onToggle, onDelete }) {
if (todos.length === 0) {
return <p style={{ color: '#888' }}>할 일이 없습니다. 새로 추가해보세요.</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
export default TodoList;이렇게 중간 컴포넌트가 자기와 무관한 props를 그저 아래로 전달하는 모양이 보이실 텐데요, 이게 바로 #12에서 다룬 prop drilling입니다. Todo 앱 정도 규모에서는 문제될 정도는 아닙니다 (트리가 얕고 컴포넌트가 적음). 더 커지면 그때 Context를 도입하면 됩니다.
TodoStats — 통계 컴포넌트
전체 개수와 남은 개수를 보여주는 단순한 컴포넌트입니다. 데이터를 받아 계산만 해서 표시합니다.
src/TodoStats.jsx:
function TodoStats({ todos }) {
const total = todos.length;
const remaining = todos.filter(todo => !todo.completed).length;
const completed = total - remaining;
return (
<div style={{
padding: '8px 0',
fontSize: '14px',
color: '#555',
borderBottom: '1px solid #eee',
}}>
전체 {total} · 남은 일 {remaining} · 완료 {completed}
</div>
);
}
export default TodoStats;여기서 짚어둘 한 가지 — total, remaining, completed는 state로 만들지 않았습니다. todos로부터 그때그때 계산할 수 있는 값이라서요. #11에서 본 Single Source of Truth 원칙입니다.
계산 가능한 값은 state로 만들지 마세요. 진짜 state는
todos하나뿐, 통계는 거기서 파생되는 값.
만약 total을 별도 state로 만들었다면 todos가 바뀔 때마다 effect로 동기화해야 했을 것이고, 동기화 누락 같은 버그의 여지가 생겼겠죠. 그냥 매 렌더링마다 계산하는 편이 단순하고 안전합니다.
useMemo는 필요 없을까?
#14에서 배운 useMemo를 떠올린 분도 있을 거예요. "filter가 매 렌더링마다 도는 게 비효율 아닌가?"
답은 현재 규모에선 전혀 신경 쓸 필요 없다입니다. 항목이 수만 개가 아니라 수십~수백 개 정도일 거고, filter는 매우 빠른 연산입니다. 측정해서 진짜 느릴 때만 최적화하는 원칙(#14)을 그대로 적용하면 됩니다. 지금은 코드를 단순하게 두는 쪽이 훨씬 가치 있어요.
동작 확인
저장하고 브라우저에서 다음을 확인해보세요.
- 할 일 몇 개 추가
- 체크박스를 누르면 항목이 흐려지고 취소선 표시
- 다시 누르면 원래대로 돌아옴
- 통계 영역의 "남은 일 / 완료" 숫자가 즉시 갱신
- 항목 삭제하면 통계도 자동 갱신
흔한 실수 짚어보기
1. 객체를 직접 변경하기
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed; // 🚫
setTodos([...todos]);
}todo.completed = ...로 직접 변경하면 안 됩니다. 같은 객체 참조이기 때문에 리액트가 비교 시 "같은 객체"로 판단할 가능성이 있고, 미래의 React Compiler 같은 도구도 불변성을 가정하고 동작합니다. **항상 새 객체를 만들어 넣는 패턴({ ...todo, completed: ... })**을 고수하세요.
2. 체크박스에 value 사용
<input type="checkbox" value={todo.completed} onChange={...} />체크박스는 value가 아닌 **checked**를 사용합니다 (#9). value는 그냥 form submit 시 전송될 값일 뿐 체크 여부와는 무관합니다.
3. onToggle을 인덱스 기준으로
{todos.map((todo, index) => (
<TodoItem onToggle={() => toggleByIndex(index)} ... />
))}ID로 토글하는 게 안전합니다. 인덱스로 하면 정렬이나 필터링이 적용된 순간 인덱스와 실제 항목 매칭이 어긋날 수 있어요. ID는 항목 자체에 붙어 있으니 어떤 변환을 거쳐도 정확합니다.
마무리
이번 글에서는 두 가지를 추가했습니다.
- 완료 토글 — 체크박스 + 시각적 구분 + 불변 업데이트의
map패턴 - 통계 — 파생 값은 state로 만들지 않고 계산
지금 우리 앱은 모든 할 일을 항상 한꺼번에 보여줍니다. 할 일이 많아지면 "남은 것만 보고 싶다"거나 "완료한 것만 정리해서 보고 싶다"는 욕구가 생기죠. 다음 글인 "리액트로 Todo 앱 만들기 #3 필터링"에서는 전체 / 미완료 / 완료 필터를 추가하고, 완료 항목 일괄 삭제 같은 일괄 처리 기능도 만들어보겠습니다.