Recoil 찍먹 - ToDoList 만들기!

Clzzi·2021년 4월 22일
2

Recoil

목록 보기
2/2
post-thumbnail

📢 오늘은 React + Recoil + TypeScript 스택으로 ToDoList를 만들어 볼거에요!

만들기 전 제가만든 ToDoList의 파일구조는 이렇습니다!

📦 React + Recoil + Typescript (src)
├─ components
│  ├─ TodoInput
│  │  ├─ todoInput.tsx
│  │  ├─ todoInput.scss
│  │  └─ index.ts
│  ├─ TodoFilter
│  │  ├─ todoFilter.tsx
│  │  ├─ todoFilter.scss
│  │  └─ index.ts
│  ├─ TodoItem
│  │  ├─ todoItem.tsx
│  │  ├─ todoItem.scss
│  │  └─ index.ts
│  └─ TodoList
│     ├─ todoList.tsx
│     ├─ todoList.scss
│     └─ index.ts
├─ recoil
│  └─ todosState.ts
├─ types
│  └─ todoType.ts
├─ templates
│  ├─ todoTemplate.tsx
│  ├─ todoTemplate.scss
│  └─ index.ts
├─ app.tsx
└─ index.tsx

© generated by woochanlee

💻 Recoil설치 및 RecoilRoot 설정

Recoil을 설치하기 위해선 터미널에서 React폴더로 가서 아래 코드를 치면 됩니다 :)

$ Yarn add recoil 또는 npm install recoil

설치를 했다면 src/index.tsx에서 아래와 같이 설정해줍시다!

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { RecoilRoot } from "recoil";

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("root")
);

각 폴더의 index.tsx에는 아래와 같이 적어주시면 됩니다!

export { default } from "[컴포넌트명].tsx";


💻 Step Ⅰ

먼저 ToDoList를 위한 전역상태들을 Recoil로 만들어주고 그에 맞는 타입들을 만들어주겠습니다.

types/todoType.ts

export interface TodoType {
  id: number;
  done: boolean;
  contents: string;
}

export interface TodoPropTypes {
  data: TodoType;
  onDelete: (id: number) => void;
  onDone: (id: number) => void;
}
  • TodoType - 사용자로 부터 Todo를 받아올 때 쓰입니다.

  • TodoPropTyes - TodoItem.tsx에서 각 Todo에 쓰일 Props들의 타입입니다.

recoil/todosState.ts

import { atom, selector } from "recoil";
import { TodoType } from "types/todoType";

export const todosState = atom<TodoType[]>({
  key: "todosState",
  default: [
    {
      id: 1,
      done: true,
      contents: "레코일 공부하기!",
    },
    {
      id: 2,
      done: false,
      contents: "투두리스트 만들어보기!",
    },
    {
      id: 3,
      done: false,
      contents: "놀기!",
    },
  ],
});

export const todoInputState = atom<string>({
  key: "todoInputState",
  default: "",
});

export const filterTodosState = atom<string>({
  key: "filterTodosState",
  default: "All",
});

export const filterTodosSelector = selector({
  key: "filterTodosSelector",
  get: ({ get }) => {
    const todos = get(todosState);
    const filter = get(filterTodosState);

    switch (filter) {
      case "Done":
        return todos.filter((todo) => {
          return todo.done !== false;
        });
      case "UnDone":
        return todos.filter((todo) => {
          return todo.done !== true;
        });
      default:
        // All
        return todos;
    }
  },
});
  • todosState - 사용자로 부터 Todo를 받아와서 저장하는 atom입니다.
    추후 디자인할 때 편하게 하기위해 저는 default 값에 3개의 Todo를 임의로 넣어주겠습니다.

  • todoInputState - 사용자가 Todo를 입력할 때 그 값을 전역으로 관리해주기 위한 atom입니다.

  • filterTodosState - 사용자가 어떤 Todo를 볼것인지 정했을 때 그 값을 저장하는 atom입니다.
    default는 All(모두보기)으로 주겠습니다.

  • filterTodosSelector - 사용자가 정한 filter에 맞는 Todo들을 반환해주는 selector입니다.
    각 필터명에 맞게 Switch-Case를 써서 Todo를 반환했습니다.


💻 Step Ⅱ

이제 사용자가 어떤 Todo를 볼것인지 확인할 TodoFilter를 만들겠습니다.

components/TodoFilter/todoFilter.tsx

import { filterTodosState } from "recoil/todosState";
import { useSetRecoilState } from "recoil";
import "./todoFilter.scss";

const TodoFilter = (): JSX.Element => {
  const setFilter = useSetRecoilState(filterTodosState);

  const onChangeFilter = (e: any) => {
    const { value } = e.target;
    setFilter(value);
  };

  return (
    <select className={"todoSelector"} name="filter" onChange={onChangeFilter}>
      <option value="All">All</option>
      <option value="Done">Done</option>
      <option value="UnDone">UnDone</option>
    </select>
  );
};

export default TodoFilter;

onChangeFilter함수로 사용자가 select box에서 어떤 값을 선택했는지 확인하고 statestring으로 set해줍니다!

