FullStack FrontEnd

React Todo 앱 - 추가 기능 구현 (콜백 패턴, 불변성)

SBDevv 2026. 9. 18. 08:45

React Todo 앱 - 추가 기능 구현 (콜백 패턴, 불변성)

Todo 앱의 첫 번째 기능인 "추가"를 구현한다. 자식 컴포넌트에서 부모에게 데이터를 요청하는 콜백 패턴, 고유 id 생성, 그리고 React에서 배열 state를 다룰 때 반드시 지켜야 할 불변성 개념까지 다룬다.

TodoForm.jsx 만들기 - 입력값을 state로 관리

// src/TodoForm.jsx
import { useState } from "react";

function TodoForm() {
  const [text, setText] = useState("");

  return (
    <form>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="할 일을 입력하세요"
      />
      <button type="submit">추가</button>
    </form>
  );
}

export default TodoForm;

부모(App)에게 추가 요청하기 - 콜백 함수 전달

text state는 TodoForm 자신이 가지고 있지만, 진짜 할 일 목록(todos)은 부모인 App이 가지고 있다. 그래서 TodoForm은 새 할 일을 직접 추가할 수 없고, App으로부터 "추가해줘"라고 요청할 함수를 props로 받아서 호출해야 한다.

// src/TodoForm.jsx
import { useState } from "react";

function TodoForm({ onAdd }) {
  const [text, setText] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    onAdd(text);
    setText("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="할 일을 입력하세요"
      />
      <button type="submit">추가</button>
    </form>
  );
}

export default TodoForm;

제출 후에는 setText("")로 입력창을 다시 비워준다.

새로운 todo 객체 만들기 - 고유 id 부여

const newTodo = { id: Date.now(), text: text, completed: false };

왜 id를 1, 2, 3 ... 처럼 직접 정하지 않고 Date.now()를 쓸까? 할 일을 추가했다가 삭제하는 상황이 반복되면, 단순히 순서로 번호를 매기는 방식은 항목끼리 번호가 겹치기 쉽다. Date.now()는 "지금 이 순간의 시각"을 숫자로 돌려주는데, 같은 밀리초에 두 개를 동시에 만들지 않는 한 항상 서로 다른 값이 나오기 때문에 간단하면서도 안전하게 고유한 id를 만들 수 있다.

배열 state에 불변성을 지키며 항목 추가하기

배열에 값을 추가할 때 자바스크립트의 push()를 바로 떠올리기 쉽지만, React의 state를 다룰 때는 push()를 사용하면 안 된다.

// ❌ 이렇게 하면 안 됩니다
todos.push(newTodo);
setTodos(todos);

// ✅ 스프레드 연산자로 새 배열을 만들어야 합니다
setTodos([...todos, newTodo]);

push()는 기존 배열 자체를 그 자리에서 바꿔버린다. React는 state가 바뀌었는지 확인할 때 "이전 배열과 새 배열이 완전히 다른 객체인지"를 비교하는데, push()를 쓰면 배열의 내용은 바뀌어도 배열 자체(메모리 주소)는 그대로이기 때문에 React가 변화를 감지하지 못해 화면이 갱신되지 않을 수 있다.

[...todos, newTodo]처럼 스프레드 연산자(...)를 사용하면, 기존 todos의 내용을 그대로 복사한 완전히 새로운 배열을 만들어서 setTodos에 전달하기 때문에 React가 변화를 확실하게 감지한다.

⚠️ 이 원칙(불변성)은 배열뿐 아니라 객체 state를 다룰 때도 똑같이 적용된다. React state는 직접 수정하지 말고 항상 "새로운 값을 만들어서" 교체해야 한다.

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]);
  };

  return (
    <div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
      <h1>Todo 리스트</h1>
      <TodoForm onAdd={handleAdd} />
      <TodoList todos={todos} />
    </div>
  );
}

export default App;

실습 정리

실습 1) TodoForm.jsx 만들기 — text state와 input을 연결한 Controlled Component로 작성한다.

실습 2) onAdd 콜백 연결하기 — TodoForm이 onAdd props를 받도록 수정하고, 제출 시 onAdd(text)를 호출한 뒤 입력창을 비운다.

실습 3) 고유 id 부여하고 todos 배열에 추가하기 — App.jsx에 handleAdd 함수를 작성해서 새 할 일 객체를 만들고 스프레드 연산자로 todos에 추가한다.

실습 4) [선택] 빈 문자열 추가 방지하기

const handleSubmit = (e) => {
  e.preventDefault();
  if (text.trim() === "") {
    return;
  }
  onAdd(text);
  setText("");
};

마무리

  • 자식이 부모의 데이터를 바꿔야 할 때는, 부모가 함수를 props로 내려주고 자식은 그 함수를 호출(onAdd(text))하는 콜백 패턴을 사용한다.
  • Date.now()는 간단하면서도 겹치지 않는 고유 id를 만드는 실용적인 방법이다.
  • React state(특히 배열/객체)는 절대 직접 수정하지 말고, 스프레드 연산자([...todos, newTodo])로 항상 새로운 값을 만들어서 교체해야 한다. 이게 바로 "불변성을 지킨다"는 의미다.