이번 포스팅에서는 Redux Toolkit을 사용하여 보다 효율적으로 Redux를 사용하는 방법을 정리해보고자 한다.
목차
- Redux Toolkit 이란?
- Redux Toolkit 설치
- Redux Toolkit 기능과 사용 예시
1. Redux Toolkit 이란?
Redux Toolkit은 리덕스의 복잡한 부분을 간소화하여 리덕스를 보다 쉽고 간편하게 사용할 수 있도록 만들어진 라이브러리이다. Redux Toolkit은 기존 Redux를 사용했을때 대비 아래와 같은 이점이 있다.
- 초기 저장소를 설정을 기존 Redux 저장소 설정에 비해 간편하게 할 수 있음.
- 기존 Redux 코드의 불필요한 반복을 줄이고 보다 간편한 코드로 작성할 수 있는 이점이 있음
- Redux 사용에 용이한 패키지(DevTools, Thunk 등)들이 기본적으로 설치되어 있어 별도의 설치 필요 없음.
- Immutable.js, Immer와 같은 라이브러리를 사용하여 불변성 유지를 자동으로 처리
Redux Toolkit의 사용이 필수적인 것은 아니지만 Redux Toolkit의 공식 홈페이지(https://redux-toolkit.js.org/)에서는 보다 간편하고 빠르게 Redux 앱을 개발할 수 있도록 Redux Toolkit의 사용을 권장하고 있다.
2. Redux Toolkit 설치
1) react-create-app 으로 설치시
# Redux JS template
npx create-react-app my-app --template redux
# Redux TS template
npx create-react-app my-app --template redux-typescript
2) node 앱에서 패키지로 설치시
# NPM
npm install @reduxjs/toolkit
# Yarn
yarn add @reduxjs/toolkit
3. Redux Toolkit 기능과 사용 예시
1) 저장소(Store) 생성 및 미들웨어 설정
import rootReducer from './reducers';
import { configureStore } from '@reduxjs/toolkit';
const loggerMiddleware = (store: any) => (next: any) => (action: any) => {
console.log('첫번째 미들웨어');
console.log('store', store);
console.log('action', action);
next(action);
};
const secondLoggerMiddleware = (store: any) => (next: any) => (action: any) => {
console.log('두번째 미들웨어');
next(action);
};
// reducer, middleware, devtools를 직관적으로 간편하게 설정 가능
const store = configureStore({
reducer: rootReducer,
// reducer: {posts: postsReducer, counter: counterReducer} -> 이렇게 호출시 combineReducer처럼 합쳐줌
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(loggerMiddleware, secondLoggerMiddleware),
devTools: true,
});
redux/toolkit에서 제공하는 configureStore를 사용하여 저장소를 생성한다.
- reducer: combineReducer로 합쳐진 reducer를 넣어주거나 slice의 reducer를 각각 호출해서 객체로 넣어주면 configureStore에서combineReducer를 호출하여 각 reducer를 합쳐준다.
- middleware: getDefaultMiddleware와 함게 redux toolkit에서 제공하는 기본 미들웨어를 호출하고 사용자가 지정한 다른 미들웨어를 넣어서 같이 사용할 수 있다.
- devTools : 개발자 도구에서 사용하는 redux DevTools의 사용여부를 boolean 값으로 설정한다.
2) createAction을 사용하여 액션 생성
기존 Redux 에서는 액션을 정의할때 액션생성자 함수를 따로 선언하여 사용했었지만 Redux Toolkit에서는 createAction() 함수를 사용하여 간단하게 액션을 생성할 수 있다.
// before
const INCREMENT = 'counter/increment'
function increment(amount: number) {
return {
type: INCREMENT,
payload: amount,
}
}
const action = increment(3) // { type: 'counter/increment', payload: 3 }
// after
import { createAction } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
const action = increment(3) // { type: 'counter/increment', payload: 3 }
3) createReducer 를 사용한 reducer 생성
저장소의 상태값들을 업데이트 해주는 reducer 를 생성하는 함수이다. immer 라이브러리를 사용하여 불변성이 유지될 수 있도록 해준다.
redux-toolkit에서 액션을 처리하기 위해 case reducer를 정의하는 방법은 builder callback과 map object 표기법 두가지 방법이 있지만 TypeScript와의 호환성을 위해 builder callback을 권장한다.
- builderCallback
-> builder.addCase(actionCreator, reducer): 액션 타입과 정확히 맵핑되는 case reducer를 추가하여 해당 액션을 처리, ✔︎addMatcher 와 addDefaultCase 보다 먼저 작성해야함
import { createAction, createReducer } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const increment = createAction('counter/increment');
const decrement = createAction('counter/decrement');
const incrementByAmount = createAction<number>('counter/incrementByAmount');
const initialState = { value: 0 } as CounterState;
const counter = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state, action) => {
state.value += 1;
})
.addCase(decrement, (state, action) => {
state.value -= 1;
})
.addCase(incrementByAmount, (state, action) => {
state.value += action.payload;
});
});
export default counter;
-> builder.addMatcher(matcher, reducer): 새로 들어오는 모든 액션에 대해서 주어진 패턴과 일치하는지 확인하고 리듀서를 실행.
import { createReducer, AsyncThunk, AnyAction} from '@reduxjs/toolkit'
type GenericAsyncThunk = AsyncThunk<unknown, unknown, any>
type PendingAction = ReturnType<GenericAsyncThunk['pending']>
type RejectedAction = ReturnType<GenericAsyncThunk['rejected']>
type FulfilledAction = ReturnType<GenericAsyncThunk['fulfilled']>
function isPendingAction(action: AnyAction): action is PendingAction {
return action.type.endsWith('/pending')
}
const reducer = createReducer(initialState, (builder) => {
builder
//matcher가 밖에서 함수로 정의
.addMatcher(isPendingAction, (state, action) => {
state[action.meta.requestId] = 'pending'
})
// matcher가 inline으로 정의
.addMatcher(
(action): action is RejectedAction => action.type.endsWith('/rejected'),
(state, action) => {
state[action.meta.requestId] = 'rejected'
}
)
// matcher가 generic argument를 받아서 처리
.addMatcher<FulfilledAction>(
(action) => action.type.endsWith('/fulfilled'),
(state, action) => {
state[action.meta.requestId] = 'fulfilled'
}
)
})
-> builder.addDefaultCase(reducer) : 그 어떤 case reducer나 matcher 리듀서도 실행되지 않았다면 기본 케이스 리듀서를 실행
const reducer = createReducer(initialState, (builder) => {
builder
// .addCase(...)
// .addMatcher(...)
.addDefaultCase((state, action) => {
state.otherActions++
})
})
-Map Object
액션 타입을 key 값으로 사용하고 해당 액션타입을 처리하는 case reducer를 value로 사용한다. JS에서 사용이 유효하기에 TypeScript사용시에는 builder callback 표기법을 권장한다.
const counterReducer = createReducer(0, {
increment: (state, action) => state + action.payload,
decrement: (state, action) => state - action.payload
})
//createAction 으로 생성된 액션 객체의 프로퍼티 값을 키값으로 사용가능
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
})
4) createSlice()를 사용 action과 reducer를 한번에 생성하기
createSlice를 사용하여 생성한 action과 reducer를 따로 따로 생성하지 않고 한번에 생성할 수 있다.
createSlice 내부에서 createAction과 createReducer를 사용하고 있기 때문이다. reducer 함수의 대상인 초기상태(initial state)와 slice 이름을 받아와서 액션 생성자와 액션타입을 자동으로 생성한다.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface CounterState {
value: number
}
const initialState = { value: 0 } as CounterState
interface Item {
cnt: number;
}
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: {
reducer: (state, action: PayloadAction<Item>) => {
state.value += action.payload.cnt;
},
// action의 매개변수를 전처리가 필요하다면 prepare를 정의하여 사용(기본값: reducer)
// typescript 사용시 reducer의 매개변수로 사용되는 action의 타입을 반드시 정의(PayloadAction)
prepare: (cnt: number) => {
return { payload: { cnt } };
},
},
decrement(state) {
state.value--
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload
},
},
extraReducers: (builder) => {
builder
.addCase(incrementBy, (state, action) => {
return state + action.payload
})
}
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
action을 dispatch 시 action 의 매개변수에 전처리가 필요하다면 위 코드처럼 prepare를 따로 정의하여 사용할 수 있다.
위 코드를 보면 reducer이외에 extraReducer가 작성이 되어있는 것을 확인할 수 있는데 extraReducer는 createSlice가 생성한 action type외에 다른 action type에 응답할 수 있다.
5) createAsyncThunk로 비동기 처리하기
createAsyncThunk 는 Redux Toolkit에서 제공하는 유틸리티 함수로 비동기 작업을 처리하는 Redux Thunk를 쉽게 생성할 수 있도록 도와주는 함수이다. 해당 액션 타입을 호출하기 위한 문자열과 프로미스를 반환하는 비동기함수를 인자로 받는다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
const usersSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle',
},
reducers: {},
// 비동기 처리 작업의 진행상태 따른 리듀서 실행
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.entities.push(action.payload)
})
.addCase(fetchUserById.rejected, (state) => {})
},
})
const UsersComponent = () => {
const { users, loading, error } = useSelector((state) => state.users)
const dispatch = useDispatch()
const getUser = async (userId) => {
const user = await dispatch(fetchUserById(userId))
}
}
createAsyncThunk를 사용하여 비동기 작업의 성공 및 실패에 따른 액션을 생성할 수 있다. 비동기 작업이 성공할 경우에는 'fullfilled' 상태의 액션을 , 실패할 경우에는 'rejected' 상태의 액션을 자동을 생성한다. 이를 통해 간단하게 진행 상태에 따라 리듀서를 실행하고 비동기작업의 결과를 redux에 반영할 수 있다.