Redux todo 앱 만들기 [state, action, reducer]

Yunes·2023년 9월 23일
0

리액트스터디

목록 보기
14/18
post-thumbnail

redux 로 todo 앱 만들기

docs 에 redux 를 사용하는 코드를 어떤 순서로 만들어야하는지 설명하는 예시로 todo 앱을 만드는 부분이 있었다.

요구사항 정의하기

  • 3개의 메인 section 으로 UI 구성
    • 새로운 todo item 에 글을 유저가 타이핑하기 위한 입력창
    • 존재하는 모든 todo items 리스트
    • 아직 완료되지 않은 todos 의 수, filter 옵션을 보여주는 footer
  • todo list item 들은 완료됨 상태를 토글할 수 있는 체크박스
  • 모든 todos 를 완료 상태로 바꿀 수 있는 버튼과 모든 완료된 todos 를 제거할 수 있는 버튼
  • todos 의 상태에 따라 All, Active, Completed 상태의 todos 를 list 에 보이기

상태값 디자인하기

React 나 Redux 의 주 원리는 UI 가 상태에 따라 결정되어야 한다는 것이다. 그래서 앱을 디자인하기 위해 접근하는 방법중 처음으로 해야 하는 것은 앱이 어떻게 동작하는지 표현하기 위한 모든 상태를 생각하는 것이다.

그래서 todo 앱에 필요한 것은 실질적인 현재 todo item 리스트, 현재 필터링 옵션이다.

각각의 todo item 은 다음의 정보들을 저장해야 한다.

  • 유저가 입력한 내용
  • 할일이 완료되었는지 여부
  • unique ID 값

필터링 동작은 다음 열거값들로 설명할 수 있다.

  • 할일의 상태 : All , Active , Completed

이때 상태를 2가지로 구분할 수 있다.

todos 는 app state 이고 필터링 값은 UI state 이다. 이 둘을 구분하면 다양한 상태가 어떻게 사용되는지 이해하는데 도움이 된다.

app state : 앱이 작동하는데 사용되는 핵심 데이터
UI state : 앱이 현재 무엇을 하고있는지 나타내는 상태

상태 구조 디자인하기

Redux 에서 앱 상태는 항상 plain JavaScript object, array 이다. 이는 Redux 상태에 class instance, built-in JS types ( Map, Set, Promise, Date, functions, other things like not plain JS data ) 등을 넣지 않는다는 것을 의미한다.

루트 Redux 상태값은 대부분 plain JS object 혹은 중첩구조의 데이터를 내부적으로 갖는다.

state - todo item object

각 아이템들은 다음의 필드들이 필요할 것이다.

  • id : unique number
  • text : 유저가 타이핑한 텍스트
  • completed : 불린값

그리고 필터링 옵션을 위해 다음의 값이 피요하다.

  • 현재 completed filter 값
