직접 만들어보는 To Do List - Express.js + React.js + SQLite (5)

  1. Express.js 설치
  2. Express.js - Route 설정
  3. React 설치
  4. Backend API

Frontend

필요한 라이브러리부터 일단 설치하겠습니다.

npm i open-props react-hot-toast react-icons prop-types pretendard

최근 유행하는 tailwindcss 는 일단 배제하고,  open-props 를 사용하여 CSS를 작성했습니다.

open-props 에 대해서 궁금하신 분들은 아래 포스팅을 참고하시기 바랍니다.

Open Props - 지저분한 HTML을 만드는 Tailwindcss의 대안
OpenProps로 디자인토큰을 사용해 웹을 쉽고 빠르게 구축할 수 있습니다. CSS변수를 통해 TailwindCSS의 대안으로 사용하기에 좋은 기능들이 잘 준비되어있습니다.
  • react-hot-toast - Notification(알림) 컴포넌트 라이브러리로, 작업의 변화, 결과 등을 알려주는데 사용합니.
  • react-icons - 오픈소스 아이콘 라이브러리
  • prop-types - 부모로부터 전달 받은 props의 타입을 체크하는데 사용합니다.
  • pretendard - 오픈소스 폰트 라이브러리

지난 글에서 vite 로 리액트를 설치하면서 기본적으로 작성된 css 들 부터 먼저 정리해주겠습니다.

CSS

