[React 입문 개인과제] Todo List 만들기

Habin Lee·2023년 11월 6일
3

※ 주말 포함 이틀에 걸쳐 만든 내용이라 스크롤이 길기 때문에 오른쪽 List에서 참고할 부분만 눌러서 보시길 추천드립니당😊

요약

  • React를 사용하여 Todo List를 만들 수 있다.

Todo List 제작 시 구현해야 할 기능

  1. UI 구현
  2. Todo 추가
  3. Todo 삭제
  4. Todo 완료 상태 변경(완료 <-> 진행중)

필수 요구 사항

  1. 제목과 내용을 입력하고, [추가하기] 버튼을 클릭하면 '진행중...'에 새로운 todo가 추가되고 제목 input과 내용 input은 다시 빈 값으로 바뀌도록 구성
  2. todo의 isDone 상태가 true이면 상태 버튼의 라벨을 '취소', isDone 상태가 false이면 라벨을 '완료'로 조건부 렌더링
  3. todo의 상태가 '진행중...'이면 위쪽에 위치하고, '완료'이면 아래쪽에 위치하도록 구현
  4. 레이아웃의 최대 넓이는 1200px, 최소 넓이는 800px로 제한하고, 전체 화면의 가운데로 정렬
  5. 컴포넌트 구조는 자유롭게 구현 - 반복되는 컴포넌트를 찾아서 직접 컴포넌트 분리해보기

오늘의 완성작

코드 뜯어보기

App.js

import { useState } from "react"
// 자식 컴포넌트 import
import Header from "./components/Header"
import Input from "./components/Input"
import TodoList from "./components/TodoList"

function App() {
  // 새로운 todolist 생성 폼(초기값은 빈 배열)
  const [todoList, setTodoList] = useState([])
  return (
    <div>
      <Header>🥰 Habin's Todo List 😉</Header>
      // Input 컴포넌트로 setTodoList 값 내보내기
      <Input setTodoList={setTodoList}></Input>
      // *** check 1 ***
      // TodoList 컴포넌트로 todoList와 setTodoList 값 내보내기
      <TodoList IsActive={false} todoList={todoList} setTodoList={setTodoList}></TodoList>
      <TodoList IsActive={true} todoList={todoList} setTodoList={setTodoList}></TodoList>
    </div>
  )
}
// 컴포넌트 내보내기(export default)
export default App

문제점 - check 1

  • Todo 완료 상태를 변경(완료 <-> 진행중)하는 코드짤 때 시간을 굉장히 많이 잡아먹었다.
    : TodoList 컴포넌트를 하나만 작성하니 완료버튼을 눌렀을 때 어떻게 카드를 옮겨야할지 감이 안잡혀서 구글링만 5시간을 한 것 같다.

해결방법 - check 1

  • TodoList 컴포넌트를 두 번 불러와서 isDone 상태로 각각 나눠적었다.
    : 우연히 보게 된 유튜브에서 정렬하는 내용의 공통점을 찾아 따로 적는 것을 보았다. isDone의 상태에 따라서 위쪽에 위치할지, 아래쪽에 위치할지 구분할 수 있어서 IsActive의 값을 불리언 값으로 주고 따로 코드를 작성하였다.

Header.jsx/header.css

// Header.jsx
// css import
import 'header.css';

function Header({children}) {
  return (
    <h1 className='header'>{children}</h1>
  )
}

export default Header

// header.css
.header {
  padding: 0.5rem;
  color: rgb(0, 110, 255);
}
  • header는 간단해서 강의에서 배웠던 props children을 사용해봤는데, 안쓰다가 쓰면 헷갈릴것같다..

Input.jsx/input.css

// Input.jsx
import { useState } from "react"
// 중복되지 않는 유일한 값으로 id 값을 주기 위해 사용 - 아래의 import를 꼭 해줘야함
import { v4 as uuidv4 } from "uuid"
import 'input.css';

