Next.js와 recoil-persist로 Todo앱 만들기

jonyChoiGenius·2023년 4월 25일
3

React.js 치트 시트

목록 보기
16/22

Recoil은 매우 간결한 상태관리 툴이다.

'하나의 원천'을 강조하는 Redux와는 달리
여러 Atom으로 상태를 관리한다.

이러한 Atom은 React에서 사용하는 'useState'에서의 상태와 일맥상통하며,
결과적으로 React의 함수형 컴포넌트에 잘 녹아드는 문법으로 코드를 작성할 수 있는 장점이 생긴다.

Redux가 강력한 Devtools를 제공하고 있고, Redux-toolkit을 이용하여 Slice의 개념(Recoil의 Atom과 비슷한 개념)을 도입하고, Actions를 쉽게 관리할 수 있게 되었다는 점에서 매우 쉽고 매력적인 상태관리 툴이 되었지만,
Recoil은 Redux-toolkit보다 여전히 쉬운 문법을 가지는 한편, React를 각 컴포넌트가 아닌 거대한 하나의 App으로 다루며 다양한 Hook을 쓸 수 있다는 점이 매력적으로 다가온다.

Recoil에서 필요한 문법은 3가지이다.

  1. const [todoList, setTodoList] = useRecoilState(todoListState);
    useRecoilState는 react의 useState와 마찬가지로 state와 setState함수를 반환한다.

  2. const todoList = useRecoilValue(todoListState);
    useRecoilValue는 단방향으로서, 상태를 읽을 때 사용한다.

  3. const setTodoList = useSetRecoilState(todoListState);
    useSetRecoilState는 state의 값을 변경하는 setState 함수 역할을 한다.

todo앱 만들기

atom 만들기

/store/atoms.ts에 아래와 같이 atom을 만든다.
atom({key: 값, default: 값}) 의 형식으로 생성하게 되는데,
이때 key는 atom을 구분하는 고유한 값으로, 고유한 문자열을 입력하면 된다.
default는 initial state의 역할을 한다.

import { atom } from "recoil";

//atom({key:, default:})로 새로운 아톰을 만들 수 있다.
// 이때 key는 각 아톰을 구별하는 고유한 식별자이다.
// default는 initial state를 의미한다.
export const todoListState = atom({
  key: "Todos",
  default: [],
});

투두 리스트 만들기

todo는 id, text, completed를 가지고 있다.

pages/todos.tsx에 아래와 같이 작성한다.

위에서 만든 atom을 useRecoilState로 불러오고,
나머지는 기존 리액트의 방식대로 하면 된다.

import React, { useEffect } from "react";
import { useRecoilState } from "recoil";
import { todoListState } from "../store/atoms";
import TodoItemCreator from "../components/TodoItemCreator";

function Todos() {
  const [todoList, setTodoList] = useRecoilState(todoListState);

  const toggleTodo = (data) => {
    setTodoList((todoList) =>
      todoList.map((todo) =>
        todo.id === data.id ? { ...data, completed: !data.completed } : todo,
      ),
    );
  };

  const removeTodo = (data) => {
    setTodoList((todoList) => todoList.filter((todo) => todo.id !== data.id));
  };
  
  return (
    <div>
      <div>todos</div>
      <TodoItemCreator />

      {todoList.map((todoItem) => (
        <div key={todoItem.id}>
          <div role="button" onClick={() => toggleTodo(todoItem, setTodoList)}>
            {todoItem.text}
          </div>
          <div> {todoItem.completed ? "완료됨" : ""} </div>
          <div role="button" onClick={() => removeTodo(todoItem, setTodoList)}>
            삭제하기
          </div>
        </div>
      ))}
    </div>
  );
}

할 일 생성하기

components\TodoItemCreator.tsx에 아래와 같이 todo를 생성할 수 있게 한다.

useSetRecoilState함수에 콜백 함수를 넘기는 경우, setState와 동일하게 첫번째 파라미터로 현재의 상태를 전달한다.