/* index.css */
:root {
  font-family: "Pretendard", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

index.css 파일에 많은 내용들이 있습니다만, 다 지우고 위의 내용만 남겨두겠습니다. Pretendard 폰트를 설치해주었기 때문에, font-family 부분에서 가장 앞에 추가해주는 작업만 해주면 됩니다.

App.css 는 바꿔야 할 내용이 상당히 많습니다. 전부 다 지우고, 아래 링크에서 간단하게 복사해서 붙여 넣으시기 바랍니다.

todo_list-express-react-sqlite/frontend/src/App.css at master · baehyunki/todo_list-express-react-sqlite
To Do List - Express.js + React.js + SQLite. Contribute to baehyunki/todo_list-express-react-sqlite development by creating an account on GitHub.

미리보기

비글 이미지는 OPEN AIDALL·E 3 를 이용해서 생성했습니다

완성 화면을 미리 보면 위와 같습니다.

주 된 기능은 할 일 입력, 내용 수정, 완료 유무 체크, 할 일 삭제 입니다. 결과에 따라서 react-hot-toast 라이브러리를 이용해 알림이 뜹니다.

전체적인 흐름은 App.jsx , ListItem.jsx , Form.jsx 컴포넌트에서 주된 작업들이 진행 될 것입니다.

App.jsx

App.jsx에서는 전체 할 일 목록을 불러옵니다. 백엔드 apiGET요청을 보낼 것이고, getTodos 컨트롤러를 통해 전체 목록을 뿌려줍니다.

import { useEffect, useRef, useState } from "react"
import { toast, Toaster } from "react-hot-toast"
import "./App.css"
import Form from "./components/Form.jsx"
import ListItem from "./components/ListItem.jsx"
import { GrGithub } from "react-icons/gr"
import Footer from "./components/Footer.jsx"
const App = () => {
  const [list, setList] = useState([]) // 빈 배열로 초기화된 list 상태를 생성
  const [value, setValue] = useState("") // 빈 문자열로 초기화된 value 상태를 생성
  const [edit, setEdit] = useState({ id: "", status: false }) // 빈 id와 false 상태를 가지는 객체로 초기화된 edit 상태를 생성
  const [loading, setLoading] = useState(true) // true로 초기화된 loading 상태를 생성
  const [error, setError] = useState({ message: "", status: false }) // false로 초기화된 error 상태를 생성
  const ref = useRef(null) // 요소에 대한 참조를 저장하기 위해 ref를 생성

  /**
   * @description `ref`로 참조된 요소에 포커스를 설정합니다.
   */
  const setFocus = () => {
    // `ref`로 참조된 요소에 포커스를 설정합니다.
    ref.current.focus()
  }

  // 컴포넌트가 마운트될 때 API 서버에서 데이터를 가져옵니다.
  useEffect(() => {
    fetch("http://localhost:5000/api/todos") // API 서버에서 데이터를 가져옵니다.
      .then((data) => data.json()) // 받은 데이터를 JSON으로 변환합니다.
      .then((result) => setList(result.todos)) // 받은 todos로 list 상태를 업데이트합니다.
      .catch((error) => setError({ message: error.message, status: true })) // fetch 작업 중 발생한 오류를 로그에 기록합니다.
      .finally(() => setLoading(false)) // fetch 작업이 완료되면 loading 상태를 false로 설정합니다.
  }, [])

  if (error.status) return <div>404</div>

  // 데이터를 가져오는 동안 로딩 메시지를 표시합니다.
  if (loading) {
    return <div>Loading...</div>
  }

  return (
    <>
      <div className={"container"}>
        {/* Form 컴포넌트를 렌더링합니다. */}
        <Form
          ref={ref}
          setFocus={setFocus}
          edit={edit}
          setEdit={setEdit}
          value={value}
          setValue={setValue}
          setList={setList}
        />

        {/* list가 비어있을 때 메시지를 표시합니다. */}
        {list.length === 0 && (
          <div
            style={{
              padding: "var(--size-fluid-1)",
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
            }}
          >
            <h2
              style={{
                width: "fit-content",
                textAlign: "center",
                fontSize: "var(--font-size-fluid-1)",
                lineHeight: "var(--line-height-fluid-1)",
                fontWeight: "var(--font-weight-6)",
                color: "var(--teal-5)",
              }}
            >
              새로운 할 일을 추가해주세요
            </h2>
            <img src="nodata.png" alt="empty" />
          </div>
        )}

        <ul>
          {/* list의 각 항목을 렌더링합니다. */}
          {list &&
            list.map((todo) => (
              <ListItem
                key={todo.id}
                todo={todo}
                edit={edit}
                setEdit={setEdit}
                list={list}
                setList={setList}
                setValue={setValue}
                setFocus={setFocus}
              />
            ))}
        </ul>
        {/* Toaster 컴포넌트를 렌더링합니다. */}
        <Toaster position={"bottom-right"} />
      </div>
      <Footer />
    </>
  )
}

export default App

Form.jsx

Form 컴포넌트에서는 updatecreate를 담당할 것입니다. POST 요청으로 createTodo 컨트롤러를 통해 새로운 할 일을 데이터베이스에 저장합니다.

그리고 수정이 발생했을 경우엔 PUT 요청으로 updateTodo 컨트롤러를 통해 데이터베이스에 접근해 해당 아이디와 일치하는 내용을 변경합니다.

import { forwardRef } from "react"
import { BiEdit, BiPlus } from "react-icons/bi"
import { toast } from "react-hot-toast"
import PropTypes from "prop-types"

/**
 * @description 특정 할 일 아이템을 나타내는 컴포넌트
 * @param {object} props - 컴포넌트에 전달되는 속성들
 * @param {object} props.todo - 할 일 아이템 객체
 * @param {Array} props.list - 할 일 목록 배열
 * @param {Function} props.setList - 할 일 목록을 업데이트하는 함수
 * @param {object} props.edit - 편집 상태를 나타내는 객체
 * @param {Function} props.setEdit - 편집 상태를 업데이트하는 함수
 * @param {Function} props.setValue - 입력 값(state)을 업데이트하는 함수
 * @param {Function} props.setFocus - 입력 필드에 포커스하는 함수
 * @returns {JSX.Element} 할 일 아이템을 나타내는 JSX 요소
 */
// eslint-disable-next-line react/display-name
const Form = forwardRef(
  ({ setFocus, edit, setEdit, setList, value, setValue }, ref) => {
    /**
     * @description 입력 형식을 위한 핸들러 함수
     * @param {Event} e - 이벤트 객체
     * @param {number} id - todo 아이디
     * @returns {void}
     */
    const submitHandler = (e, id) => {
      e.preventDefault() // 기본 폼 동작 방지

      // API 엔드포인트와 HTTP 메서드 설정
      const url = edit.status
        ? `http://localhost:5000/api/todos/${id}`
        : "http://localhost:5000/api/todos/create"
      const method = edit.status ? "PUT" : "POST"
      const headers = {
        "Content-Type": "application/json",
      }

      // 값이 비어있는지 확인
      if (value === undefined || value.trim().length === 0) {
        setFocus()
        return toast("값을 입력해주세요", { type: "error" })
      }

      // 엔터 키를 누르면 값 비우기
      e.key === "Enter" && setValue("")

      if (edit.status) {
        // todo 업데이트를 위해 PUT 요청
        return fetch(url, {
          method,
          headers,
          body: JSON.stringify({ id, value }),
        })
          .then((res) => res.json())
          .then((result) => {
            const { todos, message } = result

            // 수정 모드 토글
            setEdit((prevState) => {
              return { ...prevState, status: !prevState.status }
            })

            // todo 목록 업데이트
            setList(todos)

            // 값 비우기
            setValue("")

            // 성공 토스트 메시지 출력
            toast(message, { type: "success" })
          })
      } else {
        // todo 생성을 위해 POST 요청
        return fetch(url, {
          method,
          headers,
          body: JSON.stringify({
            value,
            completed: false,
          }),
        })
          .then((result) => result.json())
          .then((result) => {
            // 새로운 todo 목록에 추가
            setList((prevState) => [...prevState, result.todos])

            // 값 비우기
            setValue("")

            // 포커스 설정
            setFocus()

            // 성공 토스트 메시지 출력
            toast(`${value} 추가 성공`, { type: "success" })
          })
          .catch((error) => console.log(error))
      }
    }
    /**
     * 입력값에 따라 값을 변경하는 핸들러 함수입니다.
     * @param {Event} e - 이벤트 객체
     */
    const changeHandler = (e) => {
      // 입력값에 따라 값을 변경합니다.
      setValue(e.target.value)
    }

    return (
      <form
        className={"form-group"}
        onSubmit={(e) => submitHandler(e, edit.status ? edit.id : null)}
      >
        <label htmlFor="add-todo">
          {edit.status ? <BiEdit /> : <BiPlus />}
        </label>
        <input
          ref={ref}
          id="add-todo"
          type="text"
          onChange={changeHandler}
          value={value}
        />
        {edit.status ? (
          <button type="submit">수정</button>
        ) : (
          <button type="submit">추가</button>
        )}
      </form>
    )
  },
)

export default Form

Form.propTypes = {
  setFocus: PropTypes.func,
  edit: PropTypes.object,
  setEdit: PropTypes.func,
  setList: PropTypes.func,
  value: PropTypes.string,
  setValue: PropTypes.func,
}

ListItem.jsx

ListItem 컴포넌트에서는 할 일 종료 여부를 표시하는 check(completed) 와  할 일 삭제 - delete 를 수행합니다. API 서버로는 PATCH 요청으로 completeTodo , DELETE 요청으로 deleteTodo 를 수행하도록 연결해줍니다.

import { GiCancel } from "react-icons/gi"
import { BiEdit } from "react-icons/bi"
import { AiFillDelete } from "react-icons/ai"
import { toast } from "react-hot-toast"
import PropTypes from "prop-types"

/**
 * @description 특정 할 일 아이템을 나타내는 컴포넌트
 * @param {object} props - 컴포넌트에 전달되는 속성들
 * @param {object} props.todo - 할 일 아이템 객체
 * @param {Array} props.list - 할 일 목록 배열
 * @param {Function} props.setList - 할 일 목록을 업데이트하는 함수
 * @param {object} props.edit - 편집 상태를 나타내는 객체
 * @param {Function} props.setEdit - 편집 상태를 업데이트하는 함수
 * @param {Function} props.setValue - 입력 값(state)을 업데이트하는 함수
 * @param {Function} props.setFocus - 입력 필드에 포커스하는 함수
 * @returns {JSX.Element} 할 일 아이템을 나타내는 JSX 요소
 */
const ListItem = ({
  todo,
  list,
  setList,
  edit,
  setEdit,
  setValue,
  setFocus,
}) => {
  /**
   * @description 체크박스 클릭 이벤트를 처리하는 함수
   * @param {string} id - 할 일 아이템의 ID
   * @param {boolean} completed - 할 일 완료 여부
   */
  const checkHandler = (id, completed) => {
    fetch(`http://localhost:5000/api/todos/${id}`, {
      method: "PATCH",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        id: id,
        completed: !completed,
      }),
    })
      .then((res) => res.json())
      .then((result) => {
        const { todos, message } = result
        setList(todos)
        toast(
          message === "완료" ? "완료로 상태 변경" : "진행중으로 상태 변경",
          { type: "success" },
        )
      })
      .catch((error) => console.log(error))
  }

  /**
   * @description 삭제 버튼 클릭 이벤트를 처리하는 함수
   * @param {string} id - 할 일 아이템의 ID
   */
  const deleteHandler = (id) => {
    fetch(`http://localhost:5000/api/todos/${id}`, {
      method: "DELETE",
    })
      .then((res) => res.json())
      .then((result) => {
        const { todos, message } = result
        const getItem = list.find((todo) => todo.id === id)
        const filteredList = todos.filter((todo) => todo.id !== id)
        setList(filteredList)
        toast(getItem.title + message, { type: "success" })
      })
      .catch((error) => console.log(error))
  }

  return (
    <li key={todo.id}>
      <input
        id={todo.id}
        type="checkbox"
        checked={todo.completed}
        onChange={() => checkHandler(todo.id, todo.completed)}
      />
      <label htmlFor={todo.id}>{todo.title}</label>
      <div className="button-group">
        {edit.id === todo.id && edit.status ? (
          <button
            onClick={() => {
              setEdit((prevState) => {
                return { ...prevState, status: false }
              })
              setValue("")
            }}
          >
            <GiCancel color={"var(--orange-6)"} />
          </button>
        ) : (
          <button
            onClick={() => {
              setEdit((prevState) => {
                return {
                  id: todo.id,
                  title: todo.title,
                  status: !prevState.status,
                }
              })
              setFocus()
              setValue(todo.title)
            }}
          >
            <BiEdit color={"var(--teal-6)"} />
          </button>
        )}

        <button onClick={() => deleteHandler(todo.id)}>
          <AiFillDelete color={"var(--pink-6)"} />
        </button>
      </div>
    </li>
  )
}

