본문 바로가기
FE/React

React를 사용하여 TodoList 만들기

by aeyong-dev 2023. 9. 23.

백엔드에서 API가 주어졌고, 나는 React로 TodoList를 만드는 미니 프로젝트를 진행하였다.

요구사항은 다음과 같다.


  • 구현 내용
    • Data APIs를 제공해주는 React Router 활용하여 아래의 페이지들을 제작합니다
      • 페이지 설명
        • /: 홈페이지
        • /todos: todoList 정보를 확인할 수 있는 페이지
      • 페이지에 존재하는 기능
        • /
          • todo list 제목 작성
          • todo list 페이지로 이동하는 버튼 제작
        • /todos
          • CRUD 기능을 하는 버튼 4개 제작 (TodoList에 대한 로컬 상태관리)
            • Firebase Api 명세서를 참고하여 Firebase 연동 및 기능을 구현합니다
          • 뒤로가기 버튼 →/으로 이동합니다 (pop)
    • GET : 데이터 읽기
      • curl 'https://[SERVER_URL]/todos.json'
      • 성공적인 요청은 200 OK HTTP 상태 코드로 표시됩니다. 응답에는 GET 요청의 경로와 관련된 데이터가 포함됩니다.
      • [ { id: 0, text: '할 일 1, done: false } ]
    • PUT : 데이터 쓰기
      • curl -X PUT -d { id: 1, text: '할 일 2', done: false } \\ 'https://[SERVER_URL]/todos/{todoId}.json'
      • “성공적인 요청”은 200 OK HTTP 상태 코드로 표시됩니다. “응답”에는 PUT 요청에 지정된 데이터가 포함됩니다.
      • { id: 1, text: '할 일 2', done: false }
    • PATCH : 데이터 업데이트
      • curl -x PATCH -d { done: True } \\ 'https://[SERVER_URL]/todos/{todoId}.json'
      • “성공적인 요청”은 200 OK HTTP 상태 코드로 표시됩니다. “응답”에는 PATCH 요청에 지정된 데이터가 포함됩니다.
      • { done: True }
    • DELETE 데이터 삭제
      • curl -x DELETE 'https://[SERVER_URL]/todos/{todoId}.json'
      • 성공적인 DELETE 요청은 JSON null 포함하는 응답과 함께 200 OK HTTP 상태 코드로 표시됩니다.
      • 테이블이 삭제되더라도, 데이터 쓰기는 정상적으로 작동됩니다

 

https://github.com/YoonKeumJae/TodoList

 

GitHub - YoonKeumJae/TodoList

Contribute to YoonKeumJae/TodoList development by creating an account on GitHub.

github.com

 

쉽게 끝낼 수 있으리라 생각했지만.. 그것은 오만이었다. 

생각보다 더 고려할 것들이 있었고, 생각만큼 잘 되지 않았다. 

내가 아직 한참 더 공부해야 한다는 반증이겠지..

 

프로젝트 진행 순서는 다음과 같다. 

  1. 프로젝트 세팅
  2. 페이지 구성
  3. 라우팅 설정
  4. API 연결
  5. 스타일 적용
  6. 리팩토링

1. 프로젝트 세팅

프로젝트 생성: CRA

가장 먼저 CRA로 프로젝트를 생성하였다.

Vite을 쓰는 선택지도 있었지만, 익숙한 CRA로 개발하고 나중에 필요 시 리팩토링 하기로 했다. 

라우팅: react-router-dom

스타일: SCSS

Styled-components를 주로 사용했지만, 앞으로 진행하게 될 프로젝트에서 SCSS를 사용할 예정이라 연습삼아 사용했다. 

 

2. 페이지 구성

페이지는 메인 페이지, 리스트 페이지 두 장만 있었다. 

메인 페이지에서는 제목을 설정할 수 있고, 리스트 페이지로 이동하는 버튼이 있다.

리스트 페이지에서는 메인 페이지로 돌아가는 버튼이 있고, DB에 저장된 TodoList에 대한 CRUD기능을 사용할 수 있다. 

디렉토리 구조는 위와 같다. 

pages에는 페이지를 대표하는 컴포넌트가 있다. 

components에는 TodoList에서 하나의 항목을 나타내는 Todo, 버튼을 눌렀을 때만 보이는 TodoInput이 있다. 

index는 코드 작성의 효율을 위해 디렉토리 내 모든 컴포넌트를 import, 한꺼번에 export하였다. 

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/pages/Home.scss";

const Home = () => {
  const [title, setTitle] = useState("My TodoList");
  const [inputValue, setInputValue] = useState("");

  const handleTitle = (e) => {
    e.preventDefault();
    if (inputValue.trim() === "") {
      alert("공백은 입력 불가입니다");
      document.querySelector(".input__title").value = "";
      return;
    }
    setTitle(inputValue.trim());
    document.querySelector(".input__title").value = "";
  };

  const handleInput = (e) => {
    e.preventDefault();
    setInputValue(e.target.value);
  };

  const navigate = useNavigate();

  const moveToList = () => {
    navigate("/todos");
  };

  return (
    <div className="wrapper">
      <h1 className="title">{title}</h1>
      <div className="body__wrapper">
        <form className="inputForm">
          <input
            type="text"
            placeholder="Input title"
            onChange={handleInput}
            className="input__title"
          />
          <button type="submit" onClick={handleTitle} className="btn__title">
            OK
          </button>
        </form>
        <button type="button" onClick={moveToList} className="btn__move">
          Move to List
        </button>
      </div>
    </div>
  );
};

export default Home;

Home.js의 코드이다. 

useState를 사용하여 title을 사용자가 변경할 수 있게 하였다. 

useNavigate를 사용하여 버튼을 누를 시 '/todos'로 이동할 수 있게 하였다. 

공백을 입력할 경우, alert로 경고창을 띄워 예외처리를 하였다. 

또한 문자열 앞뒤로 공백을 없앴다. 

버튼이 눌리면 input 내부는 비어있게 된다. 

import { getTodos } from "../api";
import { useState, useEffect } from "react";
import { Todo, TodoInput } from "../components";
import { v4 as uuidv4 } from "uuid";
import { useNavigate } from "react-router-dom";
import "../styles/pages/Todos.scss";

const Todos = () => {
  const [todos, setTodos] = useState([]);
  const [formVisible, setFormVisible] = useState(false);

  useEffect(() => {
    const updateData = async () => {
      try {
        const res = await getTodos();
        if (res === null) {
          const newId = uuidv4();
          return [
            ...todos,
            { id: newId, text: "아직 아무것도 없어요!", done: false },
          ];
        }
        setTodos(res);
      } catch (e) {
        console.error(e);
      }
    };
    updateData();
  }, [todos]);

  const viewForm = () => {
    setFormVisible(!formVisible);
  };

  const navigate = useNavigate();

  return (
    <div className="wrapper">
      <h1 className="title">Todos</h1>
      <button type="button" onClick={() => navigate("/")}>뒤로가기</button>
      <div className="form">
        <button type="button" onClick={viewForm} className="btn__newTask">
          ➕ Click to add a new task!
        </button>
        {formVisible ? <TodoInput /> : null}
        <div className="todoList">
          {Object.values(todos).map((todo) => (
            <div key={todo.id}>
              <Todo todo={todo} />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default Todos;

Todos.js의 코드이다. 

useNavigate로 홈 화면으로 돌아가는 버튼을 구현하였다. 

또한 useState를 사용하여 새 todo를 입력하는 컴포넌트 TodoInput을 토글 버튼으로 보이게, 또는 보이지 않게 할 수 있다. 

객체의 배열인 todos를 map함수로 리스트의 todo 하나를 가리키는 컴포넌트 Todo를 생성하였다. 

API 사용과 관련된 코드는 나중에 설명하겠다. 

import { putTodos } from "api";
import { useState } from "react";
import { v4 as uuidv4 } from "uuid";
import "../styles/components/TodoInput.scss";

const TodoInput = () => {
  const [todo, setTodo] = useState("");

  const todoChangeHandler = (e) => {
    setTodo(e.target.value);
  };

  const newId = uuidv4();

  const onClickSubmit = (e) => {
    if (todo === "") {
      alert("공백은 입력 불가입니다");
      return;
    }
    e.preventDefault();
    putTodos({
      id: newId,
      text: todo.trim(),
      done: false,
    });
    document.querySelector(".input__text").value = "";
  };

  return (
    <form className="form">
      <input
        type="text"
        placeholder="I have to do..."
        onChange={todoChangeHandler}
        className="input__text"
      ></input>
      <button type="submit" onClick={onClickSubmit}>
        OK
      </button>
    </form>
  );
};
export default TodoInput;

TodoInput.js의 코드이다. 

홈 화면과 마찬가지로 입력 후 버튼을 눌렀을 때의 예외처리를 해주었다. 

import { useState } from "react";
import { patchTodos, deleteTodos } from "api";
import "../styles/components/Todo.scss";

const Todo = ({ todo }) => {
  const [done, setDone] = useState(todo.done);

  const onStatusEdit = (e) => {
    e.preventDefault();
    patchTodos({
      id: todo.id,
      text: todo.text,
      done: !done,
    });
    setDone(!done);
  };

  const onDelete = (e) => {
    e.preventDefault();
    deleteTodos(todo.id);
  };

  return (
    <div className="wrapper__todo">
      <div className="todo">
        {done ? (
          <span onClick={onStatusEdit}>✅</span>
        ) : (
          <span onClick={onStatusEdit}>❎</span>
        )}
        <span className="task">{todo.text}</span>
        <button type="button" onClick={onDelete} className="btn__delete">
          Delete
        </button>
      </div>
    </div>
  );
};

export default Todo;

마지막으로 Todo.js이다. 

리스트의 하나를 가리키는 컴포넌트 이다. 

처음에는 useState로 onStatusEdit을 작성하였지만(const [isDone, setIsDone] = useState(false);) API를 연결하면서 위와 같이 수정하게 되었다. 

3. 라우팅 설정

import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { Home, Todos } from "./pages";
import "./styles/App.scss"

const router = createBrowserRouter([
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/todos",
    element: <Todos />,
  },
]);

function App() {
  return (
    <div>
      <RouterProvider router={router} />
    </div>
  );
}

export default App;

App.js에서 react-router-dom의 기능을 사용하였다. 

createBrowserRouter로 객체의 배열을 생성하여 path와 element 속성을 줬다. 

4. API 연결

API를 연결하는 과정이 제일 험난했다...

먼저 api.js를 만들고 api 관련 함수들을 작성하였다. 

import api_addr from "apiKey";

const getTodos = async () => {
  const response = await fetch(`${api_addr}todos.json`);
  const data = await response.json();
  return data;
};

const putTodos = async (todo) => {
  const response = await fetch(`${api_addr}todos/${todo.id}.json`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(todo),
  });
  if (response.ok) {
    return response.json();
  } else {
    console.log("error");
    throw new Error("Error");
  }
};

const patchTodos = async (todo) => {
  const response = await fetch(`${api_addr}todos/${todo.id}.json`, {
    method: "PATCH",
    body: JSON.stringify({ done: todo.done }),
  });
  if (response.ok) {
    return response.json();
  } else {
    throw new Error("Error");
  }
};

const deleteTodos = async (id) => {
  const response = await fetch(`${api_addr}todos/${id}.json`, {
    method: "DELETE",
    headers: {
      "Content-Type": "application/json",
    },
  });
  if (response.ok) {
    return response.json();
  } else {
    throw new Error("Error");
  }
};

export { getTodos, putTodos, patchTodos, deleteTodos };

요청을 보낼 때는 headers에 "나 json파일 보낼거야" 라는 메세지를 보내야한다. 

코드가 잘 작동하지 않아 chatGPT에게 물어보고 나서야 헤더를 붙이지 않았다는 것을 깨닫게 되었다..

api 함수를 작성했으니, 기능이 필요한 컴포넌트에 api를 연결할 차례이다. 

  useEffect(() => {
    const updateData = async () => {
      try {
        const res = await getTodos();
        if (res === null) {
          const newId = uuidv4();
          return [
            ...todos,
            { id: newId, text: "아직 아무것도 없어요!", done: false },
          ];
        }
        setTodos(res);
      } catch (e) {
        console.error(e);
      }
    };
    updateData();
  }, [todos]);

이 코드는 Todos.js의 일부이다. 

처음에는 useEffect의 의존성 배열로 빈 배열을 줬다. 

이 컴포넌트가 처음 렌더링이 될 때만 이 함수를 실행하고 싶었기 때문이다. 

하지만 todos 배열이 수정된 후에 사용자가 일일이 새로고침을 눌러야 리스트가 업데이트 되었다. 

이 문제는 useEffect의 의존성 배열에 todos를 추가해주니 해결되었다. 

todos가 변경될 때 마다 이 기능을 수행하는 것이다. 

이렇게 하면 처음 마운트 될 때는 아무것도 동작하지 않는 줄 알았는데 아니었다. 

import { v4 as uuidv4 } from "uuid";
...
const newId = uuidv4();
...
putTodos({
      id: newId,
      text: todo.trim(),
      done: false,
    });

TodoInput.js의 일부분이다. 

putTodos는 새로운 Todo를 추가하는 함수이다. 

각각의 Todo는 중복되지 않는 특정한 id값을 가져야 한다. 

처음에는 todos.length+1로 해서 id값을 주려고 했지만, 생각대로 잘 되지 않았다.

한참을 고민하다가 chatGPT에게 물어봤는데, uuid를 사용하면 된다고 했다. 

이렇게 해서 id에 대한 문제가 해결되었지만, Todo의 인덱스 처리가 되지 않는다는 문제가 새로 생겼다

코드를 직접 실행시켜보면 알겠지만, Todo를 추가했을 때 순서대로 추가되는 것이 아니라 자기 멋대로 추가된다. 

id값을 기준으로 TodoList에 정렬되는데, 이 id값은 랜덤으로 정해지기 때문이다. 

이 문제는 아직 고치지 못했고 추후 수정 예정이다. 

const onStatusEdit = (e) => {
    e.preventDefault();
    patchTodos({
      id: todo.id,
      text: todo.text,
      done: !done,
    });
    setDone(!done);
  };
  
const onDelete = (e) => {
    e.preventDefault();
    deleteTodos(todo.id);
};

Todo.js의 일부이다. 

Todo에 대한 완료 상태(done)를 바꾸는 버튼을 누르면 patchTodos를 통해 데이터를 수정한다. 

삭제 버튼을 누르면 todo.id를 전달해 데이터를 삭제한다. 

 

..이제 돌아보니 정말 쉬워보이지만 막상 할 때는 정말 골치아팠다.

실수로 id값을 주지 않은 채 put요청을 보내서 이상한 데이터를 가득 채우기도 했다.

get요청으로 받은 배열을 순회하며 delete해서 문제를 해결할 수 있었다. 

5. 스타일 적용

평소에는 styled-components를 사용하여 스타일을 작성하였지만, 다음에 진행 예정인 프로젝트에서 SASS를 사용할 예정이라 연습삼아 사용해보았다. 

잘 했다고 생각한 것이, SASS에서는 클래스의 이름이 중복돼서는 안됐다. 

styled-components에서는 컴포넌트 마다 wrapper, body 등의 클래스 이름을 썼지만 SASS에서는 중복된 클래스 이름을 사용하지 않기 위해 머리를 써야했다. 

6. 리팩토링

홈 페이지의 input 태그는 예외처리가 되어있지 않는다거나, api 링크를 숨겨놓지 않았다던가 하는 등의 사항들을 수정하였다. 

이후로 Todo의 index 관련 문제, 최적화 문제 등을 수정할 예정이다. 

실행영상