Fullstack 89

heo4·2026년 8월 23일

Fullstack

목록 보기
44/71

풀스택

⚛️ React Todo 앱 - 삭제 기능 구현

Todo 앱의 두 번째 기능인 "삭제"를 구현했다. TodoItem에서 TodoList를 거쳐 App까지 콜백 함수를 전달하는 props drilling, 그리고 filter()로 안전하게 배열에서 항목을 제거하는 방법을 다룬다.

1. TodoItem에 삭제 버튼 추가하기

할 일을 삭제하려면, 먼저 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이 정확히 그 항목만 삭제할 수 있다.


2. TodoList를 거쳐 콜백 전달하기

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;

3. filter()로 배열에서 항목 제거하기

App에서는 삭제할 id를 전달받아, filter()로 그 id를 제외한 나머지 항목들만 남긴 새 배열을 만든다.

const handleDelete = (id) => {
  setTodos(todos.filter((todo) => todo.id !== id));
};

filter()는 정확히 어떻게 동작할까? filter()는 배열의 각 항목을 하나씩 확인하면서, 괄호 안 조건이 true인 것들만 골라 새 배열로 돌려준다. todo.id !== id는 "삭제하려는 id와 다른 항목만 남긴다"는 뜻이므로, 결과적으로 삭제 대상 하나만 빠진 새 배열이 만들어진다.

💡 앞서 push()를 쓰면 안 됐던 것과 같은 이유로, filter()도 원본 배열을 건드리지 않고 항상 새 배열을 만들어주기 때문에 state를 안전하게(불변성을 지키며) 업데이트할 수 있다.


4. App.jsx에서 전체 연결하기

// 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;

5. 실습 정리

실습 1) TodoItem에 삭제 버튼 추가하기

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;

실습 2) TodoList를 거쳐 onDelete 전달하기

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;

실습 3) filter()로 실제 삭제 기능 완성하기

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의 불변성 원칙에 맞다.
  • 삭제 로직의 핵심은 "지울 대상의 id를 자식에서 부모까지 정확히 전달하는 것"과 "그 id를 기준으로 필터링하는 것" 두 가지다.

⚛️ React Todo 앱 - 완료 토글 기능 구현

Todo 앱의 세 번째 기능인 "완료 체크"를 구현했다. checkbox 연결부터, filter()와는 다르게 map()으로 특정 항목만 값을 바꾸는 방법, 그리고 배열 안 객체 하나하나의 불변성까지 챙기는 법을 다룬다.

1. TodoItem에 checkbox 추가하기

할 일을 완료 처리하려면, 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를 가진 항목의 완료 여부를 바꿔달라"고 부모에게 요청한다.


2. TodoList를 거쳐 onToggle 전달하기

삭제 기능 때와 마찬가지로, 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;

3. map()으로 특정 id만 토글하기

삭제할 때는 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를 다루는 기본 규칙이다.


4. App.jsx에서 전체 연결하기

// 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) 세 기능이 전부 연결되어, 요구사항 정의 단계에서 정했던 세 가지 기능이 모두 완성됐다.


5. 실습 정리

실습 1) TodoItem에 checkbox 추가하기

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;

실습 2) TodoList를 거쳐 onToggle 전달하기

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;

실습 3) map()으로 토글 기능 완성하기

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가 변화를 확실히 감지한다.
  • 이걸로 추가·삭제·토글 세 기능이 모두 같은 패턴(콜백 props 전달 + 불변성 있는 state 업데이트)으로 구현됐다는 걸 확인할 수 있다.

⚛️ React Todo 앱 - 필터링 기능 구현

Todo 앱에 "전체 / 미완료 / 완료" 필터 기능을 추가했다. 원본 데이터(todos)와 화면에 보여줄 데이터(filteredTodos)를 분리하는 이유, 남은 할 일 개수 계산, 조건부 스타일로 활성 버튼 강조까지 다룬다.

1. 필터 상태를 위한 state 만들기

지금 "전체 / 완료 / 미완료" 중 어떤 걸 보고 있는지는 todos 배열과는 다른, 별도의 정보다. todos는 "무엇이 있는가"를 담고, 필터는 "그중 무엇을 보여줄 것인가"를 담기 때문에, 새로운 state로 따로 관리한다.

const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"

2. 필터 버튼 만들기

이벤트 처리를 활용해서, 버튼 3개로 filter 값을 바꾼다.

<div>
  <button onClick={() => setFilter("all")}>전체</button>
  <button onClick={() => setFilter("active")}>미완료</button>
  <button onClick={() => setFilter("completed")}>완료</button>
</div>

3. 필터링된 목록 계산하기

화면에는 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로 만들지 않고, 렌더링할 때마다 원본으로부터 계산하는 게 일반적이다.


4. 남은 할 일 개수 표시하기

const activeCount = todos.filter((todo) => !todo.completed).length;
<p>{activeCount}개 남음</p>

activeCount는 filter 값과 상관없이, 항상 todos 전체를 기준으로 "완료되지 않은 항목이 몇 개인지" 계산한다.


5. 활성화된 필터 버튼 강조하기

지금 선택된 필터가 무엇인지 시각적으로 보여주면 사용성이 좋아진다. 조건부 스타일을 활용한다.

<button
  onClick={() => setFilter("all")}
  style={{ fontWeight: filter === "all" ? "bold" : "normal" }}
>
  전체
</button>

filter === "all"이 참일 때만 글자를 굵게 만들어서, 지금 어떤 필터가 선택되어 있는지 한눈에 알 수 있게 한다.


6. App.jsx에서 전체 연결하기

// 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;

7. 실습 정리

실습 1) filter state와 버튼 3개 만들기

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>

실습 2) 필터링된 목록 계산해서 렌더링하기

filter 값에 따라 todos를 걸러낸 filteredTodos를 만들고, TodoList에 todos 대신 전달해서 버튼을 누를 때마다 목록이 바뀌는지 확인한다.

const filteredTodos = todos.filter((todo) => {
  if (filter === "active") return !todo.completed;
  if (filter === "completed") return todo.completed;
  return true;
});

실습 3) 남은 할 일 개수 표시하기

todos 중 완료되지 않은 항목 개수를 계산해서 화면에 "N개 남음"으로 표시한다.

const activeCount = todos.filter((todo) => !todo.completed).length;

// ...

<p>{activeCount}개 남음</p>

실습 4) [선택] 활성화된 필터 버튼 강조하기

현재 선택된 filter와 일치하는 버튼만 글자가 굵게 보이도록 조건부 스타일을 적용한다.

<button
  onClick={() => setFilter("all")}
  style={{ fontWeight: filter === "all" ? "bold" : "normal" }}
>
  전체
</button>

마무리

  • 원본 데이터(todos)와 "지금 화면에 보여줄 데이터"(filteredTodos)를 분리하는 게 핵심이다. 필터는 원본을 지우는 게 아니라, 매번 원본으로부터 새로 계산해서 보여주는 것뿐이다.
  • activeCount처럼 filter와 무관하게 항상 전체 todos 기준으로 계산해야 하는 값은, filteredTodos가 아니라 todos를 기준으로 따로 계산해야 한다.
  • 이걸로 Todo 앱의 추가·삭제·완료 토글·필터링까지 기본적인 기능이 모두 완성됐다.

0개의 댓글