Redux 로 todo app 만들기

Yunes·2023년 9월 23일
0

리액트스터디

목록 보기
15/18
post-thumbnail

store

Redux store 는 앱을 구성하는 state, action, reducer 를 통합한다.

  • 내부에 현재 앱의 상태를 갖는다.
  • store.getState() 를 통해 현재 상태에 접근할 수 있다.
  • store.dispatch(action) 를 통해 상태를 업데이트할 수 있다.
  • store.subscribe(listener) 를 통해 리스터 콜백을 등록한다.
  • store.subscribe(listener) 로 반환된 함수를 unsubscribe 할 수 있다.

Redux 앱에서 하나의 store 만 가져야 한다. 만약 로직을 분리하고 싶다면 store 를 분리하는게 아니라 reducer composition 을 사용하여 다수의 reducer를 생성하고 결합하여 사용한다.

store 생성하기

이전 state, action, reducer 편에서 다수의 reducer 를 combineReducers 를 통해 하나의 root reducer 를 만들어줬다.

// src/store.js

import { createStore } from 'redux'
import rootReducer from './reducer'

const store = createStore(rootReducer)

export default store

root reducer 를 createStore 의 인자로 전달하여 store 를 생성해줄 수 있다.

초기 상태를 로딩

createStore 는 두번째 인자로 초기 상태를 담은 배열을 전달할 수 있다. createStore(rootReducer, preloadedState)

import { createStore } from 'redux'

function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return state.concat([action.text])
    default:
      return state
  }
}

const store = createStore(todos, ['Use Redux'])

store.dispatch({
  type: 'ADD_TODO',
  text: 'Read the docs'
})

console.log(store.getState())
// [ 'Use Redux', 'Read the docs' ]

dispatch action

이제 store 까지 만들어줬다. 그럼 프로그램이 동작하게 만들어보자.

dispatch 는 파라미터로 액션을 받아 상태를 업데이트할 수 있게 해준다

// src/index.js

// Omit existing React imports

import store from './store'

// Log the initial state
console.log('Initial state: ', store.getState())
// {todos: [....], filters: {status, colors}}

// Every time the state changes, log it
// Note that subscribe() returns a function for unregistering the listener
const unsubscribe = store.subscribe(() =>
  console.log('State after dispatch: ', store.getState())
)

// Now, dispatch some actions

store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about actions' })
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about reducers' })
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about stores' })

store.dispatch({ type: 'todos/todoToggled', payload: 0 })
store.dispatch({ type: 'todos/todoToggled', payload: 1 })

store.dispatch({ type: 'filters/statusFilterChanged', payload: 'Active' })

store.dispatch({
  type: 'filters/colorFilterChanged',
  payload: { color: 'red', changeType: 'added' }
})

// Stop listening to state updates
unsubscribe()

// Dispatch one more action to see what happens

store.dispatch({ type: 'todos/todoAdded', payload: 'Try creating a store' })

// Omit existing React rendering logic

configure the store

store 생성은 toolkit 을 사용할 경우 configureStore 로 할 수 있고 redux 의 경우 createStore 로 할수 있다. 이때 createStore 가 이전 포스트에서는 root reducer, 초기 상태인 preloadedState 를 인자로 받을 수 있다고 했는데 세번째 인자로 enhancer 를 받을 수도 있다.

공식 문서의 enhancer 에 대한 설명

  • 미들웨어나 시간여행, 영속성 등의 서드파티 기능을 저장소에 추가하기 위해 지정가능하다.

음.. 역시 설명을 보고 이해가 하나도 안되었다.

그나마 코드 예시를 보고 대강 이런거구나 라는 생각이 들었는데 Redux store 는 store enhancer 를 사용하여 커스터마이징 할 수 있다. store enhancer 는 createStore 의 특별한 버전으로 Redux store 원본을 감싸는 또하나의 layer 를 추가할 수 있다. 그렇게 enhanced 된 store 는 store 가 dispatch, getState, subscribe 함수등을 사용하는 행위를 어느정도 변경할 수 있다.

enhancer 는 dispatch 하는동안 추가적으로 어떤 동작을 할 수 있도록 지정할 수 있다. 예를 들어 dispatch 할때 인사하는 로그를 남기도록 한다면

