상태 관리 라이브러리인 Redux를 공식 문서에서 적극 사용을 권장하는 redux-toolkit을 톺아보면서 조금 더 제대로 된 사용법을 익혀보고자 한다.
[ Redux Toolkit ]
- Redux Toolkit
# NPM
npm install @reduxjs/toolkit
# Yarn
yarn add @reduxjs/toolkit
Redux Toolkit은 효율적인 Redux 개발을 위해 개발되었으며, Redux 로직을 작성하기 위한 표준 방식이 되도록 만들어졌으며, 사용하기를 적극 추천한다.
Redux Toolkit에는 저장소 준비, 리듀서 정의, 불변 업데이트 로직, 액션 생산자나 액션 타입을 직접 작성하지 않고도 전체 상태 "조각"을 만들어내는 기능까지 Redux에 사용되는 유틸리티 함수들이 들어있다.
Redux Toolkit을 사용할 경우 첫 프로젝트에 Redux를 새로 도입하든 기존 앱에 적용하든 상관없이 일반적인 작업들을 단순화해 주는 유틸리티를 사용할 수 있어 보다 나은 Redux 코드를 만들 수 있도록 해준다.
거기다가 비동기 로직을 위한 Redux Thunk와 셀렉터 작성을 위한 Reselect 등의 널리 사용되는 애드온을 포함하고 있어 이들을 제대로 사용할 수 있게 해 준다.
👍🏻 실제로 Redux Toolkit 사용 전과 사용 후를 비교해 보면 사용 전 많은 보일러 플레이트 코드를 사용 후 어느 정도의 개선이 있었다.
[ 왜 Redux Toolkit 만들었을까? ]
Redux 코어 라이브러리는 의도적으로 특정한 방향을 배제하고 만들어졌다. 이를 통해 저장소 준비, 상태 보관, 리듀서 작성과 같은 모든 것들을 사용자가 결정하게 했다.
이러한 융통성이 어떤 경우에는 좋지만, 항상 필요한 것은 아니다. 쓸만한 기본 동작을 바로 사용하는 간단한 방법이나 커다란 애플리케이션을 만들다 보니 비슷한 코드를 계속 작성하고 되었고, 직접 작성하는 코드의 양을 죽이는 방법이 있으면 좋을 것이다.
Redux Toolkit은 Redux에 대한 아래의 불편함을 해결하기 위해 만들어졌다.
- 복잡한 저장소 설정
- 쓸만하게 만들기 위한 많은 패키지 설치
- 너무 많은 보일러 플레이트 코드
[ 왜 Redux Toolkit을 사용해야 할까? ]
사용 이유는 간단하고 명확하다. 보일러 플레이트 코드를 줄여 간단한 코드를 작성하게 되니 실수도 그만큼 줄 것이다. 이를 통해 Redux를 사용한 애플리케이션을 빠르게 개발할 수 있다. 또한 좋은 코드를 작성할 수 있으며 이로 인해 유지 보수를 쉽게 만들어 준다. 공식 문서에는 추가적인 사용 이점이 작성되어 있지만 앞서 작성한 이점만 생각하더라도 Redux Toolkit 사용은 필수로 느껴진다.
[ Redux Toolkit 유틸리티 ]
- configureStore()
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({ reducer: rootReducer })
createStore를 감싸서 쓸만한 기본값들과 단순화된 설정을 제공한다. 사용자가 만든 여러 개의 리듀서들을 자동으로 합쳐주고, 기본 제공되는 redux-thunk를 포함해서 지정한 미들웨어들을 더해주고, Redux DevTools 확장을 사용할 수 있게 해 준다.
- createReducer()
switch 문을 작성하는 대신, 액션 타입과 리듀서 함수를 연결해 주는 목록을 작성하도록 한다. 여기에 더해 immer 라이브러리를 자동으로 사용하여, state.todos [3]. completed = true와 같은 변이 코드를 간편하게 불변 업데이트할 수 있도록 한다.
사용 전
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
switch (action.type) {
case 'increment':
return { ...state, value: state.value + 1 }
case 'decrement':
return { ...state, value: state.value - 1 }
case 'incrementByAmount':
return { ...state, value: state.value + action.payload }
default:
return state
}
}
Redux Toolkit 사용 전의 경우 액션 타입의 기본 케이스(default)나 초기 상태 설정을 잊는 등으로 인한 오류가 발생하기 쉽다.
사용 후
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction('counter/incrementByAmount')
const initialState = { value: 0 }
const counterReducer = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state, action) => {
state.value++
})
.addCase(decrement, (state, action) => {
state.value--
})
.addCase(incrementByAmount, (state, action) => {
state.value += action.payload
})
})
Redux Toolkit의 createReducer를 사용하게 되면 리듀서 구현이 간소화된다. 이러한 작업을 처리하기 위해 케이스 리듀서를 정의하는 두 가지 방법인 "빌더 콜백" 표기법과 "맵 객체" 표기법을 지원한다.(둘 다 동일하지만 "빌더 콜백" 표기법을 선호)
"빌더 콜백"
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('increment')
const decrement = createAction('decrement')
function isActionWithNumberPayload(action) {
return typeof action.payload === 'number'
}
const reducer = createReducer(
{
counter: 0,
sumOfNumberPayloads: 0,
unhandledActions: 0,
},
(builder) => {
builder
.addCase(increment, (state, action) => {
// 액션은 여기서 정확하게 추론된다.
state.counter += action.payload
})
// 호출을 연결하거나 매번 별도의 `builder.addCase()` 줄을 사용할 수 있다.
.addCase(decrement, (state, action) => {
state.counter -= action.payload
})
// 들어오는 액션에 "매칭 함수"를 적용 적용할 수 있다.
.addMatcher(isActionWithNumberPayload, (state, action) => {})
// 일치하는 다른 핸들러가 없는 경우 기본 케이스를 제공할 수 있다.
.addDefaultCase((state, action) => {})
}
)
builder는 리듀서가 처리할 액션을 정의하기 위해 호출할 수 있는 addCase, addMatcher, addDefaultCase 함수를 제공한다.
앞서 언급한 유틸리티인 createReducer 권장 사용 방법은 빌더 콜백 표기법인데, 이는 타입스크립트와 대부분의 IDE에서 가장 잘 작동하기 때문이다.
- 파라미터
initialState State | (() => State)
리듀서를 처음 호출할 때 사용해야 하는 초기 상태이다. 또한 호출될 때 초기 상태 값을 반환해야 하는 지연 초기화(lazy initializer) 함수일 수 있다. 이는 정의되지 않은 상태 값을 사용하여 리듀서를 호출할 때마다 사용되며, 주로 localStorage에서 초기 상태를 읽는 경우에 유용하다.
- 빌더 메서드
addCase 메서드는 하나의 정확한 액션 타입을 처리하기 위해 케이스 리듀서를 추가한다. builder.addCase에 대한 모든 호출은 builder.addMatcher 또는 builder.addDefaultCase에 대한 호출보다 먼저 와야 한다.
액션의 유형을 나타내는 문자열 또는 액션 유형을 결정하는 데 사용할 수 있는 createAction 유틸리티에 의해 생성된 actionCreator, 실제 리듀서 함수 Reducer를 파라미터에 전달받는다.
addMatcher 메서드는 액션의 타입(action.type)만 지정하는 대신 수신 작업을 고유의 필터 함수와 일치시킬 수 있다.
import { createAction, createReducer } from '@reduxjs/toolkit'
const initialState = {}
const resetAction = createAction('reset-tracked-loading-state')
function isPendingAction(action) {
return action.type.endsWith('/pending')
}
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(resetAction, () => initialState)
// matcher는 타입 서술 함수로 외부에 정의 될 수 있다.
.addMatcher(isPendingAction, (state, action) => {
state[action.meta.requestId] = 'pending'
})
// matcher는 타입 서술 함수로 인라인으로 정의될 수 있다.
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state[action.meta.requestId] = 'rejected'
}
)
// matcher는 boolean을 반환할 수 있고 matcher는 일반 인수를 수신할 수 있다.
.addMatcher(
(action) => action.type.endsWith('/fulfilled'),
(state, action) => {
state[action.meta.requestId] = 'fulfilled'
}
)
})
여러 개의 matcher 리듀서와 일치하는 경우 이미 케이스 리듀서가 일치하는 경우에도 정의된 순서대로 모두 실행된다. builder.addMathcer에 대한 모든 호출은 builder.addCase에 대한 호출 후 builder.addDefaultCase에 대한 호출 전에 와야 한다.
matcher(타입스크립트에서 타입이 서술된 함수여야 한다.) 함수와 실제 리듀서 함수를 파라미터로 전달받는다.
addDefaultCase 메서드는 액션에 대한 리듀서가 실행되지 않은 경우 실행되는 기본 리듀서를 추가한다.
import { createReducer } from '@reduxjs/toolkit'
const initialState = { otherActions: 0 }
const reducer = createReducer(initialState, (builder) => {
builder
// .addCase(...)
// .addMatcher(...)
.addDefaultCase((state, action) => {
state.otherActions++
})
})
default case의 리듀서 함수를 파라미터로 전달받는다.
"맵 개체 표기법"
맵 개체 표기법은 키가 문자열 액션 타입이고, 값이 해당 액션 타입을 처리하는 케이스 리듀서를 줄이는 함수인 개체를 허용한다. 이 경우 코드의 길이가 조금 짧지만 타입스크립트가 아닌 자바스크립트에서만 작동하며 IDE와의 통합이 적기 때문에 대부분의 경우 "빌더 콜백" 표기법을 권장한다.
- 파라미터
initialState State | (() => State)
리듀서를 처음 호출할 때 사용해야 하는 초기 상태이다. 초기 상태 값을 반환해야 하는 지연 초기화(lazy initializer) 함수일 수 있다. 정의되지 않은 상태 값(undefined)을 사용하여 리듀서를 호출할 때마다 사용되며 localStroage에서 초기 상태를 읽는 경우에 유용하다.
actionsMap
각각 하나의 특정 액션 타입을 처리하는 액션 타입에서 case reducer로의 객체를 매핑한다.
actionMatchers
{matcher, reducer} 형식의 matcher 정의 배열이다. 일치하는 모든 리듀서는 케이스 리듀서가 일치하는지 여부에 상관없이 순서대로 실행된다.
defaultCaseReducer
액션에 대해 케이스 리듀서 및 매처 리듀서가 실행되지 않은 경우 실행되는 기본 케이스 리듀서이다.
- 반환
const counterReducer = createReducer(0, {
increment: (state, action) => state + action.payload,
decrement: (state, action) => state - action.payload,
})
console.log(counterReducer.getInitialState()) // 0
생성된 리듀서 함수를 반환한다. 리듀서에는 호출 시 초기 상태를 반환하는 getInitialState 함수가 연결되며, 이는 테스트 또는 리액트의 useReducer hook에 유용하다.
- 사용 예시
const counterReducer = createReducer(0, {
increment: (state, action) => state + action.payload,
decrement: (state, action) => state - action.payload
})
// "lazy initializer"를 사용하여 초기 상태를 제공한다.
// ( createReducer 형식 중 하나와 함께 작동 )
const initialState = () => 0
const counterReducer = createReducer(initialState, {
increment: (state, action) => state + action.payload,
decrement: (state, action) => state - action.payload
})
const increment = createAction('increment')
const decrement = createAction('decrement')
const counterReducer = createReducer(0, {
[increment]: (state, action) => state + action.payload,
[decrement.type]: (state, action) => state - action.payload
})
createAction을 사용하여 생성된 액션 작성자는 직접 키로 사용할 수 있다.
- 인수로서의 매처 또는 디폴트 케이스
const isStringPayloadAction = (action) => typeof action.payload === 'string'
const lengthOfAllStringsReducer = createReducer(
// 초기 상태 값
{ strLen: 0, nonStringActions: 0 },
// normal reducers
{
/*...*/
},
// 매처 리듀서 배열
[
{
matcher: isStringPayloadAction,
reducer(state, action) {
state.strLen += action.payload.length
},
},
],
// 디폴트 리듀서
(state) => {
state.nonStringActions++
}
)
매처 케이스 및 디폴트 케이스를 정의하는 가장 읽기 쉬운 방법은 위에서 설명한 builder.addMatcher 및 builder.addDefaultCase 메서드를 사용하는 것이지만, {matcher, reducer} 개의 개체 배열을 세 번째 인수로 전달하고 네 번째 인수로 기본 사례 리듀서를 전달하여 개체 표기법과 함께 사용할 수도 있다.
- 직접 상태 변환
리덕스는 리듀서 함수가 순수해야 하며 상태 값을 불변으로 취급해야 한다. 이것은 상태 업데이트를 예측 가능하고 관찰 가능하게 만드는 데 필수적이지만, 때때로 이러한 업데이트의 구현을 어색하게 만든다. 다음과 같은 예를 생각해 보자.
import { createAction, createReducer } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add')
const toggleTodo = createAction('todos/toggle')
const todosReducer = createReducer([], (builder) => {
builder
.addCase(addTodo, (state, action) => {
const todo = action.payload
return [...state, todo]
})
.addCase(toggleTodo, (state, action) => {
const index = action.payload
const todo = state[index]
return [
...state.slice(0, index),
{ ...todo, completed: !todo.completed },
...state.slice(index + 1),
]
})
})
addTodo 리듀서는 ES6 spread syntax만 알면 간단하다. 그러나 특히 toggleTodo의 코드는 특히 단일 플래그만 설정한다는 점을 고려하면 훨씬 덜 간단하다.
import { createAction, createReducer } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add')
const toggleTodo = createAction('todos/toggle')
const todosReducer = createReducer([], (builder) => {
builder
.addCase(addTodo, (state, action) => {
// push()는 앞의 예제와 같이 확장 배열을 생성하여 동일하게 변환된다.
const todo = action.payload
state.push(todo)
})
.addCase(toggleTodo, (state, action) => {
// 이 케이스 리듀서의 명시적으로 이전 버전보다 훨씬 직접적으로 변환한다.
const index = action.payload
const todo = state[index]
todo.completed = !todo.completed
})
})
앞선 예제와 비교하면 보다 간편하게, 직접적으로 state의 값을 변환한다. 하지만 state의 값을 직접적으로 변환하는 것으로 보일 뿐 내부의 immer가 작동하여 여전히 불변성을 유지하고 있다.
createReducer는 immer를 사용하여 상태를 직접 변환하는 것처럼 상태를 직접 변환하는 것처럼 리듀서를 작성할 수 있게 해 준다.
이러한 "mutating" 리듀서를 작성하면 코드가 단순해진다. 간접적으로 값을 변화시키지 않아 중첩된 상태를 전파하는 동안 방생하는 일반적인 실수를 제거할 수 있다. 그러나 immer의 사용은 약간의 "magic"을 추가하며, immer는 행동에 있어서 그 나름의 뉘앙스를 가지고 있다.
가장 중요한 것은 state 인수를 변경하거나 새로운 state를 반환해야 하지만 둘 다 반환해서는 안된다. 예를 들어 다음 예제의 리듀서는 toggleToDo 액션이 전달되면 예외를 발생시킨다.
import { createReducer } from '@reduxjs/toolkit'
const reducer = createReducer(0, (builder) => {
builder
.addCase('increment', (state) => state + 1)
.addMatcher(
(action) => action.type.startsWith('i'),
(state) => state * 5
)
.addMatcher(
(action) => action.type.endsWith('t'),
(state) => state + 2
)
})
console.log(reducer(0, { type: 'increment' }))
// 'increment' case와 두 matchers가 모두 순차적으로 실행됨에 따라 7을 리턴한다.
// - case 'increment": 0 => 1
// - matcher starts with 'i': 1 => 5
// - matcher ends with 't': 5 => 7
1을 기대했지만 케이스 리듀서와 매처 리듀서가 모두 순차적으로 실행되어 7을 리턴하는 예외가 발생했다.
- 임시 상태 값 로깅
개발자가 개발 과정에서 콘솔로 state를 출력하는 것은 매우 흔한 일이다. 그러나 브라우저는 읽기 어려운 형식으로 프락시를 표시하므로 immer 기반 상태의 콘솔 로깅이 어려울 수 있다.
import { createSlice, current } from '@reduxjs/toolkit'
const slice = createSlice({
name: 'todos',
initialState: [{ id: 1, title: 'Example todo' }],
reducers: {
addTodo: (state, action) => {
console.log('before', current(state))
state.push(action.payload)
console.log('after', current(state))
},
},
})
createSlice 또는 createReducer를 사용할 때 immer 라이브러리에서 다시 내보낸 현재 유틸리티를 사용할 수 있다. 이 유틸리티는 현재 immer Draft state value의 일반 복사본을 별도로 생성하며, 이 복사본을 로그에 기록하여 정상적으로 볼 수 있다.
- createAction()
주어진 액션 타입 문자열을 이용해 액션 생산자 함수를 만들어 준다. 함수 자체에 toString() 정의가 포함되어 있어 타입 상수가 필요한 곳에 사용할 수 있다.
createAction 유틸리티를 사용하니 액션 타입의 오타 걱정을 더 이상 하지 않아도 되게 되었다.
* createAction 사용 예시는 createReducer 사용 예시에 포함되어 있으니 참고.
- createSlice()
slice의 name(이름), initialState(초기값), reducers(리듀스 함수)로 이루어진 객체를 받아 그에 맞는 액션 생성자와 액션 타입을 포함하는 리듀서 slice를 자동으로 만들어 준다.
- createAsyncThunk
액션 타입 문자열과 프로미스를 반환하는 함수를 받아, pending / fulfilled / rejected 액션 타입을 디스 패치해 주는 thunk를 생성해 준다.
- createEntityAdapter
저장소 내에 정규화된 데이터를 다루기 위한 리듀서와 셀렉터를 만들어 준다.
- createSelector
유틸리티를 Reselect 라이브러리에서 다시 export 해서 사용하기 쉽게 해 준다.
[ Redux toolkit 적용 ]
가장 중요한 사용 방법을 공식 문서를 통해 배워보자.
npm install @reduxjs/toolkit react-redux
프로젝트에 Redux Toolkit 및 React-Redux 패키지를 추가한다.
import { configureStore } from '@reduxjs/toolkit'
export default configureStore({
reducer: {}
})
src / app / store.js 경로로 store 파일을 생성 하고 위와 같이 Redux Tookit에서 configureStore API를 가져온다. 빈 리덕스 스토어를 만들고 내보는 것으로 시작하자. 위와 같이 진행하면 리덕스 스토어가 생성되고 Redux DevTools 확장을 자동으로 구성하여 개발하는 동안 스토어을 검사할 수 있다.
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import store from './app/store'
import { Provider } from 'react-redux'
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Provider를 애플리케이션 주변에 배치하면 리액트 구성요소에서 스토어를 사용할 수 있게된다.
// src/features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment: state => {
// 리덕스 툴킷을 사용하면 리듀서 내부에 뮤테이팅 로직 작성이 가능하다.
// 하지만 이것은 실제로 상태를 변형시키는 것이 아니다.
// Draft state의 변화를 감지하고 그 변화를 기반으로 새오운 불변 상태를 생성하는
// immer 라이브러리를 사용하기 때문이다.
state.value += 1
},
decrement: state => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
}
}
})
// 각 케이스 리듀서 함수에 대해 액션 생성자가 생성된다.
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
src / features / counter / counterSlice.js 의 경로로 파일을 생성한다. 해당 파일에서 Redux toolkit의 createSlice API를 가져온다.
export const counterSlice = createSlice({
name: 식별 문자열 이름,
initialState: 초기 상태 값,
reducers: 리듀서 함수 객체
})
슬라이스를 생성하려면 슬라이스를 식별하기 위한 문자열 이름, 초기 상태 값 및 상태를 업데이트 할 수 있는 방법을 정의 하는 하나 이상의 리듀서 함수가 필요하다. 슬라이스가 생성되면 생성된 리덕스 액션 생성자와 전체 슬라이스에 대한 리듀서 기능을 내보낼 수 있다.
❗️리덕스는 데이터 복사본을 만들고 본사본을 업데이드하여 모든 상태 업데이트를 불변으로 작성하도록 요구한다. 하지만 리덕스 툴킷의 createSlice, createReducer API는 내부에서 immer를 사용하여 올바른 불변 업데이트가 되는 mutating 업데이트 논리를 작성할 수 있도록 해준다.
// app / store.js
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
export default configureStore({
reducer: {
counter: counterReducer
}
})
다음으로 카운터 슬라이스에서 리듀서 함수를 가져와서 스토어에 추가해야 한다. 매개 변수 내부에 필드를 정의하여 reducer에 해당 상태에 대한 모든 업데이트를 처리하기 위해 리듀서 함수를 사용하도록 store에 지시한다.
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { decrement, increment } from './counterSlice'
import styles from './Counter.module.css'
export function Counter() {
const count = useSelector(state => state.counter.value)
const dispatch = useDispatch()
return (
<div>
<div>
<button
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
Increment
</button>
<span>{count}</span>
<button
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
Decrement
</button>
</div>
</div>
)
}
이제 React-Redux hook를 사용하여 리액트 구성요소가 리덕스 스토어와 상호 작용할 수 있다. useSelector를 사용하여 작업을 스토어에 저장된 데이터를 읽을 수 있고 useDispatch를 사용하여 액션을 디스패치할 수 있다.
이제 "Increment", "Decrement" 버튼을 클릭할 때 마다 아래의 작업이 진행되게 된다.
- 해당 액션이 리덕스 스토어로 디스패치된다.
- 카운터 슬라이스 리듀서는 액션을 확인하고 상태를 업데이트한다.
- Counter 컴포넌트는 스토어에서 새로운 상태 값을 확인하고 새 데이터로 다시 렌더링한다.
😭 여전히 영어로 된 공식 문서로 공부하는데 어려움이 크다. 하지만 공식 문서로 공부하면서 얻는 가장 큰 장점은 정확한 기술 내용인 것같다.
✏️ immer를 내부적으로 사용하여 state의 값을 보다 쉽게 바꿀 수 있어 편리하다고만 생각하고 사용하던 redux-toolkit에 대해 보다 깊이 이해하고 알 수 있었다. 또한 평소 createSlice만 사용했었는데 이번 기회에 crateReducer에 대해 살펴보고 알 수 있어 좋았다.
'React > 상태관리하기' 카테고리의 다른 글
MobX 톺아보기 0 - 컨셉, 원칙 (0) | 2023.02.13 |
---|---|
Redux 톺아보기 2 - (비동기 로직) (0) | 2023.02.11 |
Redux 톺아보기 0 - ( 핵심 컨셉, 적절한 사용, 개념 및 용어 ) (0) | 2023.02.10 |
Redux toolkit 사용 (0) | 2022.11.08 |
Redux React에 적용하기 (0) | 2022.11.08 |