Reduxtoolkit : INTERMEDIATE TUTORIAL

R

원문 : https://redux-toolkit.js.org/tutorials/intermediate-tutorial

주의 : 제가 이해하기 쉬운 방식으로 번역 및 첨언했습니다.

이전글 : https://develoger.local:80/redux-toolkit-basic-tutorial/


이번 글에서는 original Redux Todo App을 Redux-Toolkit을 사용한 Todo App으로 바꿔보면서 Redux-Toolkit의 사용법을 익혀볼것이다.

Reviewing the Redux Todos Example

original Redux Todo App을 보면 아래것들을 발견할 수 있다.

  • todos reducer function 은 state의 불변성을 유지하기 위해서 "직접" object를 deep copy하고있다.
  • actions file 에서 action 생성자 함수를 "직접" 작성하고 있다. 또한, aciton의 type을 string으로 "직접" 적어주고 있으며, 다른 파일에서 도 중복으로 "직접"적어서 사용하고 있다.
  • 같은 context를 가진 action과 reducer를 한 파일에 두지 않고, 모든 action은 actions 파일에, 모든 reducer는 reducer파일에 보관한다. 상관없는 action들끼리 모여있으니 보기가 영 안좋다.
  • "container/presentational" pattern에 따라서 화면에 어떻게 그려질지가 중요한 컴포넌트들(presentational)을 한 폴더에 넣고, 어떻게 작동(action dispatch)하는지가 중요한 컴포넌트들은 다른 폴더에 넣어놨다.
  • 몇몇 코드들은 별로 좋은 pattern이 아니다. 이것도 찝어서 수정해 나갈것이다.

Initial Conversion Steps

original code 다운받기

redux github repository에서 redux를 다운받고, examples > todos 폴더만 남기고 다른건 다 지운다.

새 프로젝트 생성

original code의 module들은 좀 옛날꺼라서 아예 create-react-app으로 새 프로젝트를 만든 다음에 original code만 복사해오는 방식으로 한다.

npx create-react-app my-app --template redux

필요없는 코드들 지우기

src폴더에 있는거 모두 지워준다. 그리고 public폴더 안에있는것들도 지워준다.

코드 복붙

여기에 있는 소스코드들중 public과 src폴더를 복사해서 방금 만든 새 프로젝트에 붙여넣기한다. 그럼 다음과 같은 구조가 된다.

이미지에 대체텍스트 속성이 없습니다; 파일명은 캡처.png 입니다.

이제 npm run start 하면 잘 뜬다.

이미지에 대체텍스트 속성이 없습니다; 파일명은 캡처2.png 입니다.

Converting the Store to Use configureStore()

index.js에서 createStore함수를 configureStore로 대체해보자.

import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux'
import App from './components/App'
import rootReducer from './reducers'
const store = configureStore({ reducer: rootReducer });
render(
  <Provider store = { store } >
    <App />
  </Provider>,
document.getElementById('root'));

configureStore는 알아서 Redux DevTools Extension과 연동을 해주기 때문에 만약 크롬브라우저에 Redux DevTools Extension 이 설치되어 있다면, 아래와 같은 모습을 볼 수 있다.

이미지에 대체텍스트 속성이 없습니다; 파일명은 캡처-1.png 입니다.

Creating the Todos Slice

original todo는 action과 reducer를 떨어뜨려놓았다. 이거를 하나의 slice로 묶어보자

Understanding Slices

우리는 RTK(redux toolkit)의 createAction()createReducer()를 사용해서 original todo의 reducers/todos.jsactions/index.js를 더 깔끔하게 작성할수있다. 하지만, 실질적으로 createSlice()만 써도 된다. createSlice()는 같은 맥락의 action과 reducer를 하나로 묶어주기 때문에 코드를 더 깔끔하고 이해하기 쉽게 작성할 수 있다. 그리고 createSlice()내부적으로 createAction()createReducer()를 사용한다.