// src/store.js

import { createStore } from 'redux'
import rootReducer from './reducer'
import { sayHiOnDispatch } from './exampleAddons/enhancers'

const store = createStore(rootReducer, undefined, sayHiOnDispatch)

export default store
// src/index.js

import store from './store'

console.log('Dispatching action')
store.dispatch({ type: 'todos/todoAdded', payload: 'Learn about actions' })
console.log('Dispatch complete')

이러면 dispatch 할때 Hi 라는 로그가 남는다. enhancer 도 하나만 존재해야 하는데 만약 여러개가 존재할 경우 combineReducer 처럼 redux 의 compose 메서드를 사용하여 여러 enhancer 를 하나로 합쳐줄 수 있다.

// src/store.js

import { createStore, compose } from 'redux'
import rootReducer from './reducer'
import {
  sayHiOnDispatch,
  includeMeaningOfLife
} from './exampleAddons/enhancers'

const composedEnhancer = compose(sayHiOnDispatch, includeMeaningOfLife)

const store = createStore(rootReducer, undefined, composedEnhancer)

export default store

진짜 todo 앱 만들기

전체 코드

https://codesandbox.io/p/github/Stendhalsynd/react-redux/main?layout=%257B%2522sideb[…]ebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D

실행 결과

프로젝트 구조

.
├── README.md
├── package-lock.json
├── package.json
├── public
├── src
│   ├── App.css
│   ├── App.js
│   ├── App.test.js
│   ├── app
│   │   └── store.js
│   ├── features
│   │   ├── filters
│   │   │   ├── Filter.jsx
│   │   │   └── filtersSlice.js
│   │   └── todos
│   │       ├── Todo.jsx
│   │       └── todosSlice.js
│   ├── index.css
│   ├── index.js
│   ├── logo.svg
│   ├── reportWebVitals.js
│   └── setupTests.js
└── yarn.lock
// src/features/todos/todosSlice.js

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  todos: [
    { id: 0, text: "Learn React", completed: true },
    { id: 1, text: "Learn Redux", completed: false },
  ],
};

// Create a utility function to generate the next todo ID
function nextTodoId(todos) {
  const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1);
  return maxId + 1;
}

export const todosSlice = createSlice({
  name: "todos",
  initialState,
  reducers: {
    todoAdded: (state, action) => {
      state.todos.push({
        id: nextTodoId(state.todos),
        text: action.payload,
        completed: false,
      });
    },
    todoToggled: (state, action) => {
      const toggledTodo = state.todos.find(
        (todo) => todo.id === action.payload
      );
      if (toggledTodo) {
        toggledTodo.completed = !toggledTodo.completed;
      }
    },
    todoDeleted: (state, action) => {
      state.todos = state.todos.filter((todo) => todo.id !== action.payload);
    },
    allCompleted: (state) => {
      state.todos = state.todos.map((todo) => ({
        ...todo,
        completed: true,
      }));
    },
    completedCleared: (state) => {
      state.todos = state.todos.filter((todo) => !todo.completed);
    },
  },
});

export const maxId = (todos) =>
  todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1;

export const todos = (state) => state.todos;

export const completedTodos = (state) =>
  state.todos.filter((todo) => todo.completed === true);

export const {
  todoAdded,
  todoToggled,
  todoDeleted,
  allCompleted,
  completedCleared,
} = todosSlice.actions;

export default todosSlice.reducer;
//src/features/filters/filtersSlice.js

import { createSlice } from "@reduxjs/toolkit";

export const StatusFilters = {
  All: "all",
  Active: "active",
  Completed: "completed",
};

const initialState = {
  status: StatusFilters.All,
};

export const filtersSlice = createSlice({
  name: "filters",
  initialState,
  reducers: {
    statusFilterChanged: (state, action) => {
      state.status = action.payload;
    },
  },
});

export const filterStatus = (state) => state.filters.status;

export const { statusFilterChanged } = filtersSlice.actions;

export default filtersSlice.reducer;
// src/features/todos/Todo/jsx

