TypeScript + React로 todo list 만들기

.DS_Store·2023년 2월 22일
2
post-thumbnail

새로운 프로젝트를 TypeScript + React 조합으로 하게 될 것 같아,
기본 중의 기본 todo-list를 만드는 것부터 연습해본다.
완성 코드는 github에도 올려두었으니 확인 가능하다.

GitHub에서 확인하기

완성된 모습 미리보기

Todo의 CURD가 가능하다.

프로젝트 설치 및 실행(yarn)

지금까지는 npm을 통해서 react project를 설치 및 실행했었는데,
yarn이 속도, 안정성, 보안성 측면에서 조금 더 우위에 있다고 하여 이번에는 package 관리를 yarn으로 해보려 한다.

따라서 먼저 yarn을 설치해줬다.

// 설치
npm install -g yarn
// 설치 후 버전 확인
yarn --version

현재 기준으로 yarn version을 찍어보면 1.22.19이 나온다.

React project 만들기

// pjt 생성
yarn create react-app todolist --template typescript
// 폴더 이동
cd todolist
// 프로젝트 실행
yarn start

폴더 구조 확인하기


가장 기본 폴더 구조는 위와 같다.

App.tsx 내에 작성된 코드는 다 날려주고, 불필요한 파일들 (logo.png 등)도 삭제했다.

//App.tsx

import './App.css';

function App() {
  return (
    <div className="App">
      <p>TodoList를 만들 곳입니다.</p>
    </div>
  );
}

export default App;

틀 만들기

완료 버튼을 눌러 글자에 취소선이 뜨고, 수정 및 삭제가 가능하도록 하려고 한다.
우선 기본적인 배열과 동작하지 않는 버튼만 추가해줬다.
(component분리를 처음부터 할 경우에는, component 분리로 내려가면 된다.)

//App.tsx
import "./App.css";

function App() {
  return (
    <div className="App">
      <div className="todoListContainer">
        <li className="todoContainer">
          <button>완료</button>
          <p>할 일 1</p>
          <div className="buttonContainer">
            <button type="button">수정</button>
            <button type="button">삭제</button>
          </div>
        </li>
        <li className="todoContainer">
          <button>완료</button>
          <p>할 일 2</p>
          <div className="buttonContainer">
            <button type="button">수정</button>
            <button type="button">삭제</button>
          </div>
        </li>
      </div>
      <div className="todoCreateContainer">
        <form>
          <input type="text" placeholder="할 일을 입력해 주세요." />
          <button>등록하기</button>
        </form>
      </div>
    </div>
  );
}

export default App;
//App.css
.App {
  text-align: center;
  width: 30rem;
  margin: 0 auto;
  padding: 1rem;
  background-color: beige;
}


.todoContainer {
  display: flex;
  flex-direction: row;
  justify-content: space-around;
  align-items: center;
}

todo의 type 지정하기

//App.tsx
import { useState } from "react";
import "./App.css";

// 아래와 같이 type 지정
interface TList {
  id: number;
  text: string;
  completed: boolean;
}

function App() {
  // 아래와 같이 사용할 수 있습니다.
  const [todoList, setTodoList] = useState<TList[]>([
    {
      id: 1,
      text: "할일 1",
      completed: false,
    },
    {
      id: 2,
      text: "할일 2",
      completed: false,
    },
  ]);

  return (
    <div className="App">
      <div className="todoListContainer">
    
    	// map을 통해 list 내부 아이템을 뽑아줍니다.
        {todoList.map((item, idx) => (
          <div key={idx}>
            <li className="todoContainer">
              <button>완료</button>
              <p>{item.text}</p>
              <div className="buttonContainer">
                <button type="button">수정</button>
                <button type="button">삭제</button>
              </div>
            </li>
          </div>
        ))}
      </div>
      <div className="todoCreateContainer">
        <form>
          <input type="text" placeholder="할 일을 입력해 주세요." />
          <button>등록하기</button>
        </form>
      </div>
    </div>
  );
}

export default App;

component 분리하기

모두 작성하고 이후에 component를 분리할까 했지만,
벌써부터 코드가 길어지고 있어...
component를 분리했다.

전체 TodoList가 담길 곳과 하나하나의 TodoItem,
그리고 Todo를 만드는 곳
총 세개의 컴포넌트로 분리했고

App.tsx - TodoList - TodoItem
- CreateTodo
의 구조라고 보면 될 것 같다.

App.tsx

//App.tsx
import TodoList from "./Components/TodoList";
import "./App.css";

