Redux-toolkit: Todo앱 만들기

jonyChoiGenius·2023년 1월 23일
0

Todo앱 만들기(중단)에서 중단했던 Todo앱을 Redux-toolkit으로 다시 만들고자 한다.

기본 셋팅은 영화앱2: 개발 환경을 따른다.

conponents/Header.tsx를 만든다

import React from "react";

const Header: React.FC = () => {
  return <h1>개쩌는 투두앱</h1>;
};

export default Header;
  1. _app.tsx에 추가해준다
function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <GlobalStyle />
      <Header />
      <Component {...pageProps} />
    </>
  );
}

export default App;
  1. componets/TodoList.tsx를 만든다
import React from "react";

const TodoList:React.FC = () => {
  return <div>TodoList</div>;
};

export default TodoList;
  1. index.tsx에 추가해준다
import Head from "next/head";
import TodoList from "../components/TodoList";

export default function Home() {
  return (
    <>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link
          rel="stylesheet"
          type="text/css"
          href="https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@2.0/nanumsquare.css"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <TodoList />
    </>
  );
}

잘 출력된다

  1. Todo 데이터에 쓰일 TodoType를 types/todo.d.ts에 추가해준다
export type TodoType = {
  id: number;
  text: string;
  color: "RED" | "ORANGE" | "YELLOW";
  checked: boolean;
};
  1. store/todoSlice.tsx에 새로운 슬라이스를 만든다.
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TodoType } from "../types/todo";

const todoSlice = createSlice({
  name: "todoSlice",
  initialState: {
    todos: [] as TodoType[]
  },
  reducers: {
    setTodo(state, action: PayloadAction<TodoType>){
    state.todos.push(action.payload)
  }
  }
});

export default todoSlice;

책의 예제 코드와는 다른 부분이 많은데
1. todos라는 프로퍼티를 선언할 때에 타입 앨리어싱으로 새로운 타입을 주입했다. - 리덕스 툴킷에서 이미 slice의 타입을 지정해두었기 때문에 타입 앨리어싱이 가능하다.
2. reducers는 state.todos를 가변적으로 다루었다. - 리덕스 툴킷에서 불변성을 지켜주기 때문에 가변적 액션이 가능하다.
3. 별도의 actions는 export하지 않았다.

  1. HYDRATE의 처리

서버사이드에서 사용되는 Store와 클라이언트사이드에서 사용되는 Store가 다르다. 서버사이드에서 Store를 변경하고, 이를 클라이언트 사이드로 주입하는 과정이 필요한데, next-redux-wrapper에서는 HYDRATE를 통해 이를 처리해준다.

HYDRATE를 적용하기 위해 기존 리덕스는 rootReducer에 Action.type이 HYDRATE인 경우를 처리해주는데, rootReducer를 생성하는 것은 리덕스 툴킷 답지 못하다. Slice의 extraReducers에 HYDRATE를 추가해주자. (두 방법의 예제 코드는 이 블로그에 잘 정리되어있다.)

store/todoSlice.tsx

import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TodoType } from "../types/todo";
import { HYDRATE } from "next-redux-wrapper";

const todoSlice = createSlice({
  name: "todoSlice",
  initialState: {
    todos: [] as TodoType[],
  },
  reducers: {
    setTodo(state, action: PayloadAction<TodoType>) {
      state.todos.push(action.payload);
    },
  },
  extraReducers: {
    [HYDRATE]: (state, action) => {
      return {
        ...state,
        ...action.payload,
      };
    },
  },
});

export default todo;
  1. store와 wrapper 만들기
    store/index.tsx 파일이다
import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import todoSlice from "./todoSlice";

const makeStore = () =>
  configureStore({
    reducer: {
      todoSlice: todoSlice.reducer,
    },
  });

const wrapper = createWrapper(makeStore);

export default wrapper;
  1. useWrapperdStore를 이용하여 provide하기
    pages/_app.tsx 파일이다.
import type { AppProps } from "next/app";
import GlobalStyle from "../styles/GlobalStyle";

import Header from "../components/Header";
import wrapper from "../store";
import { Provider } from "react-redux";