근데 'slice' 가 뭘 의미하는거지?

combineReducers()

state tree는 내 App의 상태를 보관하는 JS Object이다. 이 state tree는 reducer의 return값으로 계속 새걸로 교체가 되는데,

이미지에 대체텍스트 속성이 없습니다; 파일명은 images.png 입니다.

프로젝트가 커지면 하나의 reducer안에 관련없는 것들이 주루룩 생기는게(switch - case) 단점이다.

// 자판기와 쓰레기통의 action에 반응하는 reducer
const BigReducer = (state = [], action) => {
  switch (action.type) {
    case '자판기에돈넣기':
      return [...state, {
        ...
      }];
    case '음료뽑기':
      return [...state, {
        ...
      }];
    case '쓰레기통문열기':
      return [...state, {
        ...
      }];
    case '쓰레기통문닫기':
      return [...state, {
        ...
      }];
    default:
      return state;
  }
}

그래서 만들어진것이 combineReducer()이다. 이 함수를 사용하면 state tree안에서 관련이 있는 부분만 담당하는 reducer를 하나로 묶을 수 있다.

const 자판기리듀서 = (state = [], action) => {
  switch (action.type) {
    case '자판기에돈넣기':
      return [...state, {
        ...
      }];
    case '음료뽑기':
      return [...state, {
        ...
      }];
    default:
      return state
  }
}
const 쓰레기통리듀서 = (state = [], action) => {
  switch (action.type) {
    case '쓰레기통문열기':
      return [...state, {
        ...
      }];
    case '쓰레기통문닫기':
      return [...state, {
        ...
      }];
    default:
      return state
  }
}
const 종합리듀서 = combineReducers({
  자판기관련상태: 자판기리듀서,
  쓰레기통관련상태: 쓰레기통리듀서
});
const store = configureStore({
  reducer: 종합리듀서
});

그래서 자판기리듀서가 뿜어내는 state값은 state tree의 자판기관련상태 가지(?) 에만 들어가고, 쓰레기통리듀서가 뿜어내는 state값은 state tree의 쓰레기통관련상태 가지에만 들어간다.

자 그래서, createSlice() 의 slice는 자판기상태 를 의미하는거고, slice reducer는 자판기리듀서를 의미한다. 포괄적으로는 저 key-value를 하나의 slice로 봐도 될것같다.

Examining the Original Todos Reducer

const todos = (state = [], action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false,
        },
      ];
    case "TOGGLE_TODO":
      return state.map((todo) =>
        todo.id === action.id
          ? {
              ...todo,
              completed: !todo.completed,
            }
          : todo
      );
    default:
      return state;
  }
};
export default todos;
  • 일반적인 reducer이다.
  • immer 같은 상태 불변성 관리 라이브러리를 안쓰니, 직접 state를 deep copy하고있다.
  • initial state는 []이다.
  • 어떤 action type도 switch-case에 맞지 않는 경우(default)에는 기존의 state를 돌려준다.

추가적으로, map함수의 사용법은 다음과 같다.

const days = ["Mon", "Tue", "Wed", "Thus", "Fri"];
const happyDay = days.map((day, index) => {
  return `너무 좋은 ${day}`;
});
console.log(happyDay); // ["너무 좋은 Mon", "너무 좋은 Tue", "너무 좋은 Wed", "너무 좋은 Thus", "너무 좋은 Fri"]

Writing the Slice Reducer

위의 todos reducer를 createSlice()를 활용해 좀 더 심플하게 바꿔보자.

먼저는, features폴더를 src폴더 안에 만들고, /features/todos/todosSlice.js 파일을 생성한다. 그리고 아래와 같이 코드를 작성한다.

import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      const { id, text } = action.payload;
      state.push({
        id,
        text,
        completed: false
      });
    },
    toggleTodo(state, action) {
      const todo = state.find(todo => todo.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    }
  }
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;