// setTodoList 값 가져오기
function Input({setTodoList}) {
  // 할일 제목 작성 폼
  const [title, setTitle] = useState('')
  // 할일 내용 작성 폼
  const [contents, setContents] = useState('')
  // *** check 2 ***
  const onSubmit = (event) => {
    // 폼 새로고침 방지
    event.preventDefault();
    // 입력 값이 빈 값일 경우 alert
    if (title === '' || contents === '')
    return alert('제목과 내용을 모두 입력해주세요');
    const newTodo = {
      // *** check 3 ***
      id: uuidv4(),
      title,
      contents,
      isDone: false,
    }
    // 입력 값을 제대로 가져오는지 체크하기 위한 console.log
    console.log(newTodo)
    // prev(전의 값) 뒤에 가져와서 새로운 todo를 아래에 붙임
    setTodoList((prev) => [...prev, newTodo]);
    // todo 추가 후에는 제목 란과 내용 란을 빈 값으로 만들기
    setTitle('');
    setContents('');
  }
  // 제목 입력 값 가져오는 함수
  const onChangeTitle =(event) => {
    setTitle(event.target.value)
  }
  // 내용 입력 값 가져오는 함수
  const onChangeContents =(event) => {
    setContents(event.target.value)
  }
  return (
    <form onSubmit={onSubmit} className="input-form">
    제목&ensp;<input className="input" type='text' value={title} onChange={onChangeTitle} placeholder='제목을 작성해주세요.' />&ensp;
    내용&ensp;<input className="input" type='text' value={contents} onChange={onChangeContents} placeholder='내용을 작성해주세요.' />&ensp;
    <button className="add-btn">추가하기</button>
  </form>
  )
}

export default Input

// input.css
.input-form {
  padding: 1rem;
  color: rgb(3, 82, 185);
  font-weight: bold;
  border-color: white;
  margin-bottom: 2.5rem;
}

.add-btn {
  background-color: white;
  color: rgb(3, 82, 185);
  font-weight: bold;
  padding: 0.3rem 2rem 0.3rem 2rem;
  border-radius: 7px;
  margin-left: 3rem;
  border-color: rgb(175, 217, 253);
}

// [추가하기] 버튼에 마우스 올리면 커서가 포인터로 변하고 색상도 변할 수 있도록 지정
.add-btn:hover {
  cursor: pointer;
  background-color: #b0d6fc;
  border-color: aliceblue;
}

.input { 
  background-color: white;
  height: 17px;
  width: 200px;
  padding: 0.4rem;
  border: 2px solid rgba(0, 0, 0, 0.23);
  border-radius: 7px;
}

// input 박스에 마우스를 올리면 테두리가 검은색으로 변하도록 지정
.input:hover {
  border: 2px solid black;
}

// input 박스를 누르면 아웃라인은 none으로 없애고 테두리는 파란색으로 변하도록 지정
.input:focus-visible  {
  outline: none!important;
  border: 2px solid #1976d2;
}
  • 강의에서 사용한 useState를 사용해봤다!
  • css 연결할 때, 상대경로를 쓰기가 귀찮아서 절대경로로 지정하여 사용했다.
    : 자세한 내용은 아래 게시글로 가서 제일 마지막 부분을 참고하면 된다.
    상대경로(./) import → 절대경로 지정하기

문제점 - check 2

  • [추가하기] 버튼을 눌렀을 때, 카드가 나타났다가 바로 사라진다.
    : 버튼을 누르지 않아도 엔터를 치면 내용이 넘어가도록 하기 위해 form 태그를 사용했는데, form의 기본 특성에 submit이 있다는 걸 처음 알아서 난감했었다. 사실 팀원분도 같은 문제를 가지고 있었는데, 다른 팀원분이 알려주셔서 form의 특성을 알게 됐다.

해결방법 - check 2

  • preventDefault 사용하기
    : form에 onSubmit 이라는 이벤트를 달아주고 event.preventDefault()을 return 해주면 강제 새로고침을 방지할 수 있다.