function App() {
  return (
    <div className="App">
      <TodoList />
    </div>
  );
}

export default App;

TodoList.tsx

//TodoList.tsx
import { useState } from "react";
import CreateTodo from "./CreateTodo";
import TodoItem from "./TodoItem";

interface TList {
  id: number;
  text: string;
  completed: boolean;
}

export default function TodoList() {
  const [todoList, setTodoList] = useState<TList[]>([
    {
      id: 1,
      text: "할일 1",
      completed: false,
    },
    {
      id: 2,
      text: "할일 2",
      completed: false,
    },
  ]);

  return (
    <div className="todoListContainer">
      {todoList.map((item) => (
        <TodoItem key={item.id} text={item.text} completed={item.completed} />
      ))}
      <CreateTodo />
    </div>
  );
}

TodoItem.tsx

//TodoItem.tsx
interface TodoItemProps {
  text: string;
  completed: boolean;
}

export default function TodoItem({ text, completed }: TodoItemProps) {
  return (
    <li className="todoContainer">
      {completed ? <button>완료됨</button> : <button>완료하기</button>}
      <p>{text}</p>
      <div className="buttonContainer">
        <button type="button">수정</button>
        <button type="button">삭제</button>
      </div>
    </li>
  );
}

CreateTodo.tsx

//CreateTodo.tsx
export default function CreateTodo() {
  return (
    <div className="todoCreateContainer">
      <form>
        <input type="text" placeholder="할 일을 입력해 주세요." />
        <button>등록하기</button>
      </form>
    </div>
  );
}

확실히 component를 분리하고 나니 하나하나의 파일이 가독성이 높아졌다.

Todo Item Create

⛔ props 내려줄 때 주의할 점

부모 -> 자식 모두에게 줄 내용과 받을 내용을 받기 전까지는 계속 빨간 줄로 TypesScript IntrinsicAttributes 오류가 뜬다. 꼭 자식 component에도 타입 속성을 지정하고 확인해보자.
(미리 고치려고 했더니 어쩐지 안 되더라!)

Create

Input 영역에 글을 작성하고, Form을 제출하면 등록한 이후 Input영역 값은 비워주는 형태로 구성했다.

따라서 useState로 입력되는 내용들을 저장하고, submit이벤트가 발생하면 저장 후 삭제한다.

먼저 props를 내려줄 TodoList.tsx

//TodoList.tsx
import { useState } from "react";
import CreateTodo from "./CreateTodo";
import TodoItem from "./TodoItem";

interface TList {
  id: number;
  text: string;
  completed: boolean;
}
export default function TodoList() {
  const [inputText, setInputText] = useState("");
  const [todoList, setTodoList] = useState<TList[]>([
    {
      id: 1,
      text: "할일 1",
      completed: false,
    },
    {
      id: 2,
      text: "할일 2",
      completed: false,
    },
  ]);

  // 입력값 변경내용 확인
  const textTypingHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputText(e.target.value);
  };

  // 입력 확인
  const textInputHandler = (event: React.FormEvent<HTMLFormElement>): void => {
    event.preventDefault();
    const newTodo: TList = {
      id: Date.now(),
      text: inputText,
      completed: false,
    };
    setTodoList([...todoList, newTodo]);
    // 아래처럼도 사용 가능
    // setTodoList(todoList.concat(newTodo));
    // 입력한 값 지우기
    setInputText("");
  };

  return (
    <div className="todoListContainer">
      {todoList.map((item) => (
        <TodoItem key={item.id} text={item.text} completed={item.completed} />
      ))}
      <CreateTodo
        onChange={textTypingHandler}
        onSubmit={textInputHandler}
        inputText={inputText}
      />
    </div>
  );
}

props를 받는 부분에서 복붙 하다 태그 잘못 안 넣게 조심해야한다... (오타 때문에 2시간은 족히 날렸다 ㅠㅠ)

//CreateTodo.tsx
interface InputTextProps {
  onChange(e: React.ChangeEvent<HTMLInputElement>): void;
  onSubmit(event: React.FormEvent<HTMLFormElement>): void;
  inputText: string;
}

export default function CreateTodo({
  onChange,
  onSubmit,
  inputText,
}: InputTextProps) {
  return (
    <div className="todoCreateContainer">
      <form onSubmit={(event) => onSubmit(event)}>
        <input
          onChange={(e) => onChange(e)}
          type="text"
          placeholder="할 일을 입력해 주세요."
          value={inputText}
        />
        <button type="submit">등록하기</button>
      </form>
    </div>
  );
}