디자인은 이렇게 했습니다

components/TodoFilter/todoFilter.scss

@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR&display=swap");

.todoSelector {
  display: block;
  width: 80px;
  border: none;
  margin-top: -4px;
  margin-bottom: 8px;
  border-radius: 3px;
  font-family: "Noto Sans KR", sans-serif;
  padding-left: 3px;
  text-align-last: center;
  text-align: center;
  appearance: none;
  box-shadow: 0.8px 0.8px 3px grey;
}

💻 Step Ⅲ

타입atom & Selector를 다 만들었으니 이제 사용자가 Todo를 입력할 Input을 만들어 볼게요

components/TodoInput/todoInput.tsx

import { useRecoilState } from "recoil";
import { todosState, todoInputState } from "recoil/todosState";
import { TodoType } from "types/todoType";
import TodoFilter from "components/TodoFilter";
import { IoCheckmarkSharp } from "react-icons/io5";
import "./todoInput.scss";

const TodoInput = (): JSX.Element => {
  const [input, setInput] = useRecoilState<string>(todoInputState);
  const [todos, setTodo] = useRecoilState<TodoType[]>(todosState);

  const onChangeInput = (e: React.ChangeEvent<HTMLInputElement>):void => {
    const { value } = e.target;
    setInput(value);
  };

  const onEnter = (e: React.KeyboardEvent<HTMLInputElement>):  void => {
    if (e.key === "Enter") {
      if (input.length !== 0) {
        const id = todos.length ? todos[todos.length - 1].id + 1 : 0;

        const todo = {
          id,
          done: false,
          contents: input,
        };

        setTodo([...todos, todo]);
        setInput("");
      }
    }
  };

  const addTodo = ():void => {
    if (input.length !== 0) {
      const id = todos.length ? todos[todos.length - 1].id + 1 : 0;

      const todo = {
        id,
        done: false,
        contents: input,
      };

      setTodo([...todos, todo]);
      setInput("");
    }
  };

  return (
    <>
      <div className={"todoInput"}>
        <TodoFilter />
        <input
          className={"todoInput-Input"}
          type="text"
          value={input}
          onChange={onChangeInput}
          onKeyPress={onEnter}
          placeholder={"Write a ToDo"}
        />
        <div className={"todoInput-btn"} onClick={addTodo}>
          <IoCheckmarkSharp className={"todoInput-icon"} />
        </div>
      </div>
    </>
  );
};

export default TodoInput;

onChangeInput함수로 사용자가 Input창에 값을 입력하면 자동으로 state에 값이 넣어지고,
onEnter함수 및 addTodo함수로 사용자가 엔터를 누르거나 입력버튼을 눌렀을 때 Todo가 생성되게 했습니다.

디자인은 아래와 같이 했습니다.

components/TodoInput/todoInput.scss

.todoInput {
  margin: 20px;
  &-Input {
    padding: 0px;
    margin: 0px;
    width: 345px;
    height: 53px;
    font-size: 1.5rem;
    justify-content: center;
    text-align: center;
    align-items: center;
    border: none;
    border-radius: 3px;
    outline: none;
    padding: 5px;
    box-shadow: 0.8px 0.8px 3px grey;
  }

  &-Input::-webkit-input-placeholder {
    text-align: center;
  }

  &-btn {
    margin-left: 12px;
    color: white;
    justify-content: center;
    text-align: center;
    line-height: 54px;
    font-size: 1.3rem;
    width: 64px;
    height: 54px;
    display: inline-block;
    cursor: pointer;
    background-color: #5c7cfa;
    border-radius: 5px;
    box-shadow: 1.2px 1.2px 3px gray;
  }

  &-icon {
    margin-top: -5px;
    vertical-align: middle;
    width: 28px;
    height: 28px;
  }
}


💻 Step Ⅳ

Input도 다 만들었으니 이제 각 Todo를 보여줄 TodoItem 과 Todo들 모두를 보여줄 TodoList를 만들겠습니다.

components/TodoList/todoList.tsx

import { useRecoilValue, useSetRecoilState, useRecoilState } from "recoil";
import { filterTodosSelector } from "recoil/todosState";
import TodoItem from "components/TodoItem";
import { todosState } from "recoil/todosState";
import { TodoType } from "types/todoType";
import "./todoList.scss";

const TodoList = (): JSX.Element => {
  const filteredTodos = useRecoilValue(filterTodosSelector);
  const [todos, setTodos] = useRecoilState<TodoType[]>(todosState);

  const onDelete = (id: number): void => {
    setTodos(
      todos.filter((todo) => {
        return todo.id !== id;
      })
    );
  };

  const onDone = (id: number): void => {
    setTodos(
      todos.map((todo) => {
        return todo.id === id ? { ...todo, done: !todo.done } : todo;
      })
    );
  };

  return (
    <div className={"todoitemList"}>
      {filteredTodos.map((todo) => {
        const data = {
          id: todo.id,
          done: todo.done,
          contents: todo.contents,
        };

        return <TodoItem data={data} onDelete={onDelete} onDone={onDone} />;
      })}
    </div>
  );
};