문제점 - check 3

  • 생성과 삭제를 많이 했을 때, 오류가 계속 났다.
    : 처음에 id값을 생성되는 순서대로 인덱스 값으로 줬었는데, 삭제하고 다시 생성을 했을 때, id 값이 중복되는 것을 발견했다. 그래서 하나를 삭제하면 여러 카드가 다 삭제 되거나 삭제가 되지 않는 등의 오류가 계속 일어났다.

해결방법 - check 3

  • uuid v4를 사용하여 id에 고유 값을 배정하였다.
    : uuid는 중복되지 않는 유일한 값을 구성하기 위해 많이 사용하는데, 보안성이 높고 생성속도가 빠른 UUID Version4를 많이 사용한다하고 한다.

    import를 꼭 해줘야함
    import { v4 as uuidv4 } from "uuid"

  • 추가로 uuid를 사용하지 않고 고유값을 만드는 방법도 있다.
    : uuid를 알기 전에 썼었는데, 이 방법도 괜찮은 것 같다.

  // 새로운 id값 생성하는 useState를 사용한다.
  const [idNumber, setIdNumber] = useState(0);
  // 새로운 todolist 생성 폼의 id 값에 uuidv4() 대신 idNumber를 넣어준다.
  id: idNumber,
  // 마지막 return에 setIdNumber 사용
  // 전의 요소(prev)에 +1 을 해주면 앞의 id값이 삭제되어도 새로 생성되는 id의 숫자는 계속 올라간다.
  setIdNumber((prev) => prev + 1);

TodoList.jsx/todo.css

  • css는 해당 컴포넌트(TodoList.jsx)에 사용한 코드만 가져옴
// TodoList.jsx
import Todo from "./Todo"
import 'todo.css';

/* IsActive, todoList, setTodoList 값 가져오기
   setTodoList값은 Todo 컴포넌트에서 사용하기 위해 TodoList 컴포넌트에서는 전달 역할만 해주고 있다.
   이것을 prop drilling 이라고 부른다.*/
function TodoList({IsActive, todoList, setTodoList}) {
  return (
    <div>
      // *** check 1 ***
      // IsActive가(? 조건) true이면 완료(:의 왼쪽)를, false이면 진행중(:의 오른쪽)을 표시
      <h2>{IsActive ? "완료🎉" : "진행중...🐇"}</h2>
      // *** check 4 ***
      <div className='todo-list'>
        /* filter 함수를 이용하여 todoList에 있는 isDone의 값과 IsActive의 값이 일치하면
           map으로 만들어진 각각에 해당하는 카드를 위쪽으로 or 아래쪽으로 배치시킨다. */
        {todoList.filter(item => item.isDone === IsActive)
         // map 함수를 사용하여 새로 만들어진 배열을 화면에 뿌리기
        .map(item=>{return(
          // Todo 컴포넌트에 item, IsActive, setTodoList 값 내보내기
          <Todo item={item} IsActive={IsActive} setTodoList={setTodoList}></Todo>
        )})}
      </div>
    </div>
  )
}

export default TodoList

// todo.css
.todo-list {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;
  /* 아래의 css도 비슷한 결과를 나타내지만, 결정적으로 wrap이 되지 않아 사용하지 않았다.
     만약 브라우저 창이 작아질 때 줄바꿈되는 현상이 싫다면 flex-wrap: nowrap;을 사용하거나
     아래의 grid 코드를 사용하면 된다. */
  /* display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr; <- 1fr의 숫자만큼 카드가 옆으로 붙는다.
  place-items: center; */
}

문제점 - App.js에 있던 check 1

  • 진행중 부분과 완료 부분을 어떻게 나눠야할지 감을 잡지 못했다.

해결방법 - App.js에 있던 check 1

  • 삼항연산자(IsActive ? "완료🎉" : "진행중...🐇") 사용
    : isDone의 값이 불리언인 점을 활용하여 삼항연산자로 깔끔하게 해결했다!