참고로, id값은 key값으로만 사용할 것이라 임의로 Date.now()를 사용해 넣어주었다.

Delete 하기

TodoList에 Delete함수를 만들고, props 해주면 된다.
중간에 id 변수명을 key라고 작성했던 것을 다시 id로 변경해주었다.

//TodoList.tsx

(...)
  // 값 삭제하기
  const textDeleteHandler = (id: number) => {
    setTodoList(todoList.filter((todoItem) => todoItem.id !== id));
  };
(...)
<TodoItem
  id={item.id}
  text={item.text}
  completed={item.completed}
  onClickDelete={textDeleteHandler}
/>
(...)
//TodoItem.tsx
interface TodoItemProps {
  id: number;
  text: string;
  completed: boolean;
  onClickDelete(id: number): void;
}

export default function TodoItem({
  id,
  text,
  completed,
  onClickDelete,
}: TodoItemProps) {
  return (
    <li className="todoContainer">
      {completed ? <button>완료됨</button> : <button>완료하기</button>}
      <p>{text}</p>
      <div className="buttonContainer">
        <button type="button">수정</button>
        <button type="button" onClick={() => onClickDelete(id)}>
          삭제
        </button>
      </div>
    </li>
  );
}

Update(수정) 하기

가장 애를 먹었던 부분이다.
TodoList에 이미 모든 변수나 함수가 선언되어 있는데, TodoItem에서 update 함수를 새로 정의하려고 했더니 전에 선언된 변수들을 가져오는 것에 애를 먹었다. 특히 이미 작성되어 있는 내용을 중복해서 작성해야하는 부분들이 생겨서 수정함수도 TodoList.tsx에 작성하는 것으로 방식을 수정했다.

값 수정 함수 작성하기

//TodoList.tsx
(...)
   // 값 수정하기
  const textUpdateHandler = (newTodo: TList): void => {
    // newTodo는 새롭게 입력한 값
    const newTodoList = todoList.map((item) => {
      // id값이 같은 것은 새롭게 입력한 값으로 return하고
      if (item.id === newTodo.id) {
        return newTodo;
        // 그 외에는 기존 값을 return
      } else {
        return item;
      }
    });
    setTodoList(newTodoList);
  };
(...)
   // props 전해주는 부분
   <TodoItem
     id={item.id}
    text={item.text}
    completed={item.completed}
    onClickDelete={textDeleteHandler}
    onClickUpdate={textUpdateHandler}
  />
 

값 수정작업이 이루어질 TodoItem

//TodoItem.tsx
import { useState } from "react";
import { TList } from "./TodoList";

interface TodoItemProps {
  id: number;
  text: string;
  completed: boolean;
  onClickDelete(id: number): void;
  onClickUpdate(updatedTodoItem: TList): void;
}

export default function TodoItem({
  id,
  text,
  completed,
  onClickDelete,
  onClickUpdate,
}: TodoItemProps) {
  
  // 수정여부
  const [isUpdating, setIsUpdating] = useState<boolean>(false);
  const [updatedText, setUpdatedText] = useState<string>(text);

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUpdatedText(event.target.value);
  };

  const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const updatedTodoItem = {
      id: id,
      text: updatedText,
      completed: completed,
    };
    onClickUpdate(updatedTodoItem);
    setIsUpdating(false);
  };

  return (
    <div>
      {!isUpdating ? (
        <li className="todoContainer">
          {completed ? <button>완료됨</button> : <button>완료하기</button>}
          <p>{text}</p>
          <div className="buttonContainer">
            <button type="button" onClick={() => setIsUpdating(true)}>
              수정
            </button>
            <button type="button" onClick={() => onClickDelete(id)}>
              삭제
            </button>
          </div>
        </li>
      ) : (
        <li className="todoContainer">
          <form onSubmit={handleFormSubmit}>
            <input
              type="text"
              value={updatedText}
              onChange={handleInputChange}
            />
            <div className="buttonContainer">
              <button type="submit">확인</button>
              <button type="button" onClick={() => setIsUpdating(false)}>
                취소
              </button>
            </div>
          </form>
        </li>
      )}
    </div>
  );
}

이제 여기서
완료하기를 누르면 글에 취소선이 그어지고,
그 외 css style을 조금 입혀서 완성한 코드는 아래와 같다.
css코드는 github에 올려두었기 때문에 따로 작성하지는 않았다.

