[React] todolist를 만들며 배운 것

sjoleee·2022년 8월 7일
0

투두리스트 앱을 만드는 여정을 끝마쳤다.
공부하면서 전부 구현한 이후, 0부터 다시 만들어볼 것이다.
처음부터 만드는 과정을 전부 기록해야지..
코드도 싹다 붙여넣어둘 생각이다

요구사항

  • todo 등록, 삭제가 가능할 것
  • 할것(to do), 하는 중(doing), 끝(done)으로 카테고리를 나눌 것
  • 카테고리별로 나누어서 볼 수 있게 구현할 것
  • todo의 카테고리를 변경할 수 있게 할 것
  • localstorage를 활용하여 새로고침해도 todo를 기억하게 할 것

1. react-hook-form으로 todo form 만들기

//App.tsx

import React from "react";
import CreateToDo from "./components/CreateToDo";

function App() {
  return (
    <div>
      <CreateToDo />
    //먼저 todo를 입력할 수 있는 form에 해당하는 컴포넌트를 만든다.
    </div>
  );
}

export default App;
//CreateTodo.tsx

import { useForm } from "react-hook-form";

function CreateToDo() {
  const { register } = useForm();

  return (
    <form>
      <input
        {...register("todo", { required: "입력해주세요" })}
  //name지정, required: true
        placeholder="할 일을 적어주세요"
      ></input>
      <button>등록</button>
    </form>
  );
}

export default CreateToDo;


간단한 input과 button을 만들어 주었다.
useForm이 제공하는 register method로 input에 name을 지정해주고, 필수값으로 만들어주었다.

이제 onSubmit을 통해 input에 입력된 값을 가져오자.

//CreateTodo.tsx

import { useForm } from "react-hook-form";

function CreateToDo() {
  const { register, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(onValid)}>
    //handleSubmit는 함수를 인자로 받는다. onValid 함수를 만들자.
      <input
        {...register("todo", { required: "입력해주세요" })}
        placeholder="할 일을 적어주세요"
      ></input>
      <button>등록</button>
    </form>
  );
}

export default CreateToDo;

handleSubmit은 함수를 인자로 받는데, 성공시 실행할 함수다.
우리는 validation이 성공한 이후, 생성하고자하는 todo의 내용, 고유한 id, 그리고 카테고리(할일, 하는중, 끝)를 담아두는 함수를 만들어야 한다.

const onValid = (data) => {
  console.log(data)
}

이렇게 작성해보자.
실행하면...?
에러가 발생할 것이다. 왜냐면 onValid의 data 타입이 정의되지 않았기 때문이다.
일단 any라고 해주고(data: any) 콘솔을 보자.

input에 할일 이라고 적고 실행하면 이렇게 나온다.
이전에 react hook form 글에서처럼, input에 담겨서 제출된 값들을 name: value로 객체에 담아 보여준다.

우리는 이제 사용자가 input에 적은 값이 무엇인지 알 수 있다.
data.todo가 바로 그것이다.
근데, 우리는 id와 category도 필요하니까 만들어보자.
id는 고유한 값으로 설정해주면 되는데, Date.now()를 사용하자.
category는... 일단 할일을 먼저 만들자. 하는중, 끝은 나중에 추가하고

이제 input에 todo를 적고 버튼을 누르면? id와 todo내용, category가 어딘가에 딱 저장되어야한다.
state를 만들자!

//atom.tsx

import { atom } from "recoil";

interface ITodo {
  id: number;
  text: string;
  category: string;
}

export const todoState = atom<ITodo[]>({
  key: "todo",
  default: [],
});

이렇게 todoState를 만들어줬다. 기본값은 빈배열이고, 거기에 todo객체들이 추가될 것이다.
onValid함수에서 setTodos로 입력받은 todo를 저장되게 만들어보자.

//CreateToDo.tsx

import { useForm } from "react-hook-form";
import { useRecoilState } from "recoil";
import { todoState } from "../atom";

interface IForm {
  todo: string;
}