코드를 살펴보자.

  • slicename'todos'이다. 이 namestate treekey이다. 동시에 앞으로 들어올 action의 prefix이다. 예를들어서 'todos/addTodo' 라는 액션이 들어올것이다.
  • initialState[]이 들어왔다.
  • reducers에는 두가지 함수를 넘겨준다. 'todos/addTodo'라는 action이 들어오면 addTodo() 가 호출될것이고, 'todos/toggleTodo'라는 함수가 들어오면 toggleTodo() 가 호출될것이다.
  • default handler는 없다. 맞지않는 action이 들어오면 별 반응 안하도록 내부적으로 세팅되어있다.

"Mutable" Update Logic

addTodo() 안에서 state.push({...})를 하고있다. 원래 javascript의 Array.push를 해버리면 state의 불변성이 훼손되버리는데, RTK는 내부적으로 immer library를 사용하기 때문에 문제 없다.

toggleTodo() 도 마찬가지로 state의 한 slice인 todo를 찾아서(find) completed property를 변경했다. 이것또한 immer가 해당 객체의 변화를 감지하고 새로운 state를 만들어서 불변성을 유지해준다. 내부적으로 어떻게 이렇게 하는지는 모르겠지만, 아무튼 내가 막 바꿔도 그거는 새로운 객체를 바꾸는것이지, 원래의 객체를 바꾸는게 아니다.

Exporting the Slice Functions

createSlice() 는 대충 이렇게 생긴 객체를 리턴한다.

{
  name: "todos",
  reducer: (state, action) => newState,
  actions: {
    addTodo: (payload) => ({
      type: "todos/addTodo",
      payload
    }),
    toggleTodo: (payload) => ({
      type: "todos/toggleTodo",
      payload
    })
  },
  caseReducers: {
    addTodo: (state, action) => newState,
    toggleTodo: (state, action) => newState,
  }
}

주의할점은 createAction()의 두번째 인자로 callback function이 들어가는데, 이 함수는 payload를 key로 가지는 객체를 리턴해야한다!

여기서 잠깐!

createSlice()함수를 보면 addTodo를 reducer로만 활용되는것처럼 보인다. 'todos/addTodo' action type을 받으면 같이 날라온 payload를 바탕으로 state를 업데이트한다.

import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      const { id, text } = action.payload
      state.push({ id, text, completed: false })
    },
    toggleTodo(state, action) {
      const todo = state.find(todo => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    }
  }
})
export const { addTodo, toggleTodo } = todosSlice.actions
export default todosSlice.reducer

근데, createAction()은 두번째 인자로 callback 함수를 넣어서 payload의 값을 내가 직접 만들 수 있다. 다시말해서 createSlice()는 creatAction으로 만들어진 action creator를 대체할 수 없다. 내가 직접 payload를 만들어서 action에 붙여서 dispatch해야하기 때문이다.

createSlice()는 이런 경우도 다~ 대비를 해놓았다. 아래처럼 쓰면 payload를 작성할 수 있는 callback함수를 사용할 수 있다.

import { createSlice } from '@reduxjs/toolkit'
let nextTodoId = 0
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer(state, action) {
        const { id, text } = action.payload
        state.push({ id, text, completed: false })
      },
      prepare(text) {
        return { payload: { text, id: nextTodoId++ } }
      }
    },
    toggleTodo(state, action) {
      const todo = state.find(todo => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    }
  }
})

Using the New Todos Slice

Updating the Root Reducer

rootReducer의 todos를 아래처럼 수정한다.

import { combineReducers } from 'redux'
import todosReducer from '../features/todos/todosSlice';
import visibilityFilter from './visibilityFilter'
export default combineReducers({
  todos: todosReducer,
  visibilityFilter
});

이것은 당연히 /features/todos/todosSlice 가 reducer를 export했기 때문이다.

const todosSlice = createSlice({
  ...
})
export const { addTodo, toggleTodo } = todosSlice.actions
export default todosSlice.reducer

Updating the Add Todo Component

