React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 11. 할 일 관리 앱 (TODO LIST)

favor 2026. 5. 14. 19:01
// App.tsx

import Todo from "./components/Todo";

export default function App() {
  return (
    <>
      <Todo />
    </>
  );
}

 

 

// src/components/Todo.tsx

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

export default function Todo() {
  const [todos, setTodos] = useState<Todo[]>([]); // 배열로 선언
  const addTodo = (text: string) => {
    // text만 매개변수로 전달받음
    setTodos((todos) => [
      ...todos,
      {
        id: Date.now(),
        text: text,
        completed: false,
      },
    ]);
  };

  const toggleTodo = (id: number) => {
    setTodos((todos) =>
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo,
      ),
    );
  };

  const deleteTodo = (id: number) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  };

  const modifyTodo = (id: number, text: string) => {
    setTodos((todos) => todos.map((todo) => todo.id === id ? {...todo, text} : todo))
  }

  return (
    <>
      <div className="todo">
        <TodoHeader />
        {/* <!-- 할 일 등록 --> */}
        <TodoEditer addTodo={addTodo} />
        {/* <!-- 할 일 목록 --> */}
        <TodoList
          todos={todos}
          toggleTodo={toggleTodo}
          deleteTodo={deleteTodo}
          modifyTodo={modifyTodo}
        />
      </div>
    </>
  );
}
// src/components/TodoHeader.tsx

export default function TodoHeader () {
  return (
    <>
      <h1 className="todo__title">Todo List</h1>
        <p className="todo__subtitle">Please enter your details to continue.</p>
    </>
  );
}
// src/components/TodoEditor.tsx

import { useState } from "react";
import Button from "./html/Button";
import Input from "./html/Input";

export default function TodoEditer({
  addTodo,
}: {
  addTodo: (text: string) => void;
}) {
  const [text, setText] = useState("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (text.trim() === "") { // 빈 문자열을 입력했을 때는 return
      return;
    }

    addTodo(text);
    setText("");
  };

  return (
    <>
      <form className="todo__form" onSubmit={handleSubmit}>
        <div className="todo__editor">
          <Input
            type="text"
            className="todo__input"
            placeholder="Enter Todo List"
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
          <Button className="todo__button" type="submit">
            Add
          </Button>
        </div>
      </form>
    </>
  );
}
// src/components/TodoListEmpty.tsx

export default function TodoListEmpty() {
  return (
    <>
      <li className="todo__item todo__item--empty">
        <p className="todo__text--empty">There are no registered tasks</p>
      </li>
    </>
  );
}
// src/components/TodoList.tsx

import TodoListEmpty from "./TodoListEmpty";
import TodoListItem from "./TodoListItem";

export default function TodoList({
  todos,
  toggleTodo,
  deleteTodo,
  modifyTodo
}: {
  todos: Todo[];
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
  modifyTodo: (id: number, text: string) => void;
}) {
  return (
    <>
      <ul className="todo__list">
        {/* <!-- 할 일 목록이 없을 때 --> */}
        {todos.length === 0 && <TodoListEmpty />}
        {/* <!-- 할 일 목록이 있을 때 --> */}
        {todos.map((todo) => (
          <TodoListItem key={todo.id} todo={todo} toggleTodo={toggleTodo} deleteTodo={deleteTodo} modifyTodo={modifyTodo} />
        ))}
      </ul>
    </>
  );
}
// TodoListItem.tsx

import { useState } from "react";
import Button from "./html/Button";
import Checkbox from "./html/Checkbox";
import Input from "./html/Input";
import SvgClose from "./svg/SvgClose";
import SvgPencil from "./svg/SvgPencil";