const todoAppState = {
  todos: [
    { id: 0, text: 'Learn React', completed: true },
    { id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
  ],
  filters: {
    status: 'Active',
  }
}

redux 바깥에서 다른 상태값을 갖는것은 상관없다! 어떤 데이터는 Redux 로 관리할 필요가 없기도 하다. ( isDropdownOpen / form input 의 현재값 등 )

Action 디자인하기

action 역시 plain JavaScript object 인데 type field 를 반드시 가져야 한다. 액션은 앱에서 발생한 무언가를 설명하는 이벤트라고 말할 수 있다.

앱의 요구사항에 맞춰 상태 구조를 디자인하던 방식과 같은 방법으로 무엇이 발생했는지를 설명하는 액션 리스트를 만들 수 있다.

  • 유저가 입력한 텍스트를 기반으로 한 새로운 todo entry 추가하기
  • todo 의 완료된 상태 토글하기
  • todo 지우기
  • 모든 todo 를 완료된 상태로 변경하기
  • 모든 완료된 todo 삭제하기
  • 다른 completed filter 값 선택하기

type 을 제외한 나머지 데이터는 action.payload 필드에 명시한다. 이는 숫자, 문자열, 다중 필드를 갖는 객체 등이 될 수 있다.

Redux store 는 action.type 필드에 어떤 텍스트가 들어가는지 상관하지 않는다. 그러나 코드를 작성할때 상태의 업데이트가 필요하면 action.type 를 보게 될 것이다. 또한 앱에서 무엇이 발생하고 있는지 디버깅ㅎ는중에 Redux DevTools Extension 에서 action type string 을 자주 보게 될 것이니 나중에 이해하기 쉽게 무엇이 발생했는지를 읽기 쉽고 명확하게 설명하는 action type 을 선택하는 편이 좋다.

위의 액션 리스트는 다음과 같은 형태로 만들 수 있다.

  • { type: 'todos/todoAdded', payload: todoText }
  • { type: 'todos/todoToggled', payload: todoId }
  • { type: 'todos/todoDeleted', payload: todoId }
  • { type: 'todos/allCompleted'}
  • { type: 'todos/completedCleared'}
  • { type: 'todos/statusFilterChanged', payload: filterValue }

payload 는 위와 같이 단일값이면 payload field 에 직접 넣어줄 수도 있으나 다중 값을 넣고자 할때는 객체형태로 payload 에 값을 넣어줄 수도 있다.

Reducer 작성하기

redux-toolkit createSlice

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

redux toolkit 에 createSlice 라는 메서드가 있다. 이 createSlice 는 reducer, action, immutable update 를 다루는 모든 boilerplate 를 제거하기 위한 API 이다.

예를 들어 전형적인 reducer Redux 는 reducer 로직, action 생성자, action type 들이 여러 파일로 나뉘어 있고 이들은 각각의 타입에 따라 다른 폴더에 있다.

// src/constants/todos.js
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'

// src/actions/todos.js - action 생성자
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

export const addTodo = (id, text) => ({
  type: ADD_TODO,
  text,
  id
})

export const toggleTodo = id => ({
  type: TOGGLE_TODO,
  id
})

// src/reducers/todos.js - reducer
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

const initialState = []

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO: {
      return state.concat({
        id: action.id,
        text: action.text,
        completed: false
      })
    }
    case TOGGLE_TODO: {
      return state.map(todo => {
        if (todo.id !== action.id) {
          return todo
        }

        return {
          ...todo,
          completed: !todo.completed
        }
      })
    }
    default:
      return state
  }
}

만약 createSlice 를 사용한다면 다음과 같은 몇가지 변경점이 있다.

  • createSlice 는 손으로 작성한 액션 생성자와 액션 타입을 완전히 제거한다.
  • action.text, action.id 와 같이 고유하게 이름이 지정된 모든 필드는 개별 값, 필드를 포함하는 action.payload 로 대체된다.
  • Immer 덕분에 불변 업데이트는 리듀서의 변형 로직으로 대체된다. ( 예를 들어 불변성을 위해 concat, spread operator 를 사용해야 했다면 Immer 덕분에 직접 수정이 가능해서 push 같은게 가능하다 )
  • 각 코드 유형에 대해 별도의 파일이 필요하지 않다.
  • 하나의 slice 파일에 주어진 리듀서에 대한 모든 로직을 갖는다.
  • 코드 유형별로 별도의 폴더를 두는 대신 기능별로 파일을 구성하고 관련 코드가 동일한 폴더에 존재하는 것을 권장한다.
  • 이상적으로 리듀서와 액션의 이름은 지금 이것을 하다 보다는 발생한 무언가 를 명시하는 편이 좋다. 예를 들어 ADD_TODO 보다는 todoAdded 이 좋다.

이런 constants, actions, reducers 같은 파일들이 createSlice 를 통해 하나의 slice 파일로 합쳐져 대체된다.

import { createSlice } from '@reduxjs/toolkit'

const initialState = []

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // Give case reducers meaningful past-tense "event"-style names
    todoAdded(state, action) {
      const { id, text } = action.payload
      // "Mutating" update syntax thanks to Immer, and no `return` needed
      todos.push({
        id,
        text,
        completed: false
      })
    },
    todoToggled(state, action) {
      // Look for the specific nested object to update.
      // In this case, `action.payload` is the default field in the action,
      // and can hold the `id` value - no need for `action.id` separately
      const matchingTodo = state.find(todo => todo.id === action.payload)

      if (matchingTodo) {
        // Can directly "mutate" the nested object
        matchingTodo.completed = !matchingTodo.completed
      }
    }
  }
})

// `createSlice` automatically generated action creators with these names.
// export them as named exports from this "slice" file
export const { todoAdded, todoToggled } = todosSlice.actions

// Export the slice reducer as the default export
export default todosSlice.reducer

