[React][TypeScript] React+TypeScript로 TodoList를 생성하고 삭제해보자

Haeun Noh·2024년 5월 31일
0

React

목록 보기
3/3
post-thumbnail

0531

이 글에서는 React와 TypeScript로 TodoList의 추가, 삭제 기능을 구현하는 프로젝트 내용을 담고 있습니다.

  • 깃허브 링크는 포스팅 하단에 첨부해두겠습니다.
  • 깃허브의 코드는 변형되었을 수 있습니다. 커밋 로그를 확인해주세요.

🗂️ 폴더 구조

root
	├── node_modules
    ├── public
    ├── src
	│   ├── components
	│   │   ├── NewTodo.module.css
    │   │   ├── NewTodo.tsx
    │   │   ├── TodoItem.module.css
    │   │   ├── TodoItem.tsx
    │   │   ├── Todos.module.css
    │   │   ├── Todos.tsx
	│   ├── models
	│   │   ├── todo.ts
    │   ├── App.css
    │   ├── App.tsx
    │   ├── index.css
    │   ├── index.tsx
    │   └── react-app-env.d.ts
    ├── .gitignore
    ├── package-lock.json
    ├── package.json
    ├── README.md
	└── tsconfig.json

⚙️ tsconfig.json

ts -> js로 컴파일하는 경우에 이 파일로 컴파일과 관련된 사항을 구성합니다.

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}
  • target: 작성한 ts코드를 어떤 버전의 js코드로 변환할건지 결정합니다.

  • lib: 기본 타입스크립트 라이브러리

    • 어떤 타입이 ts에서 기본으로 지원되는지를 결정합니다.
    • 내장되어있기 때문에 별도 설치는 안 해도 되나 사용하려면 'lib'에 이름을 추가해야합니다.
  • allowJS: js파일을 포함할 수 있는가의 여부를 결정합니다.

  • strict: true로 설정하면 이 프로젝트에 가장 엄격한 설정이 적용됩니다.

    • ex) any타입 금지
  • jsx: jsx코드를 지원할 것인가에 대한 여부를 결정합니다.


✅ TodoList를 React+TypeScript로 구현해보자

🧩 todo.ts

Todo의 형태를 정의합니다.

// models/todo.ts
class Todo {
    id: string;
    text: string;

    constructor(todoText: string) {
        this.text = todoText;
        this.id = new Date().toISOString();
    }
}

export default Todo;

어떠한 형태를 정의할 때는 interface, type, class 등을 사용할 수 있는데 여기서는 class의 형태로 Todo를 정의하였습니다.

Todoidtext에 값이 할당되는 부분이 없다면 에러가 발생합니다.
이는 "인스턴스화가 되지 않기 때문"입니다.

따라서 constructor로 값을 할당해줍니다.
이 때 idTodo가 만들어질 때마다 현재 날짜와 시간으로 자동 생성됩니다.


🧩 App.tsx

가장 메인이 되는 파일로 컴포넌트들이 렌더링됩니다.

// App.tsx
import { useState } from 'react';

import NewTodo from './components/NewTodo';
import Todos from './components/Todos';
import Todo from './models/todo';

function App() {
  const [todos, setTodos] = useState<Todo[]>([]);/

  const addTodoHandler = (todoText: string) => {
    const newTodo = new Todo(todoText);
    setTodos((prevTodos) => {
      return prevTodos.concat(newTodo);
    });
  }

  const removeTodoHandler = (todoId: string) => {
    setTodos((prevTodos) => {
      return prevTodos.filter((todo) => todo.id !== todoId);
    })
  };

  return (
    <div>
      <NewTodo onAddTodo={addTodoHandler} />
      <Todos items={todos} onRemoveTodo={removeTodoHandler} />
    </div>
  );
}

export default App;

📍 useState로 Todo[] 상태 관리하기

const [todos, setTodos] = useState<Todo[]>([]);

react가 제공하는 useState를 사용하여 Todo 배열의 상태를 관리합니다.

이 때 useState([]);로 빈 배열만 넘겨줄 경우에는 에러가 발생합니다.
빈 배열을 넘김으로써 Todo가 어떤 타입의 값을 가지는지가 모호해지기 때문입니다.

따라서 제네릭함수인 useState는 제네릭으로 Todo[] 라는 타입을 가진다고 알려주어야 합니다.


📍 Todo를 추가하는 함수

  const addTodoHandler = (todoText: string) => {
    const newTodo = new Todo(todoText);
    setTodos((prevTodos) => {
      return prevTodos.concat(newTodo);
    });
  }

파라미터로 받은 todoText를 가진 newTodo를 생성합니다.

그 후 setState로 이전의 todo list인 prevTodosnewTodo를 더하여 할 일을 추가합니다.


📍 Todo를 삭제하는 함수

  const removeTodoHandler = (todoId: string) => {
    setTodos((prevTodos) => {
      return prevTodos.filter((todo) => todo.id !== todoId);
    })
  };

동일하게 setStateTodo를 삭제하는 코드입니다.