export default ListItem

ListItem.propTypes = {
  todo: PropTypes.object,
  list: PropTypes.array,
  setList: PropTypes.func,
  edit: PropTypes.object,
  setEdit: PropTypes.func,
  setValue: PropTypes.func,
  setFocus: PropTypes.func,
}

Footer.jsx

footer 에서는 간단한 정보를 노출하는 것이 전부입니다.

import { GrGithub } from "react-icons/gr"
import style from "./Footer.module.css"

const Footer = () => {
  return (
    <footer className={style.footer}>
      <div className={style.info}>
        &copy; {new Date().getFullYear()} by
        <a
          href="https://github.com/baehyunki/todo_list-express-react-sqlite"
          target="_blank"
          rel={"noreferrer"}
          style={{ display: "flex" }}
        >
          baehyunki
          <GrGithub />
        </a>
      </div>

      <span className={style.desc}></span>
    </footer>
  )
}

export default Footer

Footer.module.css

module 방식으로 css 를 사용하였습니다.

.footer {
  display: flex;
  flex-direction: column;

  width: var(--size-content-2);
  margin: 0 auto;
  gap: 0.25rem;
}
.info {
  display: flex;
  font-size: var(--font-size-fluid-0);
  gap: var(--size-fluid-1);
  color: var(--stone-6);
}
.footer a {
  color: var(--indigo-5);
}
.desc {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  font-size: var(--font-size-fluid-0);
  color: var(--stone-5);
}

마무리

전반적으로 react 의 기본적인 사용법을 익힌 상태에서 손쉽게 쓸 수 있는 Prop Drilling 방식으로 각 컴포넌트와 통신하고 있습니다.

조금 더 여러개의 컴포넌트를 사용한다면 Context API 또는 Redux , Zustand 와 같은 상태관리를 도와주는 도구들을 사용해야겠지만, 아주 단순한 앱이기 때문에 부가적인 도구들을 사용하지 않았습니다.

다소 지저분하고 부끄러운 소스이기 합니다만, 저도 리액트를 공부하고 적용해보는 상황에서 학습한 것들을 공유하는 차원에서 작성했습니다.

GitHub - baehyunki/todo_list-express-react-sqlite: To Do List - Express.js + React.js + SQLite
To Do List - Express.js + React.js + SQLite. Contribute to baehyunki/todo_list-express-react-sqlite development by creating an account on GitHub.

흔히 MERN 스택이라고 불리는 구성과는 조금 다르지만, 큰 맥락에서는 많이 다르지 않습니다. 개인적으로는 학습할 때 단골처럼 등장하는 mongoosesequelize 와 같은 ORM 도구 없이 SQL 표준 문법을 사용했다는 점이 새로운 시도였습니다.