createSlice 의 프로퍼티로 reducers 가 있는데 이 객체의 프로퍼티가 action creater 가 되어 dispatch(todoAdded('Buy mil')) 같은 코드에서 action.payload 필드를 자동으로 생성해준다. action creater 의 파라미터에 객체를 전달할 수도 있고 혹은 prepare notation 을 createSlice reducer 내에 넣어 다수의 argument 로 payload field 를 생성할 수 있다.

// features/posts/postsSlice.js
const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    postAdded: {
      reducer(state, action) {
        state.push(action.payload)
      },
      prepare(title, content) {
        return {
          payload: {
            id: nanoid(),
            title,
            content
          }
        }
      }
    }
    // other reducers here
  }
})

위와 같이 prepare notation 을 사용한다면 payload object 가 어떻게 생겼는지 신경쓸 필요 없이 action 생성자에 같이 파라미터로 넣어주면 된다.

const onSavePostClicked = () => {
  if (title && content) {
    dispatch(postAdded(title, content))
    setTitle('')
    setContent('')
  }
}

Create root reducer

앞에서 소개한 createSlice 를 사용하면 action creator, action type, reducer 등을 코드 유형별로 나누어 관리할 필요없이 기능별로 나누어 하나의 slice로 대체할 수 있었다.

그래도 기존에 어떻게 reducer 를 생성하고 관리했었는지 알아보자.

이제 상태 구조와 action 이 어떻게 생겼는지 알게 되었으니 reducer 를 작성할 차례다.

reducer 는 현재 상태와 action 을 인자로 받아 새로운 상태를 반환하는 순수함수다.

Redux 앱은 하나의 root reducer 함수를 갖는데 이는 이후에 createStore 에 전달된다. 이 root reducer 는 dispatch 되고 매 순간마다의 전체 새로운 상태 결과 계산하는 것과 같은 action 들을 다뤄야 한다.

모든 reducer 는 초기상태를 필요로 한다. 그래서 아래의 예시에서는 fake todo entry 를 만들어 코드를 작성한다.

// src/reducer.js