function CreateToDo() {
  const { register, handleSubmit } = useForm<IForm>();
  const [todos, setTodos] = useRecoilState(todoState);

  const onValid = (data: IForm) => {
    setTodos((prev) => [
      { id: Date.now(), text: data.todo, category: "TO_DO" },
      ...prev,
    ]);
  };

  return (
    <form onSubmit={handleSubmit(onValid)}>
      <input
        {...register("todo", { required: "입력해주세요" })}
        placeholder="할 일을 적어주세요"
      ></input>
      <button>등록</button>
    </form>
  );
}

export default CreateToDo;


잘 작동한다!

2. todoState를 화면에 그려주기

등록된 todos를 화면에 그려주도록 하자.
ToDoList를 만들자.
근데 Todo가 맞나? ToDo가 맞나? 모르겠고.. 지금 막 혼용해서 사용하는 것 같은데, Todo로 통일하겠다.(저게 한 단어로 취급)

쨋든, 그럼 TodoList를 만들겠다.

//TodoList.tsx

import { useRecoilValue } from "recoil";
import { todoState } from "../atom";
import Todo from "./Todo";

function TodoList() {
  const todos = useRecoilValue(todoState);

  return (
    <ul>
      {todos.map((item) => {
        <Todo
          key={item.id}
          id={item.id}
          text={item.text}
          category={item.category}
        />;
      })}
    </ul>
  );
}

export default TodoList;

todo들이 저장된 state를 map을 사용해서 뿌려준다.
근데, 에러가 발생하는데...

      {todos.map((item) => {
        <Todo
          key={item.id}
          id={item.id} //error
          text={item.text}
          category={item.category}
        />;
      })}


props를 넘겨줄때 첫번째 prop에서 에러가 난다.
순서를 바꿔서 text가 첫번째면 text에서 에러가 나고...
해결방법은 알아냈으나, 원인은 모르겠다.

      {todos.map((item) => {
        <Todo key={item.id} {...item} />;
      })}

구조분해할당으로 작성하면 에러가 사라진다. 왜일까........
이 부분은 좀 더 공부해봐야겠다.

//Todo.tsx

import { ITodo } from "../atom";

function Todo({ id, text, category }: ITodo) {
  return <li>{text}</li>;
}

export default Todo;

이렇게 todo의 내용을 보여주도록 Todo컴포넌트를 만들었다.

아, 그리고 todo를 등록하고 나면 input이 초기화되게 하고싶다.
위에서 만들었던 onValid 함수에 한줄을 추가하자.

setValue("todo", "");

3. todo의 카테고리를 변경하는 버튼 만들기

할 일을 등록하고 나서, 하는중이나 끝으로 상태를 변경하는 버튼을 만들어보자.

    <>
      <li>{text}</li>
      <button>To Do</button>
      <button>Doing</button>
      <button>Done</button>
    </>

요런식인데.. 이제 클릭하면 그 버튼에 해당하는 category로 변경되게 만들어보자.

이 기능을 만들기 위해서 버튼마다 카테고리명과 동일하게 name을 만들어주었다.

    <>
      <li>{text}</li>
      <button onClick={onClick} name="TO_DO">
        To Do
      </button>
      <button onClick={onClick} name="DOING">
        Doing
      </button>
      <button onClick={onClick} name="DONE">
        Done
      </button>
    </>

onClick은 어떤 내용이 들어가야할까?
내가 클릭한 todo의 category를 button이 가진 name으로 바꿔주면 된다.
내가 어떤 todo를 클릭했는지는 어떻게 알 수 있을까?
index로 확인하자.

버튼을 클릭하면, findIndex로 todos를 순서대로 돌면서 클릭된 id와 각 todo의 id를 비교한다.
비교해서 동일한 id라면, 해당 todo의 index가 바로 클릭된 todo의 index다.

const targerIdx = todos.findIndex((todo) => todo.id === id);

그리고 클릭한 button의 name을 가져오자.

const {
      currentTarget: { name },
    } = event;