이를 이용하여 useRecoilValue나 useRecoilState 없이도 상태를 변경할 수 있다.

import React, { useState } from "react";
import { useSetRecoilState } from "recoil";
import { todoListState } from "../store/atoms";

function TodoItemCreator() {
  const [inputValue, setInputValue] = useState("");
  //useSetRecoilState로 상태를 수정할 수 있다.
  const setTodoList = useSetRecoilState(todoListState);
  const addItem = () => {
    setTodoList((prev) => [
      ...prev,
      { id: getId(), text: inputValue, isComplete: false },
    ]);
    setInputValue("");
  };

  const onChange = ({ target: { value } }) => {
    setInputValue(value);
  };

  return (
    <div>
      <div>todoItemCreator</div>
      <input type="text" value={inputValue} onChange={onChange} />
      <button onClick={addItem}>추가하기</button>
    </div>
  );
}

export default TodoItemCreator;

//id가 중복되지 않도록 만드는 기능 함수
let id = 0;
function getId() {
  return id++;
}

상태 변경 함수를 분리하기

Redux는 상태 변경 함수(actions)를 따로 분리하여 관리할 수 있었다.
Recoil에서도 이러한 방식을 사용하고 싶다면, 아래의 방법을 사용할 수 있다.

순수 함수 사용하기

value와 setState를 받는 순수 함수를 사용하여 상태를 업데이트한다.
store\atoms.ts에서 아래와 같은 함수를 만들고 export 한다.

export const toggleTodo = (data, setTodoList) => {
  setTodoList((todoList) =>
    todoList.map((todo) =>
      todo.id === data.id ? { ...data, completed: !data.completed } : todo,
    ),
  );
};

export const removeTodo = (data, setTodoList) => {
  setTodoList((todoList) => todoList.filter((todo) => todo.id !== data.id));
};
selector와 custom Setter사용하기

recoil의 selector는 상태 함수를 단방향으로 가져오는 역할을 한다.
vue.js의 computed와 같은 역할을 한다고 생각하면 좋다.
하지만 recoil의 selector는 get함수 뿐만 아니라 'set'함수도 제공한다.

해당 set 함수를 이용하여 상태를 변경하는 함수를 만들 수 있으며,
이 원리를 이용해 상태 변경 함수를 중앙 집중적으로 관리할 수 있다.

import { atom, selector } from 'recoil';

export const todoListState = atom({
  key: 'todoListState',
  default: [],
});

export const todoListSelector = selector({
  key: 'todoListSelector',
  get: ({ get }) => get(todoListState),
  set: ({ set }, newValue) => set(todoListState, newValue),
});

export const addTodo = selector({
  key: 'addTodo',
  set: ({ get, set }, newTodo) => {
    const todoList = get(todoListSelector);
    const updatedTodoList = [...todoList, newTodo];
    set(todoListSelector, updatedTodoList);
  },
});

export const removeTodo = selector({
  key: 'removeTodo',
  set: ({ get, set }, todoId) => {
    const todoList = get(todoListSelector);
    const updatedTodoList = todoList.filter((todo) => todo.id !== todoId);
    set(todoListSelector, updatedTodoList);
  },
});

export const toggleTodo = selector({
  key: 'toggleTodo',
  set: ({ get, set }, todoId) => {
    const todoList = get(todoListSelector);
    const updatedTodoList = todoList.map((todo) =>
      todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
    );
    set(todoListSelector, updatedTodoList);
  },
});
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { todoListState, addTodo, removeTodo, toggleTodo } from './todoState';

