지난 시간에는 완료 토글과 통계를 추가했습니다. 이번에는 필터링(전체/미완료/완료)과 일괄 처리(완료 항목 일괄 삭제)를 만들어보겠습니다.
이번 단계 목표
- 화면 상단에 "전체 / 미완료 / 완료" 필터 버튼
- 클릭하면 그 조건에 맞는 항목만 표시
- 통계 영역에 "완료 항목 모두 삭제" 버튼 (완료 항목이 있을 때만)
- 한 항목도 없을 때, 필터에 맞는 항목이 없을 때 각각 다른 안내 문구
필터 state 추가
필터링은 화면 표시 방식이지 데이터 자체의 변경이 아닙니다. todos 자체는 그대로 두고, 보여주는 방식만 바꾸는 접근이 정석입니다.
TodoApp에 filter state를 하나 더 둡니다.
const [filter, setFilter] = useState('all'); // 'all' | 'active' | 'completed'화면에 표시할 목록은 todos와 filter로부터 계산합니다.
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});또 한 번 파생 값은 state로 만들지 않는 원칙입니다. filteredTodos는 매 렌더링마다 계산되지만, Todo 앱 규모에선 비용이 무시할 수준입니다.
TodoFilter 컴포넌트
필터 버튼 그룹을 별도 컴포넌트로 뽑겠습니다.
src/TodoFilter.jsx:
const FILTERS = [
{ value: 'all', label: '전체' },
{ value: 'active', label: '미완료' },
{ value: 'completed', label: '완료' },
];
function TodoFilter({ filter, onChange }) {
return (
<div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
{FILTERS.map(item => (
<button
key={item.value}
onClick={() => onChange(item.value)}
style={{
padding: '4px 12px',
border: '1px solid #ccc',
background: filter === item.value ? '#333' : '#fff',
color: filter === item.value ? '#fff' : '#333',
cursor: 'pointer',
}}
>
{item.label}
</button>
))}
</div>
);
}
export default TodoFilter;핵심 패턴:
- 필터 옵션을 **데이터 배열(
FILTERS)**로 두고map으로 그림 (#8) — 옵션을 추가/수정할 때 JSX를 안 건드려도 됨 - 현재 선택된 필터는 시각적으로 구분 (배경색/글자색 반전)
- 변경은 콜백(
onChange)으로 부모에 알림
TodoStats에 일괄 삭제 버튼
완료 항목이 하나라도 있을 때만 "완료 항목 모두 삭제" 버튼을 보여줍니다. 조건부 렌더링(#7)이 자연스럽게 들어갑니다.
src/TodoStats.jsx:
function TodoStats({ todos, onClearCompleted }) {
const total = todos.length;
const remaining = todos.filter(todo => !todo.completed).length;
const completed = total - remaining;
return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
fontSize: '14px',
color: '#555',
borderBottom: '1px solid #eee',
}}>
<span>전체 {total} · 남은 일 {remaining} · 완료 {completed}</span>
{completed > 0 && (
<button onClick={onClearCompleted} style={{ fontSize: '12px' }}>
완료 항목 모두 삭제
</button>
)}
</div>
);
}
export default TodoStats;completed > 0 && ... — #7에서 짚었던 함정 기억하시죠? 왼쪽이 명시적인 불리언 비교라 안전합니다 (completed && ...로 쓰면 0일 때 화면에 0이 출력될 위험).
TodoApp 통합
이제 모든 조각을 묶어보겠습니다.
src/TodoApp.jsx:
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoFilter from './TodoFilter';
import TodoStats from './TodoStats';
import TodoList from './TodoList';
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
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
));
}
function clearCompleted() {
setTodos(prev => prev.filter(todo => !todo.completed));
}
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
<h1>Todo</h1>
<TodoForm onAdd={addTodo} />
<TodoFilter filter={filter} onChange={setFilter} />
<TodoStats todos={todos} onClearCompleted={clearCompleted} />
<TodoList
todos={filteredTodos}
filter={filter}
totalCount={todos.length}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
export default TodoApp;여기서 한 가지 의도적인 결정이 있습니다.
TodoStats에는todos전체를 넘김 (필터에 무관하게 전체 통계를 보여줘야 하므로)TodoList에는 **filteredTodos**를 넘김 (현재 필터에 맞는 것만 표시)
같은 todos state로부터 두 컴포넌트가 다른 가공값을 받는 셈입니다. 이 분리가 가능한 이유는 진실의 원천이 TodoApp에 있고, 자식들은 받은 데이터만 그리기 때문이에요.
TodoList — 빈 상태 분기 정교화
지금까지 TodoList는 "할 일이 없습니다"라는 메시지를 하나만 보여줬습니다. 그런데 필터를 적용했을 때 빈 상태는 "필터에 맞는 게 없다"라는 뜻이지 "할 일 자체가 없다"는 게 아닙니다. 안내 문구를 분기해줍시다.
src/TodoList.jsx:
import TodoItem from './TodoItem';
const FILTER_LABEL = {
all: '할 일',
active: '미완료 항목',
completed: '완료 항목',
};
function TodoList({ todos, filter, totalCount, onToggle, onDelete }) {
if (todos.length === 0) {
if (totalCount === 0) {
return <p style={{ color: '#888' }}>할 일이 없습니다. 새로 추가해보세요.</p>;
}
return <p style={{ color: '#888' }}>{FILTER_LABEL[filter]}이 없습니다.</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;- 전체 개수(
totalCount)가 0 → "할 일이 없습니다" - 전체는 있는데 필터링된 결과만 0 → "OO이 없습니다"
작은 차이지만 사용자 경험은 명확히 좋아집니다. 빈 상태(empty state)도 디자인의 일부라는 마인드셋이 실전 개발에서 중요합니다.
동작 확인
저장하고 다음을 시도해보세요.
- 할 일 몇 개 추가하고 일부를 완료 표시
- "미완료" 필터 클릭 → 미완료 항목만 표시
- "완료" 필터 클릭 → 완료된 항목만 표시
- "전체" 필터로 돌아가기
- "완료 항목 모두 삭제" 버튼 클릭 → 완료된 항목들이 한 번에 사라짐
- 모든 할 일을 삭제 → "할 일이 없습니다" 안내
- 할 일을 모두 미완료 상태로 만들고 "완료" 필터 → "완료 항목이 없습니다" 안내
통계 위치에 대한 결정
지금 통계와 일괄 삭제 버튼이 TodoStats 안에 함께 있습니다. 다른 선택도 가능했죠.
- 통계와 일괄 삭제를 별도 컴포넌트로 분리
- 통계는 위에 두고 일괄 삭제는 목록 아래에 둠
정답은 없습니다. 이번 시리즈에서는 관련된 정보를 가까이 두는 쪽을 택했습니다. "완료가 N개 있다"는 정보 옆에 "그걸 한번에 정리해주세요" 버튼이 있는 게 사용자에게 자연스러우니까요.
설계 결정을 내릴 때 정해진 답을 찾으려 하기보다 "왜 이렇게 했는지를 한 줄로 설명할 수 있는가?" 정도로 충분합니다. 나중에 다른 선택이 더 좋아 보이면 그때 바꾸면 되고요.
필터를 URL 쿼리(?filter=active)에 반영하면 새로고침해도 필터가 유지되고, URL을 공유하면 같은 화면으로 바로 갈 수 있습니다. #15에서 다룬 useSearchParams로 가능한데, 이 시리즈에서는 단순화를 위해 메모리 state로 두었습니다. 라우팅이 있는 더 큰 앱이라면 URL 쪽이 더 좋을 수 있어요.
마무리
이번 글에서는 필터링과 일괄 처리를 추가했습니다.
- 필터 state는 별도로 두고, 표시할 목록은
todos와filter로부터 계산 - 같은 데이터로부터 통계는 전체로, 목록은 필터링된 것으로 — 자식마다 다른 가공 결과를 줌
- 빈 상태도 상황에 따라 다르게 안내
- 필터 옵션을 데이터 배열로 두고
map으로 렌더링하는 패턴
지금까지 만든 앱은 추가, 완료 토글, 삭제, 필터링까지 가능합니다. 그런데 한 번 입력한 텍스트는 수정할 방법이 없습니다. 다음 글인 "리액트로 Todo 앱 만들기 #4 편집 기능"에서는 항목을 더블클릭하면 인라인 편집 모드로 들어가고, Enter로 저장 / Escape로 취소하는 기능을 만들어보겠습니다. 이 과정에서 처음으로 **useRef**도 등장합니다.