지금까지 redux에 api 호출에 관한 데이터를 저장할 때는 FSA 형식으로 redux-thunk를 사용하여 다음과 같은 방식으로 관리했다.
예를 들어 posts의 정보를 state.posts
에 저장하려면
state.posts = {
data: null,
loading: false,
error: false
}
post/getPosts
post/getPostsSuccess
posts/getPostsError
이렇게 관리하면 action을 통해 현재 api 호출 상태를 쉽게 알아볼 수 있고, state를 조회할 때도 편리하다.
나는 위와 같은 과정을 강의와 글로 정리하며 익히며 유용하게 사용 중이다.
그런데 최근 redux-saga와 redux-toolkit을 사용할 일이 생겼고, 위와 동일한 기능을 수행하면서 효율적으로 코드를 작성하고 싶었다.
공부해보며 쉽게 작성한 부분도 있었고, 몇 번을 썼다 지운 코드도 있었다. 그 중 현재 나에게 가장 편한 방법으로 모듈화한 과정을 잊지 않게 적어두려 한다.
이 부분은 강의를 참고하여 작성했습니다.
위에서 기술한 2번 방식으로 action을 하나씩 dispatch 하려면 번거로울 것이다. 그래서 type과 promise를 반환하는 function을 인자로 받아 2번 작업을 한번에 수행하는 saga를 반환하는 함수를 작성했다.
/* 호출 방법 예시 */
const getPostsSaga = createPromiseSaga("posts/getPosts", PostsAPI.readPosts);`
/* src/lib/asyncUtils.js 중 일부 */
import { call, put } from "redux-saga/effects";
export const createActionString = (type) => {
return { success: `${type}Success`, error: `${type}Error` };
}; // 1. return success action, error action string
export const createPromiseSaga = (type, promiseCreator) => {
const { success, error } = createActionString(type); // 1.
return function* (action) {
try {
const response = yield call(promiseCreator, action.payload); // 2.
yield put({
type: success,
payload: response
}); // 3.
} catch (err) {
yield put({
type: error,
payload: err.message,
error: true
}); // 4.
}
};
};
...
createActionString(type)
으로 type에 해당하는 action이 성공 또는 실패 했을 때 action string을 반환한다.const { success, error } = createActionString('post/getPosts');
// success = 'post/getPostsSuccess';
// error = 'post/getPostsError';
action.payload
값을 사용하여 promiseCreator function을 실행하고, 그 결과를 response
에 저장하고 post/getPosts
action을 dispatch 한다.action.payload
에 결과를 저장하고 post/getPostsSuccess
action을 dispatch 한다.action.payload
에 에러 메시지를 저장, error: true
로 변경하고 post/getPostsSuccess
action을 dispatch 한다.이렇게 createPromiseSaga
를 통해 api 호출 상태에 따라 다른 action을 dispatch 하는 과정을 모듈화할 수 있다.
먼저 FSA 형식의 객체를 return 해주는 util 함수를 작성한다.
/* src/utils/asyncUtils.js 중 일부 */
export const reducerUtils = {
init: () => ({
data: null,
loading: false,
error: false
}),
loading: (prevData = null) => ({
data: prevData,
loading: true,
error: false
}),
success: (data = null) => ({
data: data,
loading: false,
error: false
}),
error: (error) => ({
data: error,
loading: false,
error: true
})
};
위처럼 작성하면 reducer에서 state에 FSA 형식의 객체를 대입할 때 일일히 작성하지 않아도 된다.
const initState = {
posts: reducerUtils.init() // 초기화
}
위처럼 FSA 반환 과정을 모듈화해도 state마다 reducerUtils
를 참조하는 과정이 번거롭다고 생각돼서 action을 인자로 받아 type에 따라 reducerUtils
를 호출하는 함수를 만들었다.
/* src/lib/asyncUtils.js 일부 */
export const handleAsyncAction = ({ type, payload = {} }, prevData = null) => {
// success or error
if (type.includes("Success")) return reducerUtils.success(payload); // 1.
if (type.includes("Error")) return reducerUtils.error(payload); // 2.
// loading
return reducerUtils.loading(prevData); // 3.
};
reducerUtils.success
reducerUtils.error
reducerUtils.loading
지금까지 작성한 유틸 함수를 이용하면 일일히 action과 reducer를 일일히 작성하지 않고 동적으로 처리할 수 있다.
state.post.posts
에 posts/getPosts
라는 action으로 api 호출 데이터를 받는다고 가정한다.
/* src/reducer/post.js */
import { createSlice } from "@reduxjs/toolkit";
import { takeEvery } from "redux-saga/effects";
import { createPromiseSaga, reducerUtils, handleAsyncAction } from "../lib/asyncUtils";
import PostsAPI from "../api/posts";
/* action */
const prefix = "posts";
/* reducer */
const initState = {
posts: reducerUtils.init() // 0. state 초기화
};
export const posts = createSlice({
name: prefix,
initialState: initState,
reducers: {
getPosts: (state, action) => {}, // 1. saga 실행 action 생성 (api 호출)
},
extraReducers: (builder) => {
builder.addMatcher( // 2. 모든 action을 인자로 받는 callback
(action) => {
return action.type.includes(prefix); // 3. posts action일 경우
},
(state, action) => {
state.posts = handleAsyncAction(action); // 4. action type에 따라 맞는 FSA 객체 return
}
);
}
});
export const { getPosts } = posts.actions;
/* saga */
const getPostsSaga = createPromiseSaga(getPosts, PostsAPI.readPosts); // 5. api 상태에 따라 적합한 action을 dispatch하는 saga 생성
export function* postSaga() {
yield takeEvery(getPosts, getPostsSaga);
}
export default posts;
extraReducers
에서 builder.addMatcher
의 첫 번째 callback으로 posts
관련 action만 다룰 수 있도록 제한한다.builder.addMatcher
의 두 번째 cabllback으로 원하는 작업을 수행한다. handleAsyncAction
을 통해 action type에 따라 적합한 FSA 객체를 return하여 state.posts
에 저장한다.댓글을 확인하고 추가한 내용 입니다. - 2021. 06. 23
여러 방식이 있겠지만 현재 코드에서 모듈화할 수 있는 방법을 생각해보았다.
다음과 같이 reducer에 여러개의 비동기 관련 state가 존재하고, 특정 state에 값을 저장해야하는 상황을 대응할 것이다.
const prefix = "posts";
const initState = {
posts: reducerUtils.init(),
post: reducerUtils.init()
};
export const posts = createSlice({
name: prefix,
initialState: initState,
...
});
ex) posts reducer에 state.posts
, state.post
존재
// 1. saga 실행 action
dispatch(getPosts({ stateType: "posts" })); // posts/getPosts action
dispatch(getPost({ id, stateType: "post" })); // posts/getPost action
// 2. saga -> success, error action
const getPostsSaga = createPromiseSaga(getPosts, PostsAPI.readPosts, "posts");
const getPostSaga = createPromiseSaga(getPost, PostAPI.readPost, "post");
stateType
이라는 값으로 저장하고자 하는 state명을 전달한다.
export const createPromiseSaga = (type, promiseCreator, stateType = null) => { // 0.
const { success, error } = createActionString(type);
return function* (action) {
try {
const response = yield call(promiseCreator, action.payload);
const payload = stateType ? { ...response, stateType } : response; // 1.
yield put({
payload,
type: success
});
} catch (err) {
const payload = stateType ? { msg: err.message, stateType } : { msg: err.message }; // 2.
yield put({
type: error,
error: true,
payload
});
}
};
};
stateType
이 없는 reducer도 대응 가능하도록 default를 null
로 설정한다.stateType
이 존재하는 요청 성공일 경우 payload에 stateType
을 포함한다.stateType
이 존재하는 요청 실패일 경우 payload에 stateType
을 포함한다.export const handleAsyncAction = ({ type, payload = {} }, prevData = null) => {
if (payload.hasOwnProperty("stateType")) delete payload.stateType; // 변경 사항
// success or error
if (type.includes("Success")) return reducerUtils.success(payload);
if (type.includes("Error")) return reducerUtils.error(payload);
// loading
return reducerUtils.loading(prevData);
};
stateType
은 대입할 state만 판단하고, 직접 state에 저장하진 않을 것이므로 존재한다면 제거하고 return 한다.
extraReducers: (builder) => {
builder.addMatcher(
(action) => {
return action.type.includes(prefix);
},
(state, action) => {
state[action.payload.stateType] = handleAsyncAction(action); // 변경 사항
}
);
}
stateType
를 통해 저장할 state를 지정한 후 handleAsyncAction
로 제거하여 저장한다.
위의 방식대로 코드를 수정하면 단일 state일 때( = stateType
을 전달하지 않을 때), 여러개일 때 대응할 수 있는 것을 확인했다.
단, 기술하지 않았지만 state명 변경 가능성을 고려하여 reducer가 있는 위치에 state를 상수로 정의하고 사용하였다!
/* src/reducer/post.js 중 일부 */
const prefix = "posts";
export const [postsState, postState] = [prefix, "post"]; // 변경 사항
const initState = {
posts: reducerUtils.init(),
post: reducerUtils.init()
};
export const posts = createSlice({
name: prefix,
initialState: initState,
...
});
...
공통적인 case를 다루는 부분을 builder.addMatcher
로 처리했는데, 다른 reducer까지 비교해야 하므로 성능적으로 최선인 지는 모르겠다.
계속 개발하면서 개선점을 찾고 효율적인 방법을 찾아나갈 것이다. 🙆♀️
state가 여러개 존재할 때 분기 처리
항목 추가 - 2021. 06. 23createSlice
로 action 생성 createAction 사용
)handleAsyncReducer
로직 수정 - 2021. 06. 23피드백은 언제나 환영합니다 ❤
안녕하세요. 글 잘봤습니다! 글을 보고 궁금증이 생겨서 댓글을 남기게 되었습니다.
(state, action) => {
state.posts = handleAsyncAction(action);
}
만약 비동기 관련 State( posts, users 등 )가 여러개 존재할 때 위 코드에서 분기 처리를 어떻게 하시나요? 해당 코드에서는 posts만 처리하는 로직같아서요!
좋은 글 잘봤습니다 :)
근데 궁금한것이 있습니다.
둘다 비동기를 처리하는데 있어 유용한 미들웨어인건 확실하고,
툴킷 사용전에는 저도 saga를 많이 이용했거든요.
근데 툴킷 자체에서도 지원하는 미들웨어가 thunk이고 그만 큼 thunk랑 비동기처리하는건 코드면에서도 더 갈끔하고 좋다고 느겼는데요 굳이 saga를 사용한 이유가 있을까요?
정말 유익한 포스팅 감사드립니다 !! 덕분에 많이 보고 배워가요.
추가로 참고하신 강의를 알고싶은데 지금은 404 페이지가 떠서요 어떤 강의 인지 알고싶습니다 !