export default TodoList;

onDeleteonDone함수로 삭제 및 Todo를 클릭하면 done의 값을 반대로 바뀌게 했습니다.
todoList에서 map해서 리턴하는 todo들은 모두 사용자가 select box에서 정한 값에 따라 바뀝니다.
ex) All , Done, UnDone

components/TodoItem/todoItem.scss

import { TodoPropTypes } from "types/todoType";
import "./todoItem.scss";
import { TiDelete } from "react-icons/ti";

const TodoItem = ({ data, onDelete, onDone }: TodoPropTypes): JSX.Element => {
  const { id, done, contents } = data;

  return (
    <div className={done ? "todoItem-done" : "todoItem"}>
      <div
        className={done ? "todoItem-title-done" : "todoItem-title"}
        onClick={() => onDone(id)}

        {contents}
      </div>
      <div className={"todoItem-delete"} onClick={() => onDelete(id)}>
        <TiDelete className={"todoItem-delIcon"} />
      </div>
    </div>
  );
};

export default TodoItem;

TodoList에서 Props로 onDelete함수와 onDone함수, 각 todo의 data를 받아옵니다.
done값에 따라 className이 바뀌게 했고 todo를 클릭하면 onDone함수에 해당 Todo의 id를 파라미터로넘겨주고 삭제할시에는 onDelete함수에 id를 파라미터로 넣어 실행합니다.

디자인은 아래와 같이 했습니다.

components/TodoItem/todoItem.scss

@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR&display=swap");

.todoItem {
  display: flex;
  justify-content: space-between;
  text-align: center;
  align-items: center;
  font-size: 1.2rem;
  margin-bottom: 8px;
  background-color: rgba(116, 143, 252, 0.65);
  margin-left: 20px;
  margin-right: 20px;
  padding: 12px;
  border-radius: 4px;
  box-shadow: 1px 1px 3px grey;
  transition: all 0.5s;

  &-delIcon {
    cursor: pointer;
    width: 34px;
    height: 34px;
    transition: 0.7s;
  }

  &-delIcon:hover {
    color: white;
    transform: rotate(90deg);
    transform-origin: center center;
    transition: 0.7s;
  }

  &-delete {
    margin: 0px;
    padding: 0px;
    width: auto;
    height: auto;
  }

  &-title {
    font-family: "Noto Sans KR", sans-serif;
    font-weight: 500;
    cursor: pointer;
    margin-left: 40px;
  }

  &-title-done {
    font-family: "Noto Sans KR", sans-serif;
    font-weight: 500;
    cursor: pointer;
    margin-left: 40px;
    color: rgba(0, 0, 0, 0.5);
    text-decoration: line-through;
  }
}

.todoItem-done {
  display: flex;
  justify-content: space-between;
  text-align: center;
  align-items: center;
  font-size: 1.2rem;
  margin-bottom: 8px;
  background-color: rgba(255, 168, 168, 0.65);
  margin-left: 20px;
  margin-right: 20px;
  padding: 12px;
  border-radius: 4px;
  box-shadow: 1px 1px 3px grey;
  transition: 0.5s;
}

components/TodoList/todoList.scss

.todoitemList {
  height: 400px;
}

💻 Step ⅴ

이제 각 컴포넌트들을 모두 가져와서 TodoTemplate로 만들겠습니다!

templates/todoTemplate.tsx

import TodoInput from "components/TodoInput";
import TodoList from "components/TodoList";
import "./todoTemplate.scss";

const TodoTemplate = (): JSX.Element => {
  return (
    <div className={"todoTemplate"}>
      <TodoInput />
      <TodoList />
    </div>
  );
};

export default TodoTemplate;

templates/todoTemplate.scss

@import url("https://fonts.googleapis.com/css2?family=Nanum+Gothic+Coding&display=swap");

* {
  font-family: "Nanum Gothic Coding", monospace;
  user-select: none;
}

.todoTemplate {
  position: absolute;
  left: 50%;
  bottom: 0px;
  transform: translate(-50%, -50%);
  margin: 0px;
  padding: 0px;
  background-color: #dbe4ff;
  border-radius: 8px;
  box-shadow: 1px 1px 4px grey;
  overflow-y: scroll;
}


💻 Step Ⅵ

이제 마지막으로 App.tsx에서 가져와서 보여주겠습니다!

index.tsx

import TodoTemplate from "templates";

function App(): JSX.Element {
  return <TodoTemplate />;
}

export default App;

완성! 🔥🔥🔥

💜 마치면서. . . !

요즘들어 취업에 대한 고민이 많아지면서 저의 취약점이 무었인지 곰곰히 생각해봤습니다.
제 최대 취약점은 무에서 유를 창조하는것이라는것을 알게되었고,
이를 해결하기위해선 무언가를 공부하면 꼭 내 힘으로 결과물을 만들어야겠다고 생각해게 되었습니다.
투두리스트 말고도 앞으로 무언가를 더 만들어야겠어요! 😎

1개의 댓글

comment-user-thumbnail
2022년 9월 1일

덕분에 많은것을 알아갑니다! ㅎㅎ

답글 달기