import { useSelector, useDispatch } from "react-redux";
import { useState } from "react";
import {
  todoAdded,
  todoToggled,
  todoDeleted,
  allCompleted,
  completedCleared,
  todos,
} from "./todosSlice";
import {
  statusFilterChanged,
  filterStatus,
  StatusFilters,
} from "../filters/filtersSlice";

export function Todo() {
  const dispatch = useDispatch();
  const todoList = useSelector(todos).todos;
  const currentStatus = useSelector(filterStatus);
  const [textInput, setTextInput] = useState("");

  const resultTodos = (currentStatus) => {
    switch (currentStatus) {
      case StatusFilters.Active:
        return todoList.filter((todo) => todo.completed === false);
      case StatusFilters.Completed:
        return todoList.filter((todo) => todo.completed === true);
      default:
        return todoList;
    }
  };

  const titleStyle = {
    padding: "15px",
    background: "antiquewhite",
    fontWeight: "bold",
    fontSize: "x-large",
  };

  const itemContainerStyle = {
    display: "grid",
    gridTemplateColumns: "1fr 14fr 1fr",
    padding: "10px 0",
  };

  const footerContainerStyle = {
    display: "flex",
    justifyContent: "space-between",
    padding: "10px",
    borderBottom: "0.5px solid black",
  };

  return (
    <>
      <div style={titleStyle}>TODO 앱 만들기</div>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          margin: "10px",
        }}
      >
        <input
          type="text"
          value={textInput}
          onChange={(e) => setTextInput(e.target.value)}
          placeholder="할일 추가하기"
          style={{ width: "93vw" }}
        />
        <button
          onClick={() => {
            dispatch(todoAdded(textInput));
            setTextInput("");
          }}
        >
          add
        </button>
      </div>
      <ul>
        {resultTodos(currentStatus).map((todo) => (
          <li key={todo.id} style={itemContainerStyle}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch(todoToggled(todo.id))}
              id={todo.id}
            />
            <label htmlFor={todo.id} style={{ padding: "5px" }}>
              {todo.text}
            </label>
            <button onClick={() => dispatch(todoDeleted(todo.id))}>x</button>
          </li>
        ))}
      </ul>

      <div style={footerContainerStyle}>
        <div>
          <button onClick={() => dispatch(allCompleted())}>
            모두 체크하기
          </button>
          <button onClick={() => dispatch(completedCleared())}>
            완료한 일 모두 삭제하기
          </button>
        </div>

        <select
          name="status"
          onChange={(e) => {
            dispatch(statusFilterChanged(e.target.value));
          }}
          value={currentStatus}
        >
          <option value="all">모두 보기</option>
          <option value="active">할 일 보기</option>
          <option value="completed">완료한 일 보기</option>
        </select>
      </div>
    </>
  );
}
// src/app/store.js

import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
import todosReducer from "../features/todos/todosSlice";
import filtersReducer from "../features/filters/filtersSlice";

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer,
    filters: filtersReducer,
  },
});
// src/index.js

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './index.css';

const container = document.getElementById('root');
const root = createRoot(container);

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

reportWebVitals();

redux toolkit 의 createSlice 를 통해 reducer, action creator, action 폴더를 따로 만들지 않고도 하나의 slice 파일에서 모든 것을 관리할 수 있던 부분과 unmutable 하게 update 해야 하는 것을 createSlice 에 내장되어 있는 Immer 라이브러리 덕분에 mutable 하게 update 하듯 push 등을 사용할 수 있던 점이 정말 편했다.

또한 feature 별로 디렉토리를 구분하여 nested 한 구조가 좀더 알아보기 쉽게 구성할 수 있었다.

직접 todo 앱을 만들어보면서 reducer, action, dispatch 등을 통해 전역적으로 상태를 어떻게 관리할 수 있는지 확실히 알수 있게 되었다.

러닝커브가 좀 있어서 개념을 이해하는데 다소 시간이 걸렸지만 props 로 state 를 전달하는 것보다 전역적으로 상태를 관리하는 부분이 useReducer 와 비슷하면서도 toolkit 의 편리함을 느낄 수 있었다.

다음엔 recoil 로 todo 앱을 만들어보며 전역상태를 관리하는 다른 방법에 대해 알아볼 계획이다.

profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

0개의 댓글