본문 바로가기

개발/React

[React] 컴포넌트 분리와 State 끌어올리기

지금까지 Todo 앱의 모든 코드를 App.jsx 하나에 몰아넣었는데 이제 React다운 구조로 나눠보자.

 

현재 Todo 앱에는 서로 다른 역할들이 섞여 있다.

 

App
├─ Todo 입력
├─ Todo 목록 출력
├─ Todo 하나 출력
├─ 추가 기능
├─ 완료 기능
└─ 삭제 기능

이걸 이렇게 나눠보자.

App
├─ TodoForm
└─ TodoList
    └─ TodoItem

 

역할은

App
→ todos State 관리

TodoForm
→ 새로운 Todo 입력

TodoList
→ Todo 목록 출력

TodoItem
→ Todo 하나 출력

 

이다.

 

여기서 중요한 것은 State를 무작정 각 컴포넌트로 옮기는 게 아니라는 점이다.

 

Step 1. 파일 만들기

src 폴더 안에 다음 파일을 만든다.

src/
├─ App.jsx
├─ TodoForm.jsx
├─ TodoList.jsx
└─ TodoItem.jsx

현재 학습 단계에서는 구조를 단순하게 유지하기위해 별도의 components 폴더까지는 만들지 않는다.

 

Step 2. TodoItem 분리

현재 src/App.jsx에는 이런 코드가 있다.

<li key={todo.id}>
  {todo.completed ? "✅ " : "⬜️ "}
  {todo.text}

  <button onClick={() => handleToggle(todo.id)}>
    {todo.completed ? "취소" : "완료"}
  </button>

  <button onClick={() => handleDelete(todo.id)}>삭제</button>
</li>

이건 Todo 하나를 보여주는 UI이다.

따라서 TodoItem으로 분리하기 좋다.

 

src/TodoItem.jsx

function TodoItem({ todo }) {
  return (
    <li>
      {todo.completed ? "✅ " : "⬜️ "}
      {todo.text}
    </li>
  );
}

export default TodoItem;

 

이제 App.jsx에서

<TodoItem todo={todo} />

라고 작성하여 사용할 수 있다.

 

아직 완료/삭제 기능은 연결하지 않았는데 그건 조금 뒤에서 연결할 것이다.

 

Step 3. TodoList 만들기

이번에는 Todo 배열 전체를 출력하는 부분이다.

 

기존 코드:

<ul>
  {todos.map((todo) => (
    <TodoItem key={todo.id} todo={todo} />
  ))}
</ul>

이 전체를 TodoList로 분리해보자.

 

src/TodoList.jsx

import TodoItem from "./TodoItem";

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

 

여기서

function TodoList({ todos })

TodoList는 부모 컴포넌트 App에게서 todos를 props로 받는다.

 

그리고 TodoList는

todo={todo}

를 통해 다시 자식 컴포넌트인 TodoItem에게 todo를 하나씩 전달한다.

 

데이터 흐름은

App
 │
 │ todos
 ↓
TodoList
 │
 │ todo
 ↓
TodoItem

이다.

 

Step 4. App에서 TodoList 사용하기

src/App.jsx

import { useState } from "react";
import TodoList from "./TodoList";

function App() {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: "React 공부하기",
      completed: false,
    },
    {
      id: 2,
      text: "운동하기",
      completed: true,
    },
  ]);

  return (
    <div>
      <h1>Todo List</h1>

      <TodoList todos={todos} />
    </div>
  );
}

export default App;

 

 

이제 구조를 보면

App
 ↓
todos 전달
 ↓
TodoList
 ↓
map()
 ↓
TodoItem

 

이다.

 

Step 5. 완료 기능은 어디에 둘까?

현재 todos State는 App에 있다.

const [todos, setTodos] = useState([]);

그렇다면 Todo를 수정하는 함수도 기본적으로 App에 두는 것이 자연스럽다.

 

function handleToggle(id) {
  setTodos((prevTodos) =>
    prevTodos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo,
    ),
  );
}

그런데 버튼은 TodoItem 안에 있다.

App
→ State 있음

TodoItem
→ 버튼 있음


어떻게 연결할 수 있을까?

 

함수를 Props로 내려주면 된다.

 

Step 6. 함수도 Props로 전달할 수 있다

src/App.jsx

<TodoList todos={todos} onToggle={handleToggle} />

여기서

onToggle={handleToggle}

은 함수를 전달하는 것이다.

문자열이나 숫자만 Props가 될 수 있는 것이 아니다.

문자열, 숫자, boolean, 객체, 배열, 함수

모두 Props로 전달할 수 있다.

Step 7. TodoList가 함수를 다시 전달

src/TodoList.jsx

import TodoItem from "./TodoItem";

function TodoList({ todos, onToggle }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
        />
      ))}
    </ul>
  );
}