문제점 - check 4

  • 새로 만들어진 카드가 가로로 정렬이 되지 않았다.
    : 4시간을 삽질하게 한 css..... css에 flex, grid, float까지 안써본 게 없었다. 아무리 해도 가로 정렬이 되지 않아 머리 싸매고 있던 차에 팀원분이 내 코드를 clone 해서 알아봐주셨는데.... 알고보니 css가 문제가 아니라 className을 줄 div를 엉뚱한 곳에 붙여서 생긴 문제였다.

해결방법 - check 4

  • filter 함수를 div태그로 감싸 그 div에 className을 주었다.
    : 실질적으로 만들어지는 카드는 함수 부분이었기 때문에 내가 아무리 Todo 컴포넌트에 있는 카드 완성본 div 태그에 className을 붙여도 정렬이 되지 않았던 것이다...... 하... 알고 나니 너무 허무해...

Todo.jsx/todo.css

  • css는 해당 컴포넌트(Todo.jsx)에 사용한 코드만 가져옴
// Todo.jsx
import 'todo.css';

// item, IsActive, setTodoList 값 가져오기
function Todo({item, IsActive, setTodoList}) {
  // 삭제 기능 이벤트
  const btnDelete = () => {
    /* prev로 전에 있던 값들을 가져와 filter를 사용하여 입력값을 't'에 담고
       't'의 id값이 TodoList 컴포넌트의 item의 id값과 일치하지 않으면 삭제
       -> 삭제를 눌렀을 일치하는 카드들만 반환하는 형식 */
    setTodoList(prev => prev.filter((t) => t.id !== item.id));
    }
  // 완료-취소 기능 이벤트
  const btnIsDone = () => {
    /* prev로 전에 있던 값들을 가져와 map을 사용하여 입력값을 't'에 담고
       't'의 id값이 TodoList 컴포넌트의 item의 id값과 일치하면 isDone을 반대로 바꾸기 */
    setTodoList((prev) =>
      prev.map((t) => {
        if (t.id === item.id) {
          // 전에 있던 값을 가져와서 스프레드로 복사해와서 isDone 상태만 반대로 바꿔 카드 생성
          return {...t, isDone: !t.isDone}
        } else {
          // 그렇지 않으면 전에 있던 값을 그대로 반환
          return t;
        }
      })
    );
  }
  return (
    <div className="todo">
      // input 값을 가져오기 - React에서 JS의 값을 가져올 때는 꼭 {} 중괄호 안에 넣어 가져오기!
      <h3>{item.title}</h3>
      <p>{item.contents}</p>
      <div className='todo-btn-box'>
        <button className='todo-btn' onClick={btnDelete}>삭제</button>
        // IsActive가 true이면 '취소'버튼을, false이면 '완료'버튼을 출력
        <button className='todo-btn' onClick={btnIsDone}>{IsActive ? "취소" : "완료"}</button>
      </div>
    </div>
  )
}

export default Todo

// todo.css
.todo {
  width: 250px;
  border-radius: 15px;
  box-shadow: 4px 6px 16px 0 rgb(75, 127, 173);
  padding: 0.2rem 1rem 1rem 1rem;
  margin-bottom: 1.5rem;
  margin-right: 1.1rem;
}

.todo-btn {
  background-color: white;
  color: rgb(3, 82, 185);
  font-weight: bold;
  padding: 0.3rem 2rem 0.3rem 2rem;
  margin-left: 0.5rem;
  margin-right: 0.5rem;
  border-radius: 7px;
  border-color: rgb(175, 217, 253);
}

.todo-btn:hover {
  cursor: pointer;
  background-color: #b0d6fc;
  border-color: aliceblue;
}

.todo-btn-box {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
}

문제점 - 해당 컴포넌트(Todo.jsx) 전체

  • 삭제와 완료(취소) 버튼 이벤트 자체가 너무 구현하기가 어려웠다.
    : 해당 이벤트에 filter와 map 함수를 넣는 건 알았지만 어떻게 적어야하는지 감이 1도 잡히지 않았다.