이제 category를 변경해야하는 todo가 어떤건지도 알고, category를 뭘로 변경해야하는지도 알고있다.
이제 setTodos를 통해 todoState를 변경해주자.

const [todos, setTodos] = useRecoilState(todoState);
  const onClick = (event: React.FormEvent<HTMLButtonElement>) => {
    const targerIdx = todos.findIndex((todo) => todo.id === id);
    
    const {
      currentTarget: { name },
    } = event;
    
    setTodos((prev) => {
      const copiedTodos = [...prev]; //기존 todos를 깊은복사
      copiedTodos.splice(targerIdx, 1, { id, text, category: name }); 
      //복사한 todos에서 버튼 클릭한 부분만 변경한다
      return copiedTodos; //todos를 copiedTodos로 변경한다
    });
  };


추가로, todo마다 버튼이 3개 다 달려있는게 조금 별로여서 수정해주겠다.

    <>
      <li>{text}</li>
      {category !== "TO_DO" ? (
        <button onClick={onClick} name="TO_DO">
          To Do
        </button>
      ) : null}
      {category !== "DOING" ? (
        <button onClick={onClick} name="DOING">
          Doing
        </button>
      ) : null}
      {category !== "DONE" ? (
        <button onClick={onClick} name="DONE">
          Done
        </button>
      ) : null}
    </>

이렇게하면 현재 카테고리에 해당하는 버튼은 출력되지 않는다.

4. select 태그를 활용해 category별로 나눠서 출력하기


이렇게 드롭다운으로 카테고리를 선택할 수 있고, 해당 카테고리에 해당하는 todo만 출력되게 만들 것이다.

//TodoList.tsx

    <>
      <select onInput={onInput}>
        <option value={"TO_DO"}>To Do</option>
        <option value={"DOING"}>Doing</option>
        <option value={"DONE"}>Done</option>
      </select>
      <ul>
        {todos.map((item) => (
          <Todo key={item.id} {...item} />
        ))}
      </ul>
    </>

이렇게 select 태그를 써서 option을 3개 만들었다.
선택했을때 어떤 행동을 할지 onInput 함수를 작성해보자.
그 전에, 어떤 식으로 구현할지 생각해보자.

먼저, 드롭다운에서 선택한 카테고리가 뭔지 state로 관리할 것이다.
그리고 그 state에 맞게 todoState를 수정할 수 있는 recoil의 selector를 사용한다.
TodoList에서는 수정된 결과값을 map으로 보여준다.

드롭다운에서 선택한 카테고리를 state에 담자.
먼저 state를 하나 만들어주자

//atom.tsx

export const categoryState = atom({
  key: "category",
  default: "TO_DO",
});

그리고 onInput 함수를 만들어주었다.

//TodoList.tsx

  const [category, setCategory] = useRecoilState(categoryState);

  const onInput = (event: React.FormEvent<HTMLSelectElement>) => {
    const {
      currentTarget: { value },
    } = event;
    setCategory(value);
  };

드롭다운을 선택할때마다 categoryState가 잘 변한다!

이제 이 categoryState를 기반으로 todoState의 값을 필터링해서 보여주자.
https://recoiljs.org/ko/docs/basic-tutorial/selectors
recoil 공식문서를 참고하자.

//atom.tsx

export const todoSelector = selector({
  key: "todoSelector",
  get: ({ get }) => {
    const todos = get(todoState);
    const category = get(categoryState);
    return todos.filter((item) => item.category === category);
    //todos에 있는 todo들 중에, 카테고리가 categoryState의 값과 동일한 todo들만 모아서 반환한다.
  },
});

get을 사용하는게 약간 어색하다. 저기 왜 중괄호에 get을 넣지?? 조금 이상하게 생겼네...
쨋든 get을 하면 해당 state의 값을 쓸 수 있다.
filter는 배열에서 특정 조건을 만족하는 요소만으로 새로운 배열을 만들어주는 함수다.
이제 이 todoSelector를 사용하면 된다.