const initialState = {
  todos: [
    { id: 0, text: 'Learn React', completed: true },
    { id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
  ],
  filters: {
    status: 'All',
  }
}

// Use the initialState as a default value
export default function appReducer(state = initialState, action) {
  // The reducer normally looks at the action type field to decide what happens
  switch (action.type) {
    // Do something here based on the different types of actions
    default:
      // If this reducer doesn't recognize the action type, or doesn't
      // care about this specific action, return the existing state unchanged
      return state
  }
}

앱이 초기화될 때 상태값이 지정되지 않은 undefined 로 reducer 를 호출하게 될 수도 있다. 그렇게 된다면 나머지 reducer 코드가 동작하도록 초기 상태값을 제공해줘야 하는데 reducer 는 ES6 의 default argument syntax 를 사용하여 초기 상태값을 사용한다. (state = initialState, action)

todos/todoAdded action 을 위한 로직을 추가해보자.

현재 액션 타입이 특정 문자열과 일치하는지 체크해야 한다. 그런뒤 변하지 않는 필드라도 모든 상태를 포함하는 새로운 객체를 반환한다.

// src/reducer.js

function nextTodoId(todos) {
  const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
  return maxId + 1
}

// Use the initialState as a default value
export default function appReducer(state = initialState, action) {
  // The reducer normally looks at the action type field to decide what happens
  switch (action.type) {
    // Do something here based on the different types of actions
    case 'todos/todoAdded': {
      // We need to return a new state object
      return {
        // that has all the existing state data
        ...state,
        // but has a new array for the `todos` field
        todos: [
          // with all of the old todos
          ...state.todos,
          // and the new todo object
          {
            // Use an auto-incrementing numeric ID for this example
            id: nextTodoId(state.todos),
            text: action.payload,
            completed: false
          }
        ]
      }
    }
    default:
      // If this reducer doesn't recognize the action type, or doesn't
      // care about this specific action, return the existing state unchanged
      return state
  }
}

이런식으로라면 todo item 을 하나 추가하는데 너무 많은 일을 해야한다.. 그래서 다른 대안이 있다.

Reducer 의 규칙

  • reducer 는 현재 상태와 action 인자로 오직 새로운 상태를 계산한다.
  • 현재 상태를 직접 수정할수는 없다. 대신 불변의 업데이트를 위해 기존 상태를 복사하여 복사본에 변경점을 반영한다. ( Immer 를 사용하면 상태를 직접 수정할 수 있다. )
  • side effect 나 다른 비동기 로직을 수행해서는 안된다.

side effect : 함수에서 값을 반환하는것 바깥에서 볼 수 있는 상태 변경이나 동작을 의미한다.

  • 콘솔에 값 로그 남기기
  • 파일 저장하기
  • 비동기 타이머 세팅
  • ajax HTTP 요청하기
  • 함수 바깥에 존재하는 상태 변경하거나 함수로의 인자 변경하기
  • 랜덤 값이나 유일한 랜덤 ID 생성하기

리듀서는 순수함수이니 이러한 side effect 는 리듀서 함수에서 사용되지 않는다. redux 는 코드를 예측가능하게 만들기 위해 이런 side effect 를 피해야 한다.

Immer : React 에서 불변성을 유지하느라 복잡해진 코드를 짧고 간결하게 작성할 수 있도록 도와주는 라이브러리

  • 참고문서
  • Immer docs
  • 설치 : yarn add immer / npm i immer
  • Immer docs - React & Immer
    • 이 페이지에서 useState 와 Immer 를 같이 사용하는 방법, useImmer, useReducer 와 Immer 를 같이 사용하는 방법, useImmerReducer, Redux 와 Immer 를 샅이 사용하는 방법에 대해 소개되어 있다.

Redux + Immer

Immer 를 사용하여 draft 를 안전하게 mutate 시켜줄 수 있다.

import {produce} from "immer"

// Reducer with initial state
const INITIAL_STATE = [
    /* bunch of todos */
]

const todosReducer = produce((draft, action) => {
    switch (action.type) {
        case "toggle":
            const todo = draft.find(todo => todo.id === action.id)
            todo.done = !todo.done
            break
        case "add":
            draft.push({
                id: action.id,
                title: "A new todo",
                done: false
            })
            break
        default:
            break
    }
})

redux 에서는 toolkit 의 createSlice 에 암시적으로 Immer 가 적용된다.

그런 의미에서 앞에서 보인 코드를 다시 확인해보고 비교해보자.

// src/features/todos/todosSlice.js

import { createSlice } from '@reduxjs/toolkit'

const initialState = []

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // Give case reducers meaningful past-tense "event"-style names
    todoAdded(state, action) {
      const { id, text } = action.payload
      // "Mutating" update syntax thanks to Immer, and no `return` needed
      todos.push({
        id,
        text,
        completed: false
      })
    },
    todoToggled(state, action) {
      // Look for the specific nested object to update.
      // In this case, `action.payload` is the default field in the action,
      // and can hold the `id` value - no need for `action.id` separately
      const matchingTodo = state.find(todo => todo.id === action.payload)

      if (matchingTodo) {
        // Can directly "mutate" the nested object
        matchingTodo.completed = !matchingTodo.completed
      }
    }
  }
})

// `createSlice` automatically generated action creators with these names.
// export them as named exports from this "slice" file
export const { todoAdded, todoToggled } = todosSlice.actions

// Export the slice reducer as the default export
export default todosSlice.reducer

Reducer 와 Immutable Update

mutation : 기존 객체나 배열의 값을 수정하는 것
immutability : 어떤 값을 변경할 수 없는 것으로 취급하는 것

redux 에서 reducer 는 original / current state 값을 변경할 수 없다. 그에 대한 이유는 다음과 같다.

  • UI 가 최신값을 적절하게 업데이트 하지 못하는 것같은 버그를 유발할 수 있다.
  • 상태값이 왜, 어떻게 업데이트되었는지 이해하기 어려워진다.
  • 테스트코드를 작성하기 어렵다.
  • 시간 흐름에 따른 디버깅 기능을 제대로 활용하기 어렵다.
  • Redux 의 본래 의도된 목적과 사용 패턴에 어긋난다.

그래서 Redux 는 객체, 배열을 복사하여 복사본을 수정하는 식으로 상태값을 변경한다. 그런데 상태 구조가 중첩되어 있으면 이 과정이 복잡해진다. 이걸 간편하게 하기위해 Redux Toolkit 을 활용하기도 한다.

액션을 추가해보자.