export default function TodoListItem({
  todo,
  toggleTodo,
  deleteTodo,
  modifyTodo
}: {
  todo: Todo;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
  modifyTodo: (id: number, text: string) => void;

}) {
  const [isModify, setIsModify] = useState(false);
  const [modifyText, setModifyText] = useState("");
  const modifyHandler = () => {
    setIsModify((isModify) => !isModify);
    setModifyText((modifyText) => modifyText === "" ? todo.text : modifyText);
    
    if (modifyText.trim() !== "" && todo.text !== modifyText) { // todo의 text와 수정 모드에서 입력한 text가 다를 때 --> 수정 
      modifyTodo(todo.id, modifyText);
    }
  };

  return (
    <>
      {/* <!-- 할 일이 완료되면 .todo__item--complete 추가 --> */}
      <li className={`todo__item ${todo.completed && "todo__item--complete"}`}>
        {!isModify && (
          <Checkbox
            parentClassName="todo__checkbox-group"
            type="checkbox"
            className="todo__checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          >
            {todo.text}
          </Checkbox>
        )}

        {/* <!-- 할 일을 수정할 때만 노출 (.todo__checkbox-group은 비노출) --> */}
        {isModify && (
          <Input
            type="text"
            className="todo__modify-input"
            value={modifyText}
            onChange={(e) => setModifyText(e.target.value)}
          />
        )}
        <div className="todo__button-group">
          <Button className="todo__action-button" onClick={modifyHandler}>
            <SvgPencil />
          </Button>
          <Button
            className="todo__action-button"
            onClick={() => deleteTodo(todo.id)}
          >
            <SvgClose />
          </Button>
        </div>
      </li>
    </>
  );
}

 

 

// src/components/html/Button.tsx

// HTML button이 가진 모든 속성(props)를 그대로 빌려오겠다는 선언 
// ref 속성까지 가져오면 오류가 날 수 있기 때문에 WithoutRef
type ButtonProps = React.ComponentPropsWithoutRef<"button">;

export default function Button (props: ButtonProps) {

  // children : 태그 사이의 내용
  // ...rest : props 중 children을 뺀 나머지 모든 속성을 담은 객체 
  const {children, ...rest} = props;

  return (
    <>
      <button {...rest}>{children}</button>
    </>
  );
}
// src/components/html/Checkbox.tsx

type CheckboxProps = Omit<React.ComponentPropsWithoutRef<"input">, "type"> & {
  type?: "checkbox";
  parentClassName: string;
};

export default function Checkbox(props: CheckboxProps) {
  const { parentClassName, children, ...rest } = props;

  return (
    <>
      <div className={ parentClassName }>
        <input {...rest} />
        <label>{ children }</label>
      </div>
    </>
  );
}
// src/components/html/Input.tsx

type ReactInputType = React.InputHTMLAttributes<HTMLInputElement>["type"];
type InputProps = Omit<React.ComponentPropsWithoutRef<"input">, "type"> & {
  type?: Exclude<ReactInputType, "radio" | "checkbox">;
};

export default function Input(props: InputProps) {
  const { ...rest } = props;

  return (
    <>
      <input {...rest} />
    </>
  );
}

 

 

 


Checkbox.tsx

Omit<..., "type">

^ input 태그가 원래 가지고 있는 수많은 속성 중 'type'만 빼겠다는 의미

∵ 누군가 해당 컴포넌트를 가져다쓰면서 <Checkbox type="text" />라고 실수하는 것을 막기 위해

 

& { type?: "checkbox"; ... }

^ 'type' 자리에 checkbox라는 값만 들어올 수 있도록 엄격하게 제한

특정 타입만을 허용하는 것 

 

Input.tsx

type ReactInputType = React.InputHTMLAttributes<HTMLInputElement>["type"]

^ 브라우저 인풋이 가질 수 있는 모든 type(text, password, email, radio, checkbox 등)을 한데 모은 거대한 목록

 

Exclude<..., "radio" | "checkbox">

^ 거대한 목록에서 radio와 checkbox만 뺀 새로운 목록을 만듦

∴ Input 컴포넌트는 text, password, number 등은 자유롭게 쓸 수 있지만, type="radio"와 같이 넣으려고 하면 에러 발생

ㄴ 특정 타입만을 허용하는 방식이 아닌, 특정 타입들을 배제하는 방식