//TodoList.tsx
import { useState } from "react";
import CreateTodo from "./CreateTodo";
import TodoItem from "./TodoItem";

export interface TList {
  id: number;
  text: string;
  completed: boolean;
}
export default function TodoList() {
  const [inputText, setInputText] = useState("");
  const [todoList, setTodoList] = useState<TList[]>([]);

  // 입력값 변경내용 확인
  const textTypingHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputText(e.target.value);
  };

  // 입력 확인
  const textInputHandler = (event: React.FormEvent<HTMLFormElement>): void => {
    event.preventDefault();
    const newTodo: TList = {
      id: Date.now(),
      text: inputText,
      completed: false,
    };
    setTodoList([...todoList, newTodo]);
    // 아래처럼도 사용 가능
    // setTodoList(todoList.concat(newTodo));
    // 입력한 값 지우기
    setInputText("");
  };

  // 값 삭제하기
  const textDeleteHandler = (id: number) => {
    setTodoList(todoList.filter((todoItem) => todoItem.id !== id));
  };

  // 값 수정하기
  const updateHandler = (newTodo: TList): void => {
    // newTodo는 새롭게 입력한 값
    const newTodoList = todoList.map((item) => {
      // id값이 같은 것은 새롭게 입력한 값으로 return하고
      if (item.id === newTodo.id) {
        return newTodo;
        // 그 외에는 기존 값을 return
      } else {
        return item;
      }
    });
    setTodoList(newTodoList);
  };

  return (
    <div className="todoListContainer">
      <h3>💝 TodoList 💝</h3>
      {todoList.map((item) => (
        <TodoItem
          id={item.id}
          text={item.text}
          completed={item.completed}
          onClickDelete={textDeleteHandler}
          onClickUpdate={updateHandler}
        />
      ))}
      <CreateTodo
        onChange={textTypingHandler}
        onSubmit={textInputHandler}
        inputText={inputText}
      />
    </div>
  );
}
//TodoItem.tsx
import { useState } from "react";
import { TList } from "./TodoList";
import {
  AiOutlineEdit,
  AiOutlineDelete,
  AiOutlineCheck,
  AiOutlineClose,
} from "react-icons/ai";

interface TodoItemProps {
  id: number;
  text: string;
  completed: boolean;
  onClickDelete(id: number): void;
  onClickUpdate(updatedTodoItem: TList): void;
}

export default function TodoItem({
  id,
  text,
  completed,
  onClickDelete,
  onClickUpdate,
}: TodoItemProps) {
  // 수정여부
  const [isUpdating, setIsUpdating] = useState<boolean>(false);
  const [updatedText, setUpdatedText] = useState<string>(text);

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUpdatedText(event.target.value);
  };

  const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const updatedTodoItem = {
      id: id,
      text: updatedText,
      completed: completed,
    };
    onClickUpdate(updatedTodoItem);
    setIsUpdating(false);
  };

  const handleComplete = () => {
    const updatedTodoItem = {
      id: id,
      text: text,
      completed: !completed,
    };
    onClickUpdate(updatedTodoItem);
  };

  return (
    <div>
      {!isUpdating ? (
        <li className="todoContainer">
          <button className="completeBtn" onClick={handleComplete}>
            {completed ? "✔" : null}
          </button>
          <div className="itemContainer">
            <p
              className="itemText"
              style={completed ? { textDecoration: "line-through" } : undefined}
            >
              {text}
            </p>
            <div className="buttonContainer">
              <button
                type="button"
                className="inlineBtnBox"
                onClick={() => setIsUpdating(true)}
              >
                <AiOutlineEdit size="17" />
              </button>
              <button
                type="button"
                className="inlineBtnBox"
                onClick={() => onClickDelete(id)}
              >
                <AiOutlineDelete size="17" />
              </button>
            </div>
          </div>
        </li>
      ) : (
        <li className="todoContainer">
          <button className="completeBtn" onClick={handleComplete}>
            {completed ? "✔" : null}
          </button>
          <form onSubmit={handleFormSubmit} className="itemContainer">
            <input
              className="itemTextInput"
              type="text"
              value={updatedText}
              onChange={handleInputChange}
            />
            <div className="buttonContainer">
              <button type="submit" className="inlineBtnBox">
                <AiOutlineCheck size="17" />
              </button>
              <button
                type="button"
                className="inlineBtnBox"
                onClick={() => setIsUpdating(false)}
              >
                <AiOutlineClose size="17" />
              </button>
            </div>
          </form>
        </li>
      )}
    </div>
  );
}

0개의 댓글