해결방법 - 해당 컴포넌트(Todo.jsx) 전체

  • prev를 사용한 이벤트 정리
    : 나는 prev가 들어가는 곳에 자꾸 정해진 어떠한 값을 넣으려고 해서 골머리를 앓았다. 우연히 prev를 알게 되어 넣고 나니 앞에서 따로 값을 가져오지 않아도 되고 너무 간편했다.
    사실 이 부분은 조금 더 공부를 해봐야 알 것 같다. 배열 메서드를 100% 이해하지 못한 상태에서 보니 100% 이해하는 데에는 한계가 있었다.

전체 코드

  • 각 컴포넌트 별로 주석처리 함

CSS 코드

// index.css
body {
  padding: 0 2rem 1rem 2rem;
  margin-left: auto;
  margin-right: auto;
  min-width: 800px;
  max-width: 1200px;
  background-color: rgb(240, 248, 255);
}

// header.css
.header {
  padding: 0.5rem;
  color: rgb(0, 110, 255);
}

// input.css
.input-form {
  padding: 1rem;
  color: rgb(3, 82, 185);
  font-weight: bold;
  border-color: white;
  margin-bottom: 2.5rem;
}

.add-btn {
  background-color: white;
  color: rgb(3, 82, 185);
  font-weight: bold;
  padding: 0.3rem 2rem 0.3rem 2rem;
  border-radius: 7px;
  margin-left: 3rem;
  border-color: rgb(175, 217, 253);
}

.add-btn:hover {
  cursor: pointer;
  background-color: #b0d6fc;
  border-color: aliceblue;
}

.input { 
  background-color: white;
  height: 17px;
  width: 200px;
  padding: 0.4rem;
  border: 2px solid rgba(0, 0, 0, 0.23);
  border-radius: 7px;
}

.input:hover {
  border: 2px solid black;
}

.input:focus-visible  {
  outline: none!important;
  border: 2px solid #1976d2;
}

// todo.css
.todo {
  width: 250px;
  border-radius: 15px;
  box-shadow: 4px 6px 16px 0 rgb(75, 127, 173);
  padding: 0.2rem 1rem 1rem 1rem;
  margin-bottom: 1.5rem;
  margin-right: 1.1rem;
}

.todo-list {
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-wrap: wrap;
}

.todo-btn {
  background-color: white;
  color: rgb(3, 82, 185);
  font-weight: bold;
  padding: 0.3rem 2rem 0.3rem 2rem;
  margin-left: 0.5rem;
  margin-right: 0.5rem;
  border-radius: 7px;
  border-color: rgb(175, 217, 253);
}

.todo-btn:hover {
  cursor: pointer;
  background-color: #b0d6fc;
  border-color: aliceblue;
}

.todo-btn-box {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
}

JSX 코드

// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import 'index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

reportWebVitals();

// App.js
import { useState } from "react"
import { v4 as uuidv4 } from "uuid"
import Header from "./components/Header"
import Input from "./components/Input"
import TodoList from "./components/TodoList"

function App() {
  const [todoList, setTodoList] = useState([])
  return (
    <div>
      <Header>🥰 Habin's Todo List 😉</Header>
      <Input setTodoList={setTodoList}></Input>
      <TodoList IsActive={false} todoList={todoList} setTodoList={setTodoList}></TodoList>
      <TodoList IsActive={true} todoList={todoList} setTodoList={setTodoList}></TodoList>
    </div>
  )
}

export default App

// Header.jsx
import 'header.css';

function Header({children}) {
  return (
    <h1 className='header'>{children}</h1>
  )
}

export default Header

// Input.jsx
import { useState } from "react"
import { v4 as uuidv4 } from "uuid"
import 'input.css';