export default TodoList;

 

이제 흐름은

App
 │
 │ handleToggle
 ↓
TodoList
 │
 │ onToggle
 ↓
TodoItem

이다.

 

TodoList는 그냥 함수를 전달만 하고 있다.

 

Step 8. TodoItem에서 함수 실행

src/TodoItem.jsx

function TodoItem({ todo, onToggle }) {
  return (
    <li>
      {todo.completed ? "✅ " : "⬜️ "}
      {todo.text}

      <button onClick={() => onToggle(todo.id)}>
        {todo.completed ? "취소" : "완료"}
      </button>
    </li>
  );
}

export default TodoItem;

버튼을 누르면

onToggle(todo.id)

가 실행된다.

 

이 onToggle의 실제 정체는 handleToggle 함수이다.

 

즉, 흐름은

TodoItem 버튼 클릭
       ↓
onToggle(todo.id)
       ↓
App의 handleToggle(id)
       ↓
setTodos()
       ↓
State 변경
       ↓
다시 렌더링

이다.

 

이 패턴이 React에서 매우 중요하다.

 

Step 9. 삭제 기능도 똑같다

src/App.jsx

function handleDelete(id) {
  setTodos((prevTodos) =>
    prevTodos.filter((todo) => todo.id !== id)
  );
}

그리고

<TodoList
  todos={todos}
  onToggle={handleToggle}
  onDelete={handleDelete}
/>

 

src/TodoList.jsx

import TodoItem from "./TodoItem";

function TodoList({ todos, onToggle, onDelete }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

export default TodoList;

 

src/TodoItem.jsx

function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li>
      {todo.completed ? "✅ " : "⬜️ "}
      {todo.text}

      <button onClick={() => onToggle(todo.id)}>
        {todo.completed ? "취소" : "완료"}
      </button>

      <button onClick={() => onDelete(todo.id)}>
        삭제
      </button>
    </li>
  );
}

export default TodoItem;

 

Step 10. 이제 TodoForm 분리

이번에는 입력창 부분이다.

기존에는 App 컴포넌트에 이런 State가 있었다.

const [input, setInput] = useState("");

이 input State도 App에 있어야 할까?

 

한번 생각해보자.

 

input 값을 사용하는 곳은 사실 Todo 입력 폼뿐이다.

TodoForm
→ input 사용
→ setInput 사용

다른 컴포넌트는 input을 몰라도 된다.

따라서 이 input State는 TodoForm 안으로 옮겨도 된다.

이건 아주 중요한 설계 감각이다.

 

Step 11. TodoForm 만들기

src/TodoForm.jsx

import { useState } from "react";

function TodoForm({ onAdd }) {
  const [input, setInput] = useState("");

  function handleChange(event) {
    setInput(event.target.value);
  }

  function handleSubmit(event) {
    event.preventDefault();

    if (input.trim() === "") {
      return;
    }

    onAdd(input.trim());

    setInput("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={input}
        onChange={handleChange}
        placeholder="할 일을 입력하세요"
      />

      <button type="submit">추가</button>
    </form>
  );
}

export default TodoForm;

여기서 중요한 변화가 있다.

TodoForm 컴포넌트는 직접 todos를 변경하지 않는다.

그냥

onAdd(input.trim());

를 통해 부모 컴포넌트에게 알려준다.

 

쉽게 말하면

"부모 컴포넌트야, 사용자가 이 내용을 추가하겠대."

라고 전달하는 것이다.

 

좀 더 설명하자면

 

부모 컴포넌트인 App이 자식 컴포넌트인 TodoForm에게 onAdd라는 이름의 prop으로 자기 함수를 전달한다.

onAdd는 실제로는 부모 컴포넌트에 있는 함수이다.

즉, State 끌어올리기는 부모가 자기 함수를 자식에게 내려주고, 자식이 필요한 시점에 그 함수를 호출하는 구조이다.

 

Step 12. 실제 Todo 추가는 App이 한다

src/App.jsx

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

  setTodos((prevTodos) => [...prevTodos, newTodo]);
}

그리고

<TodoForm onAdd={handleAdd} />

라고 전달한다.

 

흐름:

TodoForm
사용자가 "React 공부" 입력
       ↓
onAdd("React 공부")
       ↓
App의 handleAdd("React 공부")
       ↓
newTodo 생성
       ↓
setTodos()
       ↓
새 Todo 추가

 

Step 13. 왜 todos State를 TodoForm에 두지 않을까?

TodoForm뿐만 아니라 TodoList도 todos가 필요하다.

그런데 TodoForm에 todos State를 두는 경우에는 TodoList는 TodoForm의 자식 컴포넌트가 아니므로

todos State를 전달할 길이 없다.

따라서

        App
       todos
      ↙     ↘