지금까지 수정한거를 빌드해서 보면 화면에 그려지기는 하는데 AddTodo action이 제대로 작동을 안한다.

우리가 createSlice()로 만든 reducer는 'todos/addTodo' 를 기다리고 있는데, AddTodo.js 에서는 'ADD_TODO' type의 action을 보내주고 있기 때문에 제대로 작동을 안하는것이다. 그래서 아래와 같이 변경해준다.

import React from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../features/todos/todosSlice'
 // todoSlice에서 addTodo action creator를 가져옴
const AddTodo = ({ dispatch }) => {
  let input
  return (
    <div>
      <form onSubmit={e => {
        e.preventDefault()
        if (!input.value.trim()) {
          return
        }
        dispatch(addTodo(input.value))
        input.value = ''
      }}>
        <input ref={node => input = node} />
        <button type="submit">
          Add Todo
        </button>
      </form>
    </div>
  )
}
export default connect()(AddTodo)

todoSlice.js의 상태는 아래와 같다. (혹시 햇갈릴까봐)

import { createSlice } from '@reduxjs/toolkit'
let nextTodoId = 0
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer(state, action) {
        const { id, text } = action.payload
        state.push({ id, text, completed: false })
      },
      prepare(text) {
        return { payload: { text, id: nextTodoId++ } }
      }
    },
    toggleTodo: (state, action) => {
      const todo = state.find(todo => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    }
  }
})
export const { addTodo, toggleTodo } = todosSlice.actions
export default todosSlice.reducer

이렇게 action creator를 변경하긴 했지만, AddTodo Componenet는 요즘 React Style이 아니다. (1) 먼저는 Hook을 안썼다.

Hook을 써서 다시 코드를 재작성해보자.

import React, { useState } from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../features/todos/todosSlice'
const AddTodo = ({ dispatch }) => {
  let input
  const [todoText, setTodoText] = useState('');
  return (
    <div>
      <form onSubmit={e => {
        e.preventDefault()
        if (!input.value.trim()) {
          return
        }
        dispatch(addTodo(input.value))
        setTodoText(todoText)
      }}>
        <input ref={node => input = node} />
        <button type="submit">
          Add Todo
        </button>
      </form>
    </div>
  )
}
export default connect()(AddTodo)

대단히 바뀐건 없다. useState만 사용했을 뿐이다.

(2) ref를 사용했다. ref대신에 글자가 바뀔때마다 state를 업데이트 해주는게 요즘 react style이다.

import React, { useState } from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../features/todos/todosSlice'
const AddTodo = ({ dispatch }) => {
  const [todoText, setTodoText] = useState('');
  const onChange = e => setTodoText(e.target.value);
  return (
    <div>
      <form onSubmit={e => {
        e.preventDefault()
        if (!todoText.trim()) {
          return
        }
        dispatch(addTodo(todoText))
        setTodoText(todoText)
      }}>
        <input value={todoText} onChange={onChange}/>
        <button type="submit">
          Add Todo
        </button>
      </form>
    </div>
  )
}
export default connect()(AddTodo)

(3) 마지막으로 mapDispatch를 안썻다. 기본적으로 컴포넌트와 redux store를 연결(connect)시키면 해당 컴포넌트는 props로 dispatch함수를 받아서 써도 문제는 없는데, 사실 connect()의 두번째 인자로 action creator를 넘겨주는게 정석이다.

import React, { useState } from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../features/todos/todosSlice'
const mapDispatch = { addTodo }
const AddTodo = ({ addTodo }) => {
  const [todoText, setTodoText] = useState('');
  const onChange = e => setTodoText(e.target.value);
  return (
    <div>
      <form onSubmit={e => {
        e.preventDefault()
        if (!todoText.trim()) {
          return
        }
        addTodo(todoText)
        setTodoText(todoText)
      }}>
        <input value={todoText} onChange={onChange}/>
        <button type="submit">
          Add Todo
        </button>
      </form>
    </div>
  )
}
export default connect(null, mapDispatch)(AddTodo)