function App({ Component, pageProps }: AppProps) {
  const { store, props } = wrapper.useWrappedStore(pageProps);
  
  return (
    <Provider store={store}> 
      <GlobalStyle />
      <Header />
      <Component {...props} />
    </Provider>
  );
}

export default App;

next js 8버전 이상부터 useWrappedStore(pageProps)방식이 사용된다.
useWrappedStore는 pageProps를 인자로 받아 makeStore함수가 반환한 store와 pageProps를 store와 연동시킨 props를 반환한다.

store는 리액트-리덕스의 Provider의 store로 넘겨주고,
props는 Component에 pageProps 대신 넘겨준다.

  1. useSelector에 타입 씌우기

useSelecor는 기본적으로 타입이 없어 타입스크립트와 연동을 하면 빨간줄을 내뱉는다. 리액트-리덕스의 TypedUseSelectorHook<RootState>를 이용하여 타입을 씌워줄 수 있다.

TypedUseSeletorHook을 사용하기 위해서는 RootState를 알아야 한다. next js 공식문서의 방식(RootState = ReturnType<typeof store.getState>;)을 따라 아래와 같이 수정했다.

import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import {
  TypedUseSelectorHook,
  useSelector as useReduxSeletor,
} from "react-redux";

import todoSlice from "./todoSlice";

const store = configureStore({
  reducer: {
    todoSlice: todoSlice.reducer,
  },
});
const makeStore = () => store;

const wrapper = createWrapper(makeStore);
export default wrapper;

//RootState 타입은 store의 getState의 결과값의 타입과 같음.
export type RootState = ReturnType<typeof store.getState>;
export const useSelector: TypedUseSelectorHook<RootState> = useReduxSeletor;
  1. useSelector를 이용하여 출력하기
    이제 components/TodoList.tsx에서 useSelector를 이용하여 출력이 가능한지 확인해보자. 이때 useSelector는 store에서 export된 useSelector를 이용한다.
import React from "react";
import { useSelector } from "../store";