앞에서 언급한 액션을 다시 보자.

  • { type: 'todos/todoAdded', payload: todoText }
  • { type: 'todos/todoToggled', payload: todoId }
  • { type: 'todos/todoDeleted', payload: todoId }
  • { type: 'todos/allCompleted'}
  • { type: 'todos/completedCleared'}
  • { type: 'todos/statusFilterChanged', payload: filterValue }

이중 todoAdded 만 코드를 작성했는데 todoToggle, statusFilterChanged 에 대한 리듀서를 작성해보자.

// src/reducer.js

export default function appReducer(state = initialState, action) {
  switch (action.type) {
    case 'todos/todoAdded': {
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: nextTodoId(state.todos),
            text: action.payload,
            completed: false
          }
        ]
      }
    }
    case 'todos/todoToggled': {
      return {
        ...state,
        todos: state.todos.map(todo => {
          if (todo.id !== action.payload) {
            return todo
          }

          return {
            ...todo,
            completed: !todo.completed
          }
        })
      }
    }
    case 'filters/statusFilterChanged': {
      return {
        // Copy the whole state
        ...state,
        // Overwrite the filters value
        filters: {
          // copy the other filter fields
          ...state.filters,
          // And replace the status field with the new value
          status: action.payload
        }
      }
    }
    default:
      return state
  }
}

3개만 들어갔는데도 중첩구조로 인해 코드가 벌써 복잡하다. 그래서 리듀서를 쪼개서 관리한다. 하나의 큰 root reducer 를 todosReducer, filtersReducer 2개로 쪼갠다.

이때 Redux app folders 와 files 를 앱의 특정한 개념, 영역에서 연관이 있는 코드들인 features 를 기준으로 구성할 것을 권장한다.

redux 코드의 특징중 하나가 앱의 상태에서 연관되어 있는 모든 액션들, 리듀서 로직을 포함하는 하나의 slice 파일에 작성하는 것이다. 그래서 Redux app 상태의 각 section 을 slice reducer 라고도 한다. 이때 action type string 은 features 와 발생한 이벤트를 합친 하나의 문자열로 나타낸다. todos/todoAdded

// src/features/todos/todosSlice.js

const initialState = [
  { id: 0, text: 'Learn React', completed: true },
  { id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
]

function nextTodoId(todos) {
  const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
  return maxId + 1
}

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case 'todos/todoAdded': {
      // Can return just the new todos array - no extra object around it
      return [
        ...state,
        {
          id: nextTodoId(state),
          text: action.payload,
          completed: false
        }
      ]
    }
    case 'todos/todoToggled': {
      return state.map(todo => {
        if (todo.id !== action.payload) {
          return todo
        }

        return {
          ...todo,
          completed: !todo.completed
        }
      })
    }
    default:
      return state
  }
}
// src/features/filters/filtersSlice.js

const initialState = {
  status: 'All',
  colors: []
}

export default function filtersReducer(state = initialState, action) {
  switch (action.type) {
    case 'filters/statusFilterChanged': {
      return {
        // Again, one less level of nesting to copy
        ...state,
        status: action.payload
      }
    }
    default:
      return state
  }
}

위의 코드는 여전히 중첩구조로 되어있으나 덜 중첩되어 있고 어떤 일이 발생했는지 읽기 쉽다.

reducer 결합하기

app 에 존재하는 단 하나의 store 에 root reducer 를 전달해줘야 하는데 앞에서 reducer 를 분리했다. reducer 역시 함수이니 import 해서 가져올 수 있고 rootReducer 에 여러 리듀서를 전달할 수 있다.

// src/reducer.js

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

export default function rootReducer(state = {}, action) {
  // always return a new object for the root state
  return {
    // the value of `state.todos` is whatever the todos reducer returns
    todos: todosReducer(state.todos, action),
    // For both reducers, we only pass in their slice of the state
    filters: filtersReducer(state.filters, action)
  }
}

혹은 rootReducer 대신 combineReducer 를 사용할 수 있다.

// src/reducer.js

import { combineReducers } from 'redux'

import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'

const rootReducer = combineReducers({
  // Define a top-level state field named `todos`, handled by `todosReducer`
  todos: todosReducer,
  filters: filtersReducer
})

export default rootReducer

combineReducer 에 전달하는 키가 상태 객체가 갖게되는 키 이름을 결정한다.

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

0개의 댓글