이전의 Todo[]들이 담긴 prevTodos에서 삭제할 todo의 id값인 todoIdTodo[]에서의 id가 다른 것들로 새 배열을 만들어줍니다.

이것으로 Todo삭제를 구현할 수 있습니다.


📍 NewTodo, Todos 컴포넌트 배치하기

  return (
    <div>
      <NewTodo onAddTodo={addTodoHandler} />
      <Todos items={todos} onRemoveTodo={removeTodoHandler} />
    </div>
  );
}
  • NewTodo : 새 Todo를 생성하는 form입니다.
  • Todos : 생성된 Todo[]를 보여주는 list입니다.

NewTodo에서 생성버튼을 누르면 onAddTodo가 실행되며 전달된 함수인 addTodoHandler를 통해 Todo가 생성됩니다.

Todos에서 각각의 Todoitems라는 이름으로 전달되며 todos를 클릭할 시 onRemoveTodo가 실행되며 전달된 함수인 removeTodoHandler를 통해 클릭된 todos가 삭제됩니다.


🧩 Todos.tsx

TodoItem 컴포넌트가 렌더링되어 TodoList를 볼 수 있습니다.

// components/Todos.tsx
import React from 'react';
import Todo from '../models/todo';
import TodoItem from './TodoItem';

import classes from './Todos.module.css';

const Todos: React.FC<{ items: Todo[]; onRemoveTodo: (id: string) => void }> = (props) => {

    return (
        <ul className={classes.todos}>
            {props.items.map((item) => (
                <TodoItem
                    key={item.id}
                    text={item.text}
                    onRemoveTodo={props.onRemoveTodo.bind(null, item.id)}
                />
            ))}
        </ul>
    );
}

export default Todos;

📍 React.FC란?

여기서 중요한 것은 React.FC입니다.

React.FC : 리액트 패키지에 정의된 타입으로 이를 통해 이 함수가 함수형 컴포넌트로 동작한다는 사실을 명확히 할 수 있습니다.

또한 React.FC는 제네릭 타입입니다.
만약 React.FC를 타입으로 가지는 함수가 props와 같은 프로퍼티를 가진다면 해당 프로퍼티의 타입을 제네릭으로 정의할 수 있습니다.