function TodoList() {
  const [newTodo, setNewTodo] = useState('');
  const [todoList, setTodoList] = useRecoilState(todoListState);

  const handleSubmit = (event) => {
    event.preventDefault();
    if (!newTodo.trim()) return;
    addTodo({ id: Date.now(), text: newTodo.trim(), completed: false });
    setNewTodo('');
  };

  const handleRemoveTodo = (todoId) => {
    removeTodo(todoId);
  };

  const handleToggleTodo = (todoId) => {
    toggleTodo(todoId);
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input type="text" value={newTodo} onChange={(event) => setNewTodo(event.target.value)} />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todoList.map((todo) => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
              onClick={() => handleToggleTodo(todo.id)}
            >
              {todo.text}
            </span>
            <button onClick={() => handleRemoveTodo(todo.id)}>X</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

recoil-persist 사용하기

import { recoilPersist } from "recoil-persist";

const { persistAtom } = recoilPersist();

export const todoListState = atom({
  key: "Todos",
  default: [],
  effects_UNSTABLE: [persistAtom],
});

기본적인 사용법은 recoilPersist를 통해 persistAtom를 불러오고, 이를 effects_UNSTABLE에 추가해주면 된다.

문제는 next.js에서는 hydration 이슈가 발생한다.

새로고침 시에, SSR에서는 default값 ([])으로 렌더링을 하지만, 클라이언트에서는 recoilPersist에 저장된 값으로 렌더링을 하게 된다. 이와 같은 불일치로 에러가 발생한다.

해당 에러를 해결하는 가장 쉬운 방법은,
SSR 렌더링이 끝났을 때에 recoilPersist의 값을 불러오는 것이다.
즉 useEffect 함수를 이용하여 recoilPersist를 주입하도록 하면 된다.

이를 recoil을 이용하여 패턴화 할 수 있는데,
persist 이펙트를 적용하지 않은 아톰을 false로 둔 후,
useEffect로 해당 값을 true로 만들어주는 것이다.

true로 만들어주는 작업이 끝났는지를 옵저빙한 이후, 끝났다면 persistAtom을 반환한다.

출처: 스택 오버플로우

import { AtomEffect, atom, useSetRecoilState } from "recoil";
import { recoilPersist } from "recoil-persist";

// next.js에서 사용하기 위해 ssr이 끝났는지를 확인하는 state이다.
// 새로고침 시에 항상 default값인 false를 갖는다.
const ssrCompletedState = atom({
  key: "SsrCompleted",
  default: false,
});

//useEffect에 쓰일 함수를 정의한다.
export const useSsrComplectedState = () => {
  const setSsrCompleted = useSetRecoilState(ssrCompletedState);
  return () => setSsrCompleted(true);
};

const { persistAtom } = recoilPersist();

//ssrCompletedState가 완료될 때까지 기다린 후, persistAtom을 반환한다.
export const persistAtomEffect = <T>(param: Parameters<AtomEffect<T>>[0]) => {
  param.getPromise(ssrCompletedState).then(() => persistAtom(param));
};

export const todoListState = atom({
  key: "Todos",
  default: [],
  effects_UNSTABLE: [persistAtomEffect],
});

해당 persist atom을 쓰는 컴포넌트에는 아래와 같이 useEffect를 추가해주면 된다.

  const setSsrCompleted = useSsrComplectedState();
  useEffect(setSsrCompleted, [setSsrCompleted]);

종합

store/atoms.ts는 아래와 같다.

import { AtomEffect, atom, useSetRecoilState } from "recoil";
import { recoilPersist } from "recoil-persist";

//Next.js에서 persistAtom을 쓰기 위한 구성
const ssrCompletedState = atom({
  key: "SsrCompleted",
  default: false,
});

export const useSsrComplectedState = () => {
  const setSsrCompleted = useSetRecoilState(ssrCompletedState);
  return () => setSsrCompleted(true);
};

const { persistAtom } = recoilPersist();

export const persistAtomEffect = <T>(param: Parameters<AtomEffect<T>>[0]) => {
  param.getPromise(ssrCompletedState).then(() => persistAtom(param));
};

// atoms 시작
//Todo타입을 정의한다.
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

//todoListState를 초기화한다.
export const todoListState = atom({
  key: "Todos",
  default: [] as Todo[],
  effects_UNSTABLE: [persistAtomEffect],
});

위에서 정의한 todoListState에 대한 상태 변경 함수들을
store/selectors.ts에 저장했다.

import { selector } from "recoil";
import { todoListState, Todo } from "./atoms";

export const todoListSelector = selector<Todo[]>({
  key: "todoListSelector",
  get: ({ get }) => get(todoListState),
  set: ({ set }, newValue) => set(todoListState, newValue),
});

export const addTodo = selector({
  key: "addTodo",
  get: ({ get }) => get(todoListSelector),
  set: ({ get, set }, newTodo) => {
    const todoList = get(todoListSelector);
    const updatedTodoList = [...todoList, newTodo];
    set(todoListSelector, updatedTodoList);
  },
});

export const removeTodo = selector({
  key: "removeTodo",
  get: ({ get }) => undefined,
  set: ({ get, set }, todoId) => {
    const todoList = get(todoListSelector);
    const updatedTodoList = todoList.filter((todo) => todo.id !== todoId);
    set(todoListSelector, updatedTodoList);
  },
});

export const toggleTodo = selector({
  key: "toggleTodo",
  get: ({ get }) => undefined,
  set: ({ get, set }, todoId: number) => {
    const todoList = get(todoListSelector);
    const updatedTodoList = todoList.map((todo) =>
      todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
    );
    set(todoListSelector, updatedTodoList);
  },
});

이때 1. selector는 getter 함수를 가지고 있어야 한다.
그리고 2. setter 함수는 getter 함수에서 반환하는 값과 타입이 일치해야 한다.

1문제를 해결하기 위해, 임의의 getter함수 ({ get }) => undefined를 설정한다.
그리고 2문제를 해결하기 위해, 해당 getter 함수는 undefined를 반환하도록 한다. (undefined가 아닌 경우에는, 해당 값과 newValue의 타입이 같아야 한다.)

위에서 만들어진 customSetter는 아래와 같이 사용할 수 있다.

  const toggleTodoList = useSetRecoilState(toggleTodo);
  const removeTodoList = useSetRecoilState(removeTodo);

이때 useSetRecoilState는 toggleTodo, removeTodo의 setter가 받는 값을 추론할 수 없다. 따라서 아래와 같이 명시해주는 것이 좋다.

  const toggleTodoList: (todoId: number) => void =
    useSetRecoilState(toggleTodo);
  const removeTodoList: (todoId: number) => void =
    useSetRecoilState(removeTodo);

이를 이용해서 todoApp을 완성시켜보자

import React, { useEffect } from "react";
import { useRecoilState, useSetRecoilState } from "recoil";
import { todoListState, useSsrComplectedState } from "../store/atoms";
import TodoItemCreator from "../components/TodoItemCreator";
import { removeTodo, toggleTodo } from "../store/selectors";

function Todos() {
  const [todoList, setTodoList] = useRecoilState(todoListState);
  const toggleTodoList: (todoId: number) => void =
    useSetRecoilState(toggleTodo);
  const removeTodoList: (todoId: number) => void =
    useSetRecoilState(removeTodo);

  const setSsrCompleted = useSsrComplectedState();
  useEffect(setSsrCompleted, [setSsrCompleted]);

  return (
    <div>
      <div>todos</div>
      <TodoItemCreator />

      {todoList.map((todoItem) => (
        <div key={todoItem.id}>
          <div role="button" onClick={() => toggleTodoSetter(todoItem.id)}>
            {todoItem.text}
          </div>
          <div> {todoItem.completed ? "완료됨" : ""} </div>
          <div role="button" onClick={() => removeTodoSetter(todoItem.id)}>
            삭제하기
          </div>
        </div>
      ))}
    </div>
  );
}

export default Todos;
profile
천재가 되어버린 박제를 아시오?

0개의 댓글