Updating the Todo List

VisibleTodoList.js 도 AddTodo.js와 마찬가지로 예전 방식의 action creator를 사용하고 있기 때문에 이거를 수정해주자.

import { connect } from 'react-redux'
import TodoList from '../components/TodoList'
import { toggleTodo } from '../features/todos/todosSlice'
import { VisibilityFilters } from '../actions'
const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case VisibilityFilters.SHOW_ALL:
      return todos
    case VisibilityFilters.SHOW_COMPLETED:
      return todos.filter(t => t.completed)
    case VisibilityFilters.SHOW_ACTIVE:
      return todos.filter(t => !t.completed)
    default:
      throw new Error('Unknown filter: ' + filter)
  }
}
const mapStateToProps = state => ({
  todos: getVisibleTodos(state.todos, state.visibilityFilter)
})
const mapDispatchToProps = { toggleTodo }
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

Creating and Using the Filters Slice

저 필터 로직도 createSlice()를 활용해서 더 심플하게 표현해보자.

Writing the Filters Slice

퍼져있는 filter관련 actionreducer를 하나로 묶기위해서 /features/filters/filterSlice.js를 아래와 같이 생한다.

import { createSlice } from '@reduxjs/toolkit'
export const VisibilityFilters = {
  SHOW_ALL: 'SHOW_ALL',
  SHOW_COMPLETED: 'SHOW_COMPLETED',
  SHOW_ACTIVE: 'SHOW_ACTIVE'
}
const filtersSlice = createSlice({
  name: 'visibilityFilters',
  initialState: VisibilityFilters.SHOW_ALL,
  reducers: {
    setVisibilityFilter(state, action) {
      return action.payload
    }
  }
})
export const { setVisibilityFilter } = filtersSlice.actions
export default filtersSlice.reducer

/actions/index.js 에 있던 VisibilityFilters를 지우고 여기로 가져왔다. 이렇게 하면 VisibilityFilters를 사용하고 있는 파일에서 에러가 막 나올것이다. 이거를 먼저 수정해주자.

/containers/VisibleTodoList.js 에도 아래와 같이 수정해주고

...
import { VisibilityFilters } from '../features/filters/filterSlice'
...

/components/Footer.js 도 아래와 같이 수정해준다.

...
import { VisibilityFilters } from '../features/filters/filterSlice'
...

Using the Filters Slice

우선 filterSlicereducercombineReducers에 적용해야 하니까 /reducers/index.js를 아래와 같이 수정한다.

import { combineReducers } from 'redux'
import todosReducer from '../features/todos/todosSlice';
import visibilityFilterReducer from '../features/filters/filterSlice';
export default combineReducers({
  todos: todosReducer,
  visibilityFilter: visibilityFilterReducer
});

그리고 실제적으로 actiondispatch되는 /containers/FilterLink.js를 아래와 같이 수정해준다.

import { connect } from 'react-redux'
import { setVisibilityFilter } from '../features/filters/filterSlice'
import Link from '../components/Link'
const mapStateToProps = (state, ownProps) => ({
  active: ownProps.filter === state.visibilityFilter
})
const mapDispatchToProps = {
  setVisibilityFilter
}
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Link)

그리고 /componenets/Link.js 는 다음과 같이 수정한다.

import React from 'react'
import PropTypes from 'prop-types'
const Link = ({ active, children, setVisibilityFilter, filter }) => (
    <button
       onClick={() => { setVisibilityFilter(filter) }}
       disabled={active}
       style={{
           marginLeft: '4px',
       }}
    >
      {children}
    </button>
)
Link.propTypes = {
  active: PropTypes.bool.isRequired,
  children: PropTypes.node.isRequired,
  setVisibilityFilter: PropTypes.func.isRequired,
  filter: PropTypes.string.isRequired,
}
export default Link

