리액트로 Todo 앱 만들기 #3 필터링

12 분 소요

지난 시간에는 완료 토글과 통계를 추가했습니다. 이번에는 필터링(전체/미완료/완료)과 일괄 처리(완료 항목 일괄 삭제)를 만들어보겠습니다.

이번 단계 목표

  • 화면 상단에 "전체 / 미완료 / 완료" 필터 버튼
  • 클릭하면 그 조건에 맞는 항목만 표시
  • 통계 영역에 "완료 항목 모두 삭제" 버튼 (완료 항목이 있을 때만)
  • 한 항목도 없을 때, 필터에 맞는 항목이 없을 때 각각 다른 안내 문구

필터 state 추가

필터링은 화면 표시 방식이지 데이터 자체의 변경이 아닙니다. todos 자체는 그대로 두고, 보여주는 방식만 바꾸는 접근이 정석입니다.

TodoAppfilter state를 하나 더 둡니다.

src/TodoApp.jsx
const [filter, setFilter] = useState('all');  // 'all' | 'active' | 'completed'

화면에 표시할 목록은 todosfilter로부터 계산합니다.

필터링된 목록 계산
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:

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:

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:

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:

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)도 디자인의 일부라는 마인드셋이 실전 개발에서 중요합니다.

동작 확인

저장하고 다음을 시도해보세요.

  1. 할 일 몇 개 추가하고 일부를 완료 표시
  2. "미완료" 필터 클릭 → 미완료 항목만 표시
  3. "완료" 필터 클릭 → 완료된 항목만 표시
  4. "전체" 필터로 돌아가기
  5. "완료 항목 모두 삭제" 버튼 클릭 → 완료된 항목들이 한 번에 사라짐
  6. 모든 할 일을 삭제 → "할 일이 없습니다" 안내
  7. 할 일을 모두 미완료 상태로 만들고 "완료" 필터 → "완료 항목이 없습니다" 안내

통계 위치에 대한 결정

지금 통계와 일괄 삭제 버튼이 TodoStats 안에 함께 있습니다. 다른 선택도 가능했죠.

  • 통계와 일괄 삭제를 별도 컴포넌트로 분리
  • 통계는 위에 두고 일괄 삭제는 목록 아래에 둠

정답은 없습니다. 이번 시리즈에서는 관련된 정보를 가까이 두는 쪽을 택했습니다. "완료가 N개 있다"는 정보 옆에 "그걸 한번에 정리해주세요" 버튼이 있는 게 사용자에게 자연스러우니까요.

설계 결정을 내릴 때 정해진 답을 찾으려 하기보다 "왜 이렇게 했는지를 한 줄로 설명할 수 있는가?" 정도로 충분합니다. 나중에 다른 선택이 더 좋아 보이면 그때 바꾸면 되고요.

필터를 URL 쿼리(?filter=active)에 반영하면 새로고침해도 필터가 유지되고, URL을 공유하면 같은 화면으로 바로 갈 수 있습니다. #15에서 다룬 useSearchParams로 가능한데, 이 시리즈에서는 단순화를 위해 메모리 state로 두었습니다. 라우팅이 있는 더 큰 앱이라면 URL 쪽이 더 좋을 수 있어요.

마무리

이번 글에서는 필터링과 일괄 처리를 추가했습니다.

  • 필터 state는 별도로 두고, 표시할 목록은 todosfilter로부터 계산
  • 같은 데이터로부터 통계는 전체로, 목록은 필터링된 것으로 — 자식마다 다른 가공 결과를 줌
  • 빈 상태도 상황에 따라 다르게 안내
  • 필터 옵션을 데이터 배열로 두고 map으로 렌더링하는 패턴

지금까지 만든 앱은 추가, 완료 토글, 삭제, 필터링까지 가능합니다. 그런데 한 번 입력한 텍스트는 수정할 방법이 없습니다. 다음 글인 "리액트로 Todo 앱 만들기 #4 편집 기능"에서는 항목을 더블클릭하면 인라인 편집 모드로 들어가고, Enter로 저장 / Escape로 취소하는 기능을 만들어보겠습니다. 이 과정에서 처음으로 **useRef**도 등장합니다.