const TodoList: React.FC = () => {
  const todos = useSelector((state) => state.todoSlice.todos);
  return (
    <div>
      <h1>TodoList</h1>
      <p>남은 TODO {todos.length}</p>
      <ul>
        {todos.map((todo, index) => {
          return (
            <li key={todo.id}>
              <span>{todo.checked ? "O" : "X"}</span>
              <span>{todo.text}</span>
              <span>{todo.color}</span>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default TodoList;

임의로 store/todoSlice.tsx의 initialState를 변경하고,

  initialState: {
    todos: [
      { id: 1, text: "마트 가서 장보기", color: "RED", checked: false },
      { id: 2, text: "수학 숙제하기", color: "ORANGE", checked: true },
      { id: 3, text: "투두리스트 만들기", color: "YELLOW", checked: false },
      {
        id: 4,
        text: "마트가서 투두리스트 만드는 숙제하기",
        color: "RED",
        checked: false,
      },
    ] as TodoType[],

실행하면

잘 뜬다.

  1. reducers 수정하기

dispatch를 사용하기에 앞서 todoSlice의 reducers를 아래와 같이 수정하였다. redux-toolkit이 state의 불변성을 지켜주는 바, 아래와 같이 기존 객체를 직접 수정하는 방식으로 작성해보았다.

  reducers: {
    newTodo(state, action: PayloadAction<TodoType>) {
      state.todos.push(action.payload);
    },
    deleteTodo(state, action: PayloadAction<TodoType>) {
      console.log(state.todos.indexOf(action.payload));
      state.todos.splice(
        state.todos.findIndex((todo) => todo.id === action.payload.id),
        1,
      );
    },
    updateTodo(state, action: PayloadAction<TodoType>) {
      state.todos.splice(
        state.todos.findIndex((todo) => todo.id === action.payload.id),
        1,
        action.payload,
      );
    },
  },
  1. Create 액션

payload로 넘겨줄 state를 만든다.

  const [newText, setNewText] = useState("");
  const [newColor, setNewColor] = useState<TodoType["color"]>("RED");

form을 만든다

      <form onSubmit={createTodo}>
        <input
          name="text"
          type="text"
          placeholder="할 일을 입력하세요"
          onChange={(e) => setNewText(e.target.value)}
          value={newText}
        />
        <select name="color" onChange={onSelect} value={newColor}>
          <option value="RED">RED</option>
          <option value="ORANGE">ORANGE</option>
          <option value="YELLOW">YELLOW</option>
        </select>
        <button type="submit">작성하기</button>
      </form>

select 태그의 onChange 이벤트를 다루는 이벤트 핸들러를 만든다. select 이벤트를 onChange로 처리할 때에 타입 처리의 이슈가 있다. 스택 오버플로를 참조해봤는데..해결이 안됐다; 일단 그냥 진행.

  const onSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const value: TodoType["color"] = e.target.value;
    setNewColor(value);
  };

onSubmit 이벤트를 만든다.

  const createTodo = (e: React.FormEvent) => {
    e.preventDefault();
    const newId = todos[todos.length - 1].id + 1;
    dispatch(
      todoSlice.actions.newTodo({
        id: newId,
        text: newText,
        color: newColor,
        checked: false,
      }),
    );
    setNewText("");
  };

새로운 할 일이 잘 추가된다.

  1. update 액션
    update는 간단하게 checked 여부를 바꾸는 것으로 대체하려고 한다.

리스트에 onClick 이벤트를 만든 후, todo 데이터를 인자로 넘겨준다.

<li key={todo.id} onClick={() => onChecked(todo)}>

넘겨받은 인자에서 checked 부분만 토글시켜 upatedTodo 액션을 dispatch한다.

  const onChecked = (payload) => {
    dispatch(
      todoSlice.actions.updateTodo({
        ...payload,
        checked: !payload.checked,
      }),
    );
  };

잘 작동된다.

  1. delete 액션
    리스트에 삭제 버튼을 하나 추가한다. 이번에는 화살표 함수로 곧바로 dispatch 하도록 해보았다.
              <button
                type="button"
                onClick={() => dispatch(todoSlice.actions.deleteTodo(todo))}
              >
                삭제하기
              </button>

잘 작동한다.

완성된 코드는 아래와 같다.

// components/TodoList.tsx

import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { useSelector } from "../store";
import todoSlice from "../store/todoSlice";
import { TodoType } from "../types/todo";

const TodoList: React.FC = () => {
  const todos = useSelector((state) => state.todoSlice.todos);
  const dispatch = useDispatch();

  const [newText, setNewText] = useState("");
  const [newColor, setNewColor] = useState<TodoType["color"]>("RED");

  const onSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const value: TodoType["color"] = e.target.value;
    setNewColor(value);
  };

  const createTodo = (e: React.FormEvent) => {
    e.preventDefault();
    const newId = todos[todos.length - 1].id + 1;
    dispatch(
      todoSlice.actions.newTodo({
        id: newId,
        text: newText,
        color: newColor,
        checked: false,
      }),
    );
    setNewText("");
  };

  const onChecked = (payload) => {
    dispatch(
      todoSlice.actions.updateTodo({
        ...payload,
        checked: !payload.checked,
      }),
    );
  };

  return (
    <div>
      <h1>TodoList</h1>
      <p>남은 TODO {todos.length}</p>
      <form onSubmit={createTodo}>
        <input
          name="text"
          type="text"
          placeholder="할 일을 입력하세요"
          onChange={(e) => setNewText(e.target.value)}
          value={newText}
        />
        <select name="color" onChange={onSelect} value={newColor}>
          <option value="RED">RED</option>
          <option value="ORANGE">ORANGE</option>
          <option value="YELLOW">YELLOW</option>
        </select>
        <button type="submit">작성하기</button>
      </form>
      <ul>
        {todos.map((todo, index) => {
          return (
            <>
              <li key={todo.id} onClick={() => onChecked(todo)}>
                <span>{todo.checked ? "O" : "X"}</span>
                <span>{todo.text}</span>
                <span>{todo.color}</span>
              </li>
              <button
                type="button"
                onClick={() => dispatch(todoSlice.actions.deleteTodo(todo))}
              >
                삭제하기
              </button>
            </>
          );
        })}
      </ul>
    </div>
  );
};

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

0개의 댓글