function Input({setTodoList}) {
  const [title, setTitle] = useState('')
  const [contents, setContents] = useState('')
  const onSubmit = (event) => {
    event.preventDefault();
    if (title === '' || contents === '')
    return alert('제목과 내용을 모두 입력해주세요');
    const newTodo = {
      id: uuidv4(),
      title,
      contents,
      isDone: false,
    }
    setTodoList((prev) => [...prev, newTodo]);
    setTitle('');
    setContents('');
  }
  const onChangeTitle =(event) => {
    setTitle(event.target.value)
  }
  const onChangeContents =(event) => {
    setContents(event.target.value)
  }
  return (
    <form onSubmit={onSubmit} className="input-form">
    제목&ensp;<input className="input" type='text' value={title} onChange={onChangeTitle} placeholder='제목을 작성해주세요.' />&ensp;
    내용&ensp;<input className="input" type='text' value={contents} onChange={onChangeContents} placeholder='내용을 작성해주세요.' />&ensp;
    <button className="add-btn">추가하기</button>
  </form>
  )
}

export default Input

// TodoList.jsx
import Todo from "./Todo"
import 'todo.css';

function TodoList({IsActive, todoList, setTodoList}) {
  return (
    <div>
      <h2>{IsActive ? "완료🎉" : "진행중...🐇"}</h2>
      <div className='todo-list'>
        {todoList.filter(item => item.isDone === IsActive)
        .map(item=>{return(
          <Todo item={item} IsActive={IsActive} setTodoList={setTodoList}></Todo>
        )})}
      </div>
    </div>
  )
}

export default TodoList

// Todo.jsx
import 'todo.css';

function Todo({item, IsActive, setTodoList}) {
  const btnDelete = () => {
    setTodoList(prev => prev.filter((t) => t.id !== item.id));
    }
  const btnIsDone = () => {
    setTodoList((prev) =>
      prev.map((t) => {
        if (t.id === item.id) {
          return {...t, isDone: !t.isDone}
        } else {
          return t;
        }
      })
    );
  }
  return (
    <div className="todo">
      <h3>{item.title}</h3>
      <p>{item.contents}</p>
      <div className='todo-btn-box'>
        <button className='todo-btn' onClick={btnDelete}>삭제</button>
        <button className='todo-btn' onClick={btnIsDone}>{IsActive ? "취소" : "완료"}</button>
      </div>
    </div>
  )
}

export default Todo

느낀점

처음에 컴포넌트부터 쭉 나눠놓고 시작했더니 적다가 너무 헷갈려서 코드를 다 갈아엎었다. 아직 자바스크립트도 다 이해하지 못했는데 리액트를 우겨 넣으려니 머릿속이 뒤죽박죽 되고 용량이 초과됐다 ㅠㅠ
아무 것도 없는 상태에서 시작할 때는 기본 모양부터 App.js에 쭉 적어놓고 그 컴포넌트에 맞는 내용으로 조금씩 쪼개고 props하는게 훨씬 편하고 보기도 좋다는 것을 깨달았다.
(물론 익숙해지면 바로 컴포넌트를 나눌 수 있겠지만..😂)
당연하겠지만 강의를 듣기만 할 때와 직접 해보는 것에는 차이가 있었는데, 아무리 예시 코드를 많이 쓰더라도 직접 구현해보는 것만큼 확실한 방법이 없었다. 코드를 다 짜고 나서도 나중에 보면 또 까먹을까봐 코드를 짠 후 내 코드를 내가 리뷰하는 시간을 가졌는데, 기억에 훨씬 잘 남아서 과제 후에 항상 해보면 좋을 것 같다.
더불어 전에는 완성한 코드만 올렸는데, 중간에 막혔던 부분을 추가하면 다음에 또 같은 상황이 오면 생각이 더 잘 떠오를 것 같아서 이번에는 컴포넌트마다 문제점과 해결법을 올려봤다. 써놓고 보니 생각보다 보기가 편한 것 같아서 앞으로도 이렇게 적으면 좋을 것 같다.
코린이로서 다른 블로그 내용을 참고할 때, 코드만 적혀있고 아래쪽에 따로 설명을 해놓으니 어느 부분인지 알아볼 수가 없어서 나는 코드에 주석을 덕지덕지 달아놨는데, 전체적인 코드가 보이지 않아서 답답하긴 하지만(그래서 전체 코드도 함께 올림 ㅎㅎ) 사실 아무 것도 모를 때는 이렇게 적는게 훨씬 이해하기가 쉬운 것 같다.

0개의 댓글