TodoForm   TodoList

처럼 TodoForm과 TodoList의 공통 부모인 App이 State를 가져야 한다.

 

이것이 바로 State 끌어올리기(Lifting State Up)이다.

 

Step 14. 최종 코드

src/App.jsx

import { useState } from "react";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";

function App() {
  const [todos, setTodos] = useState([]);

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

    setTodos((prevTodos) => [...prevTodos, newTodo]);
  }

  function handleToggle(id) {
    setTodos((prevTodos) =>
      prevTodos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo,
      ),
    );
  }

  function handleDelete(id) {
    setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
  }

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

  return (
    <div>
      <h1>Todo List</h1>

      <p>
        완료: {completedCount} / {todos.length}
      </p>

      <TodoForm onAdd={handleAdd} />

      <TodoList todos={todos} onToggle={handleToggle} onDelete={handleDelete} />
    </div>
  );
}

export default App;

 

 

src/TodoList.jsx

import TodoItem from "./TodoItem";

function TodoList({ todos, onToggle, onDelete }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onDelete={onDelete}
        />
      ))}
    </ul>
  );
}

export default TodoList;

 

 

src/TodoItem.jsx

function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li>
      {todo.completed ? "✅ " : "⬜️ "}
      {todo.text}

      <button onClick={() => onToggle(todo.id)}>
        {todo.completed ? "취소" : "완료"}
      </button>

      <button onClick={() => onDelete(todo.id)}>삭제</button>
    </li>
  );
}

export default TodoItem;

 

 

src/TodoForm.jsx

import { useState } from "react";

function TodoForm({ onAdd }) {
  const [input, setInput] = useState("");

  function handleChange(event) {
    setInput(event.target.value);
  }

  function handleSubmit(event) {
    event.preventDefault();

    if (input.trim() === "") {
      return;
    }

    onAdd(input.trim());

    setInput("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={input}
        onChange={handleChange}
        placeholder="할 일을 입력하세요"
      />

      <button type="submit">추가</button>
    </form>
  );
}

export default TodoForm;

 

 

Step 15. 지금 구조를 꼭 이해하자.

현재 Todo앱의 데이터 흐름이다.

                   App
              todos State
                   │
        ┌──────────┴──────────┐
        │                     │
        ↓                     ↓
    TodoForm               TodoList
        │                     │
        │ onAdd               │ todos
        │                     ↓
        │                  TodoItem
        │                     │
        │              onToggle / onDelete
        │                     │
        └──────────→ App ←────┘

좀 더 단순히 표현하면

데이터는 아래로
Props ↓

이벤트는 위로
함수 호출 ↑

 

React에서는 데이터는 부모 → 자식으로 내려가고, 자식의 행동은 부모가 내려준 함수를 호출해서 위에 알린다.

 

+

 

관례적으로

실제 처리 함수 이름은 handleSomething과 같이 사용하고,

함수를 Props로 전달하는 경우에는 onSomething과 같이 사용한다.

 

Step 16. State를 어디에 둘지 판단하는 기준

특정 State를

  • 하나의 컴포넌트만 사용하는 경우 → 그 컴포넌트 안에 둔다.
  • 여러 컴포넌트가 공유하는 경우 → 가장 가까운 공통 부모에 둔다.

이게 State 설계의 가장 기본적인 원칙이다.

 

 

실습

지금 구조에 전체 완료 버튼을 새로운 컴포넌트로 분리하여 추가해보자.

 

src/TodoActions.jsx

function TodoActions({ onCompleteAll }) {
  return <button onClick={onCompleteAll}>전체 완료</button>;
}

export default TodoActions;

 

그리고 App.jsx에 함수 추가

function handleCompleteAll() {
  setTodos((prevTodos) =>
    prevTodos.map((todo) => ({
      ...todo,
      completed: true,
    })),
  );
}

 

컴포넌트 사용:

<TodoActions onCompleteAll={handleCompleteAll} />

 

 

힌트:

여기서도 흐름은 똑같다.

TodoActions
↓
버튼 클릭
↓
onCompleteAll()
↓
App의 handleCompleteAll()
↓
setTodos()

 

 

핵심 정리

컴포넌트 분리는 단순히 파일을 여러 개 만드는 기술이 아니다.

핵심은 컴포넌트 역할 분리와 State 위치이다.

App
→ Todo 데이터 관리

TodoForm
→ 입력 책임

TodoList
→ 목록 책임

TodoItem
→ 한 항목의 UI 책임

 

그리고 가장 중요한 두 문장

  • State는 필요한 곳들의 가장 가까운 공통 부모에 둔다.
  • 데이터는 Props로 아래로 내려가고, 이벤트는 함수를 통해 부모에게 전달한다.

 

여기까지가 React의 기본 컴포넌트 설계 방식이다.