map으로 todos를 돌면서 화면을 그려주고 있었는데, 아래처럼 todoState대신 todoSelector를 쓰면 드롭다운으로 선택한 값에 해당하는 todo들만 보인다.

 const todos = useRecoilValue(todoSelector);

아, 그리고 카테고리를 나눈 김에, 선택한 카테고리에 todo가 추가되도록 수정했다.

//CreateTodo.tsx

  const category = useRecoilValue(categoryState);

  const onValid = (data: IForm) => {
    setTodos((prev) => [
      { id: Date.now(), text: data.todo, category },
      ...prev,
    ]);
    setValue("todo", "");
  };

이제 드롭다운으로 doing을 선택하면 새로 생성되는 todo들의 카테고리가 doing으로 생성된다.

5. 삭제버튼 만들기

사실 삭제버튼은... 쉽다. todo의 카테고리를 변경하는 것과 거의 비슷하기 때문이다.
버튼을 클릭하면 해당 todo를 todoState에서 지워주면 된다.

//Todo.tsx

    <>
      <li>{text}</li>
      {category !== "TO_DO" ? (
        <button onClick={onClick} name="TO_DO">
          To Do
        </button>
      ) : null}
      {category !== "DOING" ? (
        <button onClick={onClick} name="DOING">
          Doing
        </button>
      ) : null}
      {category !== "DONE" ? (
        <button onClick={onClick} name="DONE">
          Done
        </button>
      ) : null}
      <button onClick={onDelete}>삭제</button>
//여기 삭제버튼을 하나 만들어주자.
    </>

onDelete 함수를 만들자.

//Todo.tsx

  const onDelete = (event: React.FormEvent<HTMLButtonElement>) => {
    const targetIdx = todos.findIndex((item) => item.id === id);
    setTodos((prev) => {
      const copiedTodos = [...prev];
      copiedTodos.splice(targetIdx, 1);
      return copiedTodos;
    });
  };

카테고리 변경하는 버튼때와 99.9999% 동일하다.

6. localstorage로 todo를 보존하자

새로고침해도 todo를 보존하기 위해 todoState를 localstorage에 담는다.
useEffect로 todoState가 변경될때마다 localstorage에 담아주도록 하자.
근데 어디다가 useEffect를 사용해야할지 잘 모르겠다.
어쩌피 모든 컴포넌트가 렌더링될거고, state도 어디서든 사용할 수 있으니...
아무데나 작성해도 되나??
생각해보니까 그걸 갖고 map돌면서 todo를 그려줄거니까 TodoList.tsx에다가 작성하는게 좋겠다.

//TodoList.tsx

import { useEffect } from "react";
import { useRecoilState, useRecoilValue } from "recoil";
import { categories, categoryState, todoSelector, todoState } from "../atom";
import Todo from "./Todo";

function TodoList() {
  const selectedTodo = useRecoilValue(todoSelector);
  //selector과 todoState를 모두 사용해야해서 변수명을 조금 수정해주었다
  const [category, setCategory] = useRecoilState(categoryState);
  const todos = useRecoilValue(todoState);
  //전체 todo목록을 담고있음

  useEffect(() => {
    localStorage.setItem("todos", JSON.stringify(todos));
  }, [todos]);
  //todoState에 변경이 생길때마다 로컬스토리지에 업데이트

  const onInput = (event: React.FormEvent<HTMLSelectElement>) => {
    const {
      currentTarget: { value },
    } = event;
    setCategory(value as categories);
  };

  return (
    <>
      <select onInput={onInput}>
        <option value={"TO_DO"}>To Do</option>
        <option value={"DOING"}>Doing</option>
        <option value={"DONE"}>Done</option>
      </select>
      <ul>
        {selectedTodo.map((item) => (
          <Todo key={item.id} {...item} />
        ))}
      </ul>
    </>
  );
}

export default TodoList;

아무리 로컬스토리지에 넣어놓는다고 해도 지금 로컬스토리지를 불러와서 사용하는 곳이 없기때문에 보존되지 않는다.
처음에는 map돌때 로컬스토리지를 가져와서 map돌려야하나.. 생각하기도 했는데, todoState의 기본값을 수정하는게 더 효율적이다.