filter/components/Footer.js 에서 props로 전달된거고, setVisibilityFilter/containers/FilterLink.js 에서 mapDispatchToProps로 전달해 준거다.

Optimizing Todo Filtering

/containers/VisibleTodoList.js 파일을 보면

...
const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case VisibilityFilters.SHOW_ALL:
      return todos
    case VisibilityFilters.SHOW_COMPLETED:
      return todos.filter(t => t.completed)
    case VisibilityFilters.SHOW_ACTIVE:
      return todos.filter(t => !t.completed)
    default:
      throw new Error('Unknown filter: ' + filter)
  }
}
const mapStateToProps = state => ({
  todos: getVisibleTodos(state.todos, state.visibilityFilter)
})
const mapDispatchToProps = { toggleTodo }
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

이렇게 되어있는데, 이게 아주 비효율적인 코드이다. 왜냐하면, TodoListredux storeconnect 해놨기 때문에, redux store에 어떤 action이 와서 storestate값이 바뀌면 TodoListpropsgetVisibleTodos()를 호출한 결과값인 todos를 넘겨주기 때문이다. filtering과 아무 상관없는 action때문에 getVisibleTodos()가 계속 호출되는건 너무 비효율적이다. getVisibleTodos()는 새로운 배열을 만들어서 return해주기 때문이다. 그리고 이것은 TodoList컴포넌트의 re-rendering을 야기한다.

테스트 해봤더니 진짜 계속 rendering된다

그래서 이 문제를 reselect라는 라이브러리로 해결 할 수 있는데, RTK안에 이미 포함되어 있어서 바로 쓸 수있다.

import { connect } from 'react-redux'
import TodoList from '../components/TodoList'
import { toggleTodo } from '../features/todos/todosSlice'
import { VisibilityFilters } from '../features/filters/filterSlice'
import { createSelector } from '@reduxjs/toolkit'
const selectTodos = state => state.todos
const selectFilter = state => state.visibilityFilter
const selectVisibleTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case VisibilityFilters.SHOW_ALL:
        return todos
      case VisibilityFilters.SHOW_COMPLETED:
        return todos.filter(t => t.completed)
      case VisibilityFilters.SHOW_ACTIVE:
        return todos.filter(t => !t.completed)
      default: throw new Error('Unknown filter: ' + filter)
    }
  }
)
const mapStateToProps = state => ({
  todos: selectVisibleTodos(state)
})
const mapDispatchToProps = { toggleTodo }
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

createSelector()의 핵심은, state.todosstate.visibilityFilter중 하나라도 이전(cache 해놓음)과 다른게 있으면 (todos, filter) => { switch ... } 함수를 호출하고, 이전과 같다면 호출하지 않고 저장해놓은 값을 돌려준다는 것이다.

이렇게 해서 아무 상관없는 actiondispatch되도 새로운 todos배열을 만들지도 않고, 또 그래서 TodoListre-rendering되지 않는다.

Cleanup

이제 안쓰는 파일들을 지워야한다. actions/index.js , reducers/todos.js, visibilityFilter.js 는 이제 확실이 안쓰니까 지운자.

그리고 컴포넌트들도 관련있는 feature폴더에 넣어준다. 그래서 아래와 같은 폴더 구조를 갖게된다.

Summary

이번 글에서 우리는 다음과 같은것들을 알게되었다.

  1. actionreducercreateSlice()를 사용해 하나로 묶을 수 있다.
  2. reducerreturn하는 state를 편하게 수정했다. (기본으로 포함되어있는 immer.js 덕분에)
  3. prepare를 통해서 payload를 dynamic하게 만들 수 있다.
  4. createSelector()를 통해 불필요한 state의 변경을 줄일 수 있다. (re-render도 줄어든다)
  5. mapDispatch를 통해 action을 좀 더 깔끔하게 dispatch할 수있다.
  6. "feature folder" 구조로 파일을 관리하는게 기존의 방식(컴포넌트-action-reducer나누기) 보다 관리하기가 좋다.

Add Comment