const Todos: React.FC<{ items: Todo[]; onRemoveTodo: (id: string) => void }> = (props) => {
  • Todos는 props와 같은 파라미터를 받습니다.
  • 이 때 파라미터는 타입이 Todo[]items와, 파라미터 id의 타입이 string이고 반환 타입이 void인 함수 onRemoveTodo가 있습니다.

📍 TodoItem 컴포넌트 리스트로 렌더링하기

TodoItem 컴포넌트를 불러와 ul>li list형식으로 TodoList 나타냅니다.

    return (
        <ul className={classes.todos}>
            {props.items.map((item) => (
                <TodoItem
                    key={item.id}
                    text={item.text}
                    onRemoveTodo={props.onRemoveTodo.bind(null, item.id)}
                />
            ))}
        </ul>
    );
  • className : css파일의 className을 의미합니다.

Todosprops에는 Todo[]가 담긴 items가 존재합니다.
itemsmap을 이용해 각각의 값 하나씩을 item이라는 이름으로 불러와 Todo class의 속성인 idtext를 각각 불러옵니다.
불러온 idtextTodoItem 컴포넌트에 전달하여 Todo를 리스트로 보여줄 수 있게 됩니다.


📍 bind()란?

onRemoveTodo에는 TodoItem.tsx에서 TodoItem이 클릭되었을 때 동작하는 onRemoveTodo함수를 건네주는데 이 때 bind함수가 사용됩니다.

bind() : js에서 제공하는 메서드로 실행할 함수를 미리 설정할 수 있습니다.

보통 삭제하기 위해서는 id같은 고유한 값이 전달되어야 하는데 그냥 onRemoveTodo={props.onRemoveTodo}로만 전달해버리면 id를 받을 수 없고 그로 인해 삭제할 값이 무엇인지를 판단할 수 없습니다.

따라서 bind를 통해 item.id를 전달하는 것입니다.


🧩 NewTodo.tsx

사용자에게 입력창을 제공하고 사용자가 입력한 Todo의 내용을 가져올 수 있습니다.

// components/NewTodo.tsx
import { useRef } from 'react';// 레퍼런스 생성 가능

import classes from './NewTodo.module.css';

const NewTodo: React.FC<{onAddTodo: (text: string) => void }> = (props) => {
    const todoTextInput = useRef<HTMLInputElement>(null);

    const submitHandler = (event: React.FormEvent) => {/
        event.preventDefault();
 
        const enteredText = todoTextInput.current!.value;
        if ( enteredText.trim().length === 0 ) {
            return;
        }

        props.onAddTodo(enteredText);
    }

    return (
        <form onSubmit={submitHandler} className={classes.form}> 
            <label htmlFor="text">Todo text</label>
            <input type="text" id="text" ref={todoTextInput}/>
            <button>Add Todo</button>
        </form>
    );
}

export default NewTodo;

📍 useRef() 사용하기

const todoTextInput = useRef<HTMLInputElement>(null);

"react" 에서 레퍼런스를 생성하기 위해 제공하는 useRefhtmlinput 요소를 연결한 코드입니다.

  1. 제네릭으로 타입을 표현해주세요.

useRef에 제네릭으로 타입이 표현되어 있는 이유는 useRef만으로 이 레퍼런스가 input에 연결될지 button에 연결될지를 모르기 때문입니다.

useRef도 마찬가지로 제네릭 타입으로 정의되어 있습니다.

  1. 기본값을 설정해주세요.

이 레퍼런스에 다른 요소가 할당되어 있을 수 있기 때문입니다.
현재로서는 null이 들어가 있으나 값을 입력한 뒤 제출 버튼을 눌렀을 때는 todoTextInput에 해당 input이 제대로 들어가있을 것입니다.


📍 Form 제출 함수

사용자가 form의 submit button을 클릭했을 때 호출됩니다.

    // 폼 제출 함수
    const submitHandler = (event: React.FormEvent) => {
        event.preventDefault();

        const enteredText = todoTextInput.current!.value;/
        if ( enteredText.trim().length === 0 ) {
            return;
        }

        props.onAddTodo(enteredText);
    }

    const submitHandler = (event: React.FormEvent) => {
        event.preventDefault();

onSubmit 이벤트를 수신할 때, 즉 form을 제출했을 때 자동적으로 받게 됩니다.

preventDefault()로 자동 제출을 막을 수 있습니다.


📍 current? vs current!

        const enteredText = todoTextInput.current!.value;
        if ( enteredText.trim().length === 0 ) {
            return;
        }

        props.onAddTodo(enteredText);

todoTextInput.current!.value에는 input의 실제 텍스트 값이 들어가 있습니다.
이 때 기본적으로 ! 가 아닌 ? 가 처음으로 생기는데 이 둘의 차이를 알아봅시다.

? : 일단 값에 접근은 해보고 접근이 가능하면 입력된 값을 가져와서 enteredText에 저장해

  • 이 때 enteredText의 타입에는 undefined가 추가됩니다.

! : 이 값이 null이 될 수 있다는 것은 알지만 이 시점에서는 절대 null이 아니니까 입력된 값을 가져와서 enteredText에 저장해

  • null이 아님을 100% 확신할 때 사용해야 합니다.

enteredText에 공백을 제외한 입력값이 있을 경우에 props로 받은 onAddTodo함수에 입력된 텍스트를 전달합니다.

이렇게 새 Todo를 저장할 수 있습니다!


🧩 TodoItem.tsx

각각의 할 일들을 리스트요소로 담아 보여줍니다.

// components/TodoItem.tsx
import classes from './TodoItem.module.css'

const TodoItem: React.FC<{text: string, onRemoveTodo: () => void }> = (props) => {
    return (
        <li className={classes.item} onClick={props.onRemoveTodo}>{props.text}</li>
    );
}

export default TodoItem;

props에서 받아온 text를 보여주고 해당 list를 클릭했을 때 할 일을 삭제하는 onRemoveTodo를 실행하여 할 일을 삭제합니다.


🎨 CSS 파일 적용하기

css를 추가하여 조금 더 다듬어진 form을 구현해봅시다.

/* Todos.module.css */
.todos {
  list-style: none;
  margin: 2rem auto;
  padding: 0;
  width: 40rem;
}
/* NewTodo.module.css */
.form {
    width: 40rem;
    margin: 2rem auto;
    
  }
  
  .form label {
    display: block;
    font-weight: bold;
    margin-bottom: 0.5rem;
  }
  
  .form input {
    display: block;
    width: 100%;
    font: inherit;
    font-size: 1.5rem;
    padding: 0.5rem;
    border-radius: 4px;
    background-color: #f7f5ef;
    border: none;
    border-bottom: 2px solid #494844;
    border-bottom-right-radius: 0;
    border-bottom-left-radius: 0;
    margin-bottom: 0.5rem;
  }
  
  .form button {
    font: inherit;
    background-color: #ebb002;
    border: 1px solid #ebb002;
    color: #201d0f;
    padding: 0.5rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
  }
  
  .form button:hover,
  .form button:active {
    background-color: #ebc002;
    border-color: #ebc002;
  }
/* TodoItem.module.css */
.item {
  margin: 1rem 0;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
  padding: 1rem;
  background-color: #f7f5ef;
}


이렇게 "Todo text" input에 할 일을 적고 "Add Todo" 버튼을 클릭하면 할 일이 아래에 추가되며, 각각의 할 일을 클릭하면 삭제되는 기능을 구현하였습니다!


🚩 더 해보고 싶어요

몇 차례에 걸쳐 같은 프로퍼티를 계속 넘겨주는 것이 코드가 복잡해보였습니다.
그렇게 되니 자연스레 같은 타입을 여러 번 반복해서 작성하게 되니 쓸데없는 코드가 늘어나게 되었습니다.

나중에는 Context API를 사용하여 코드를 간결하게 만들어보고 싶습니다.


🔗 github 코드


profile
기록의 힘을 믿는 개발자, 노하은입니다!

0개의 댓글