redux-toolkit 을 사용해 redux 작성 하기 (createAction, createReducer, createSlice, createAsyncThunk)
redux-toolkit
redux-toolkit
은 redux 에서 공식적으로 지원하는 redux 개발 도구 입니다. redux-toolkit은 기존 redux를 사용할때 다음과 같은 문제들에 대해 해결하기 위해 만들어졌습니다.
- redux 설정의 복잡성
- redux를 사용하기 위해 설치하는 패키지 양이 늘어나는 문제
- redux 내에서 작업 하나를 만들기 위해 필요한 boilerplate 가 너무 많은 문제
이를 위해 immer produce
,reselect
, ducks pattern
, Redux Devtools
, FSA 규약
, typescript
, 미들웨어(thunk 한정)
등을 지원하고 있습니다. redux-toolkit 을 사용하면 redux 에서 공식적으로 제공하는 best-practice 의 방법으로 redux 환경을 구성 하실 수 있습니다.
이번시간에는 다음과 같이 4개의 함수에 대해서 먼저 알아보도록 하겠습니다.
- createAction,
- createReducer,
- createSlice,
- createAsyncThunk
좀더 자세한 내용은 redux-toolkit에서 확인하실수 있습니다.
설치
1. create-react-app 생성시 바로 추가 하기
create-react-app 으로 프로젝트를 처음부터 구성하시는 경우는 다음과 같이 template 옵션에 redux를 추가해 주도록 하겠습니다.
npx create-react-app my-app --template redux
2, 기존 프로젝트에 추가
기존 프로젝트의 추가하는 경우는 @reduxjs/toolkit 만 패키지에 추가해 주도록 하겠습니다.
# NPM npm install @reduxjs/toolkit # Yarn yarn add @reduxjs/toolkit
configureStore
기존에 redux애서 store 를 생성할 경우 미들웨어가 한개 이상이라면 applyMiddleware
를 통해 미들웨어를 합쳐야 하는 부분과 개발도구 확장 프로램인 redux-devtools-extension
을 사용하려면 사용하는 composeWithDevTools
를 통해 또다시 middleware 를 추가해야하는등 아래 예제 코드와 같은 번거로움이 있었습니다.
기존 createStore 를 통한 store 생성
const store = createStore(reducers, composeWithDevTools(applyMiddleware([...middlewares])));
redux-toolkit
에서 제공하는 configureStore
를 사용한다면 이런 번거로움을 간결하게 정리해 줍니다.
configureStore 를 통한 store 생성
const store = configureStore({ reducer : ruducers, // 리듀서 들을 정의합니다. middleware: [...middlewares], // 미들웨어를 정의해주도록 합니다. })
위의 예제를 보면 별도의 메소드 없이 바로 미들웨어를 추가할 수 있는 부분을 보실 수 있습니다. 또한 configureStore
에는 기본적으로 reduxtool 이 사용되도록 적용되어 있어 별도의 설정이 필요없습니다. :)
실 서비스 와 같은 개발도구가 보이면 안되는 상황에선 다음과 같이 사용설정을 변경할 수도 있습니다.
개발도구 on,off 옵션 설정 주기.
const store = configureStore({ reducer : ruducers, // 리듀서 들을 정의합니다. middleware: [...middlewares], // 미들웨어를 정의해주도록 합니다. devTools: process.env.NODE_ENV !== 'production', // devTool 의 옵션을 선택합니다. })
이외에도 좀더 자세한 옵션의 경우는 configureStore 에서 확인하실 수 있습니다.
createAction
createAction은 action 을 만들때 좀더 간결하게 만들어 주는 유틸입니다. 우선 action 자체를 만드는 것을 처음부터 만들어 보도록 하겠습니다.
1. action 만들기
const CHANGE_TITLE = 'CHANGE_TITLE' function changeTitle(title) { return { type: CHANGE_TITLE, payload: title } } const action = changeTitle('todo제목');
아무런 action 생성 유틸 패키지가 없다면 타입을 작성하고 이에대한 함수를 구성하는 action 한개를 위한 보일러 플레이트 코드들이 나오게 됩니다. 이를 위해 기존에는 typesafe-actions
등의 유틸 패키지로 작성하였으나 redux-toolkit
을 사용하게 된다면 자체 제공하는 createAction
을 통해 간결하게 구성 하실 수 있습니다.
2. createAction 으로 action 생성
const changeTitle = createAction('CHANGE_TITLE'); const action = changeTitle('todo제목'); // { type: 'CHANGE_TITLE', payload: 'todo제목' } action.toString // CHANGE_TITLE
createAction 함수로 만든 **액션으로 호출하게 되면 자동으로 type과 payload 를 생성해 줍니다. **이때 생성된 액션에 대해 toString
을 호출할경우 type 만 return 하도록 override 되어있는데 이는 아래 createReducer
에서 유용하게 사용됩니다.
3. createAction 에 typescript 사용하기
typescript 를 사용하실 경우 다음과 같이 타입을 추가 하실 수 있습니다.
const changeTitle = createAction<string>('CHANGE_TITLE');
4. createAction 커스텀 반환값 만들기
또한 createAction
의 반환값을 커스텀 하고 싶은 경우 2번째 인자에 콜백함수인 prepare
를 사용하여 커스텀 하실 수 있습니다.
const changeTitle = createAction('CHANGE_TITLE'); const action = changeTitle('todo제목', function prepare(content){ return { payload: { content, author: 'sungin'; } } }); // { type: 'CHANGE_TITLE', payload: {content: 'todo제목', author: 'sungin' } }
이때 주의하여할 부분은 반드시 액션 객체의 형태는 Flux Standard Action 형태로 구성해야 합니다. 물론 callback으로 만드는 커스텀 결과에도 동일하게 FSA를 적용해주야 합니다.
createReducer
createReducer는 reducer을 만들때 좀더 간결한 구조로 작성할 수 있도록 도와주고 immutable 관리까지 자동으로 관리 해주는 유틸 함수 합니다. 우선 유틸의 도움없이 reducer 를 작성할 경우 어떻게 사용하는지 예시를 통해 만들어보도록 하겠습니다.
1. 기초 reducer 작성
const CHANGE_TITLE = 'CHANGE_TITLE' const initState = { title : '' }; function todoReducer(initState, action) { switch (action.type) { case 'CHANGE_TITLE': return initState.title + action.payload default: return state } }
reducer의 타입을 찾을때는 switch 문법을 통해 찾도록 되어있었습니다. 그러나 이 방법은 보일러 코드도 많고 가독성도 떨어지게 되어 대부분 각종 유틸 패키지를 통해 switch 문법이 아닌 맥 객체로 작성하는 방식으로 사용하였습니다.
2. 다른 util 패키지를 사용할때의 reducer 형태
const CHANGE_TITLE = 'CHANGE_TITLE' const initState = { title : '' }; export default createReducer(initialState, { [CHANGE_TODO_TITLE]: (state, action) => produce(state, draft => { draft.title = action.payload; }), });
대부분의 유틸 패키지를 사용한 reducer의 형태는 다음과 같이 map의 형태에 immer 를 사용하여 불변성을 관리하는 형태로 구성되어 있습니다.
이러한 방식을 이제는 createReducer 를 통해서도 작성할 수 있습니다. 또한 이때의 key 값은 위에서 설명드린 createAction 을 넣어주면 되어 더욱 boilerplate 코드가 적어집니다. :)
3. createReducer 를 통한 reducer 작성
const changeTitle = createAction('CHANGE_TITLE'); const initState = { title : '' }; const todoReducer = createReducer(initState, { [changeTitle]: (state, action) => state.title + action.payload, })
위에 작성한 createReducer 와 다른 유틸 패키지로 구성한 reducer 를 보면 가장 큰 차이가 있습니다. createReducer 의 경우는 immer의 produce 를 자체적으로 지원하기 때문에 따로 코드로 immutable 관리를 하지 않아도 되는 큰 장점이 있습니다. 또한 key값으로 문자열이 아닌 action 함수를 넣었는데도 매칭이되는것은 createAction
에서 설명한 toString
함수를 override 하여 타입을 반환하도록 하였기 때문입니다.
4. createReducer에 typescript 사용하기
물론 typesctipt의 지원도 하고 있습니다. 이때 타입스크립트로 할경우 정확한 타입추론을 위해 action.type
의 형태로 맵의 key를 구성하거나 builder callback
형식으로 사용해야 합니다.
redux-toolkit
에서는 typescript 를 사용할 경우 builder callback 형식으로 작성하는 것을 추천 하고 있습니다
typescript 를 사용한 reducer 예시
// map 형식의 typescript 구성 const todoReducer = createReducer(initState, { [changeTitle.type]: (state, action: PayloadAction<string>) => state.title + action.payload, }) // builder 형식의 typescript 구성 const todoReducer = createReducer(initState, builder => { builder.addCase(changeTitle, (state, action: PayloadAction<string>) => state.title + action.payload) })
createSlice
createSlice는 redux 에서 모듈을 관리하기 위한 패턴중 ducks-pattern 을 공식적으로 지원하기 위해 나왔습니다. 가장 눈에띄는 부분으로는 reducer 만 생성하여도 reducer의 key 값으로 액션까지 자동으로 생성해 주는 기능을 지원합니다.
1. createSlice 살펴보기
createSlice 는 다음과 같은 obj 파라미터들로 구성되어있습니다.
function createSlice({ reducers : Object<string, ReducerFunction | ReducerAndPrepareObject>, initialState: any, name: string, extraReducers?: | Object<string, ReducerFunction> | ((builder: ActionReducerMapBuilder<State>) => void) })
- name : 해당 모듈의 이름을 작성합니다.
- initialState : 해당 모듈의 초기값을 세팅합니다.
- reducers : 리듀서를 작성합니다. 이때 해당 리듀서의 키값으로 액션함수가 자동으로 생성됩니다..
- extraReducers : 액션함수가 자동으로 생성되지 않는 별도의 액션함수가 존재하는 리듀서를 정의합니다. (선택 옵션 입니다.)
이제 위에서 createReducer
, createAction
으로 구성해본 todo 의 reducer 를 createSlice 를 통해 만들어 보도록 하겠습니다.
2. createSlice 를 통한 reducer 구성
const changeContent = createAction('changeContent'); const todo = createSlice({ name: 'todo', initialState : { title: '', content: '' }, reducers: { changeTitle: (state, action) => { state.title = action.payload; }, }, extraReducers: { // extraReducers 는 액션을 자동으로 생성해 주지 않기때문에 별도의 액션이 존재하는 함수의 reducer를 정의할 경우에 사용합니다. [changeContent]: (state, action) => { return state.content + action.payload } } }); // actions 아래 해당 액션이 자동 생성됨. todo.actions.changeTitle();
다음과 같이 createSlice 내부에서 state
, moduleName
, reducer
, action
에 대한 정의가 한번에 처리되고 있습니다. 중복되는 boilerplate 구조가 많이 사라진 모습을 볼 수 있습니다. :)
이때 reducers 의 key 로 잡힌 changeTitle
는 자동적으로 changeTitle
라는 이름의 액션함수로 생성됩니다.
3. extraReducers
extraReducers
는 액션을 따로 정의한 함수에 대한 리듀서를 정의하는 역활을 담당합니다. 때문에 extraReducers
에서 정의한 key값은 액션이 자동으로 생성되지 않습니다.
const changeContent = createAction('changeContent'); const todo = createSlice({ name: 'todo', initialState : { title: '', content: '' }, extraReducers: { // extraReducers 는 액션을 자동으로 생성해 주지 않기때문에 별도의 액션이 존재하는 함수의 reducer를 정의할 경우에 사용합니다. [changeContent]: (state, action) => { return state.content + action.payload } } }); todo.actions.changeContent();
4. typescript 지원
reducer 에 대한 typescript 사용시 아래 예시와 같은 CaseReducer
를 통해 타입을 지원하고 있습니다. 자세한 내용은 redux-toolit-ts 에서 확인하실 수 있습니다.
type State = number const increment: CaseReducer<State, PayloadAction<number>> = (state, action) => state + action.payload createSlice({ name: 'test', initialState: 0, reducers: { increment } })
createAsyncThunk
redux 에서 비동기 처리를 할경우 보통 thunk, saga, redux-observable 등의 미들웨어를 사용하여 한개의 비동기 액션에 대해 pending(비동기 호출 전), success(비동기 호출 성공), failure(비동기 호출 실패) 의 상태를 생성하여 처리하는 경우가 많았습니다. 이때 각 상태를 만드는것은 각자 유틸 패키지를 받거나 직접 구현하여서 사용하였는데 이를 redux-toolkit
에서 createAsyncThunk 을 통해 지원합니다.
단 이름에서부터 느껴지는 것처럼 thunk 만을 지원합니다. thunk 가 아닌 미들웨어를 사용하실 경우는 이전과 같이 직접 구현하거나 다른 유틸 패키지를 사용하여야 합니다.
사용방법은 다음과 같습니다.
1. createAsyncThunk 를 통한 비동기 액션 구성
const fetchTodo = createAsyncThunk( `todo/fetchTodo`, // 액션 이름을 정의해 주도록 합니다. async (todoId, thunkAPI) => { // 비동기 호출 함수를 정의합니다. const response = await todoApi.fetchTodoInfo(todoId); return response.data } ) // fetchTodo.pending => todo/fetchTodo/pending // fetchTodo.fulfilled => todo/fetchTodo/fulfilled // fetchTodo.rejected => todo/fetchTodo/rejected
위와 같이 createAsyncThunk
를 선언하게 되면 첫번째 파라미터로 선언한 액션 이름 에 pending
, fulfilled
, rejected
의 상태에 대한 action 을 자동으로 생성해주게 됩니다. 또한 AbortController 를 지원하기때문에 thunk를 사용하여도 api에 대한 취소 작업이 가능합니다. 이제 createSlice
와 createAsyncThunk
를 사용한 reducer 를 작성해 보도록 하겠습니다.
2. createSlice + createAsyncThunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' import { todoApi } from './todoApi' const name = 'todo'; const fetchTodo = createAsyncThunk( `${name}/fetchTodo`, // 액션 이름을 정의해 주도록 합니다. async (todoId, thunkAPI) => { const response = await todoApi.fetchTodoInfo(todoId); return response.data } ) const todoSlice = createSlice({ name, initialState : { title: '', content: '', loading: false, }, reducers: {}, extraReducers: { [fetchTodo.pending.type]: (state, action) => { // 호출 전 state.loading = true; }, [fetchTodo.fulfilled.type]: (state, action) => { // 성공 state.loading = true; state.title = action.title; state.content = action.content; }, [fetchTodo.rejected.type]: (state, action) => { // 실패 state.loading = true; state.title = ''; state.content = ''; }, } });
3. typescript와 함께 사용하기
typescript와 함께 사용하실 경우 위에서 설명한 것과 크게 다르지 않게 다음과 같이 작업하실 수 있습니다.
import {Todo} from "../../models/Todo"; import {createAsyncThunk, PayloadAction, createSlice} from "@reduxjs/toolkit"; import {AsyncState} from "../../models/redux"; import {fetchTodo} from "../../support/api/todoApi"; const name = 'todo'; export const getAsyncTodo = createAsyncThunk(`${name}/getAsyncTodo`, async (content: string) => { return { payload: await fetchTodo(content) } }); export type TodoState = { todo: AsyncState<Todo> } const initialState: TodoState = { todo: { loading: false, data: { title: '', content: '' }, } }; createSlice({ name, initialState, reducers: { changeTodoTitle: (state, action: PayloadAction<string>) => { state.todo.data.title = action.payload; } }, extraReducers: { [getAsyncTodo.pending.type]: (state) => { state.todo.loading = true; }, [getAsyncTodo.fulfilled.type]: (state, action: PayloadAction<Todo>) => { state.todo.loading = false; state.todo.data.title = action.payload.title; state.todo.data.content = action.payload.content; }, [getAsyncTodo.rejected.type]: (state) => { state.todo.loading = false; state.todo = initialState.todo; }, } })