직접 만들어보는 To Do List - Express.js + React.js + SQLite (5)
리액트에서 express의 API 서버와 통신하도록 컴포넌트 별로 정리하여 작성하였습니다. 가장 기본적인 CRUD를 구현하기 위해서 props drilling과 fetch api를 활용하여 최대한 외부 라이브러리에 의존하지 않고 기능을 구현하는데 집중하였습니다
Frontend
필요한 라이브러리부터 일단 설치하겠습니다.
npm i open-props react-hot-toast react-icons prop-types pretendard
최근 유행하는 tailwindcss
는 일단 배제하고, open-props
를 사용하여 CSS를 작성했습니다.
open-props
에 대해서 궁금하신 분들은 아래 포스팅을 참고하시기 바랍니다.
- 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
는 바꿔야 할 내용이 상당히 많습니다. 전부 다 지우고, 아래 링크에서 간단하게 복사해서 붙여 넣으시기 바랍니다.
미리보기
완성 화면을 미리 보면 위와 같습니다.
주 된 기능은 할 일 입력
, 내용 수정
, 완료 유무 체크
, 할 일 삭제
입니다. 결과에 따라서 react-hot-toast
라이브러리를 이용해 알림이 뜹니다.
전체적인 흐름은 App.jsx
, ListItem.jsx
, Form.jsx
컴포넌트에서 주된 작업들이 진행 될 것입니다.
App.jsx
App.jsx
에서는 전체 할 일 목록을 불러옵니다. 백엔드 api
에 GET
요청을 보낼 것이고, 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
컴포넌트에서는 update
와 create
를 담당할 것입니다. 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}>
© {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
와 같은 상태관리를 도와주는 도구들을 사용해야겠지만, 아주 단순한 앱이기 때문에 부가적인 도구들을 사용하지 않았습니다.
다소 지저분하고 부끄러운 소스이기 합니다만, 저도 리액트
를 공부하고 적용해보는 상황에서 학습한 것들을 공유하는 차원에서 작성했습니다.
흔히 MERN
스택이라고 불리는 구성과는 조금 다르지만, 큰 맥락에서는 많이 다르지 않습니다. 개인적으로는 학습할 때 단골처럼 등장하는 mongoose
나 sequelize
와 같은 ORM
도구 없이 SQL 표준 문법을 사용했다는 점이 새로운 시도였습니다.
Comments ()