//atom.tsx

export const todoState = atom<ITodo[]>({
  key: "todo",
  default: JSON.parse(localStorage.getItem("todos") || "[]"),
});

이렇게 todoState의 기본값을 그냥 []로 두는게 아니라,
로컬스토리지에서 꺼내서 그걸 기본값으로 하되, 만약 비어있으면(undefined) []를 기본값으로 줘라~ 라는 뜻이다.

7. enum으로 category를 보호하자

이제 기능은 전부 구현했다.
근데... category가 걍 "TO_DO" 이렇게 문자열로 사용되고 있다.
어디 오타라도 나면...
이걸 보호해주기 위해서 type을 사용하긴 했지만, 각종 name이나 value에는 여전히 문자열을 그대로 타이핑해주어야 한다는 문제가 있다.

type categories = "TO_DO" | "DOING" | "DONE";

typescript에서 제공하는 enum을 사용하면 이를 해결할 수 있다.
enum : 열거형 타입.
무엇을 열거하냐? 상수를 열거한다.
비슷한 분류의 상수들을 묶어놓기 위해 만드는 것.
예를들어 계절 : {봄, 여름, 가을, 겨울} 같은 것이다.
이렇게 만들어놓으면 계절.봄 이런식으로 사용할 수 있다. 보호받는 것.

//atom.tsx

export enum categories {
  "TO_DO",
  "DOING",
  "DONE",
}

이렇게 선언해주자.
이제 categoryState도 수정해주겠다.

export const categoryState = atom<categories>({
  key: "category",
  default: categories.TO_DO,
});

타입을 categories로 지정해주고, 기본값이 원래 TO_DO였는데 저렇게 표기법으로 수정해주었다. 이제 직접 TO_DO라고 타이핑하지 않아도 된다.

그런데, enum을 저렇게 선언해주면 문제가 발생한다.

//atom.tsx

export enum categories {
  "TO_DO", //0
  "DOING", //1
  "DONE", //2
}

categories.TO_DO의 값이 무엇일까?
0이다.
categories.DOING의 값이 무엇일까?
1이다.
이처럼, enum에서 점표기법으로 값을 호출하게되면 인덱스를 반환한다.
우리는 이 값을 item.category와 비교해야하는 경우도 있다.

export const todoSelector = selector({
  key: "todoSelector",
  get: ({ get }) => {
    const todos = get(todoState);
    const category = get(categoryState);
    return todos.filter((item) => item.category === category);
  },
});

todoSelector가 그것인데, filter함수를 들여다보자.
item을 돌면서 categoryState와 동일한 category를 가진 todo들을 반환한다는 뜻이다.
그런데, todo들은 "TO_DO" "DOING" "DONE" 이런 문자열로 된 categorty를 갖는 반면에, categoryState가 가진 값은 0, 1, 2라서 비교가 안된다.
우리는 이 문제를 enum에서 각 요소마다 값을 지정해줌으로써 해결할 수 있다.

export enum categories {
  "TO_DO" = "TO_DO",
  "DOING" = "DOING",
  "DONE" = "DONE",
}

이렇게 지정해준다면 categories.TO_DO의 값은 "TO_DO"가 된다.
이로써 모든 "TO_DO" "DOING" "DONE"를 categories.TO_DO, categories.DOING, categories.DONE로 대체할 수 있게 되었다.

이제 "TO_DO" "DOING" "DONE"가 사용된 곳들을 찾아다니면서 수정해주면 된다.

이로써 투두리스트 만들기 공부 + 복습을 마쳤다.
한번 만들고, 다시 만들때 되게 어려울 것 같다고 생각했는데 생각보다 수월하게 만들었다.
물론 블로그에 정리하느라 엄청 오래 걸리긴 했지만.. ㅋㅋㅋ
다음엔 뭘 만들어볼까

profile
상조의 개발일지

0개의 댓글