벨로퍼트 리액트 - 7. 리덕스 미들웨어
7-11. redux-saga로 프로미스 다루기
redux-thunk : 함수를 만들어서 해당 함수에서 비동기 작업을 하고 필요한 시점에 특정 액션을 디스패치
redux-saga : 특정 액션을 모니터링하고 해당 액션이 주어지면 이에따라 제너레이터 함수를 실행, 비동기 작업을 처리 후 액션을 디스패치.
예제) posts 모듈을 redux-saga로 구현
modules/posts.js
import * as postsAPI from '../api/posts'; // api/posts 안의 함수 모두 불러오기
import {
reducerUtils,
handleAsyncActions,
handleAsyncActionsById
} from '../lib/asyncUtils';
import { call, put, takeEvery } from 'redux-saga/effects';
// 액션 타입
// 포스트 여러개 조회하기
const GET_POSTS = 'GET_POSTS'; // 요청 시작
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS'; // 요청 성공
const GET_POSTS_ERROR = 'GET_POSTS_ERROR'; // 요청 실패
// 포스트 하나 조회하기
const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';
export const getPosts = () => ({ type: GET_POSTS });
// payload는 파라미터 용도, meta는 리듀서에서 id를 알기 위한 용도
export const getPost = id => ({ type: GET_POST, payload: id, meta: id});
function* getPostsSaga() {
try {
const posts = yield call(postsAPI.getPosts);
// call을 사용하면 특정 함수를 호출하고 결과물이 반환될때까지 기다려줄수있다
yield put({
type: GET_POSTS_SUCCESS,
payload: posts
}); // 성공 액션 디스패치
} catch (e) {
yield put({
type: GET_POSTS_ERROR,
error: true,
payload: e
}); // 실패 액션 디스패치
}
};
// 액션이 지니고 있는 값을 조회하고 싶을때는 action을 파라미터로 받아와서 사용할 수 있다
function* getPostSaga(action) {
const param = action.payload;
const id = action.meta;
try {
const post = yield call(postsAPI.getPostById, param);
// API 함수에 넣어주고 싶은 인자는 call 함수의 두번째 인자부터 순서대로 넣어주면 됨
yield put({
type: GET_POST_SUCCESS,
payload: post,
meta: id
});
} catch (e) {
yield put({
type: GET_POST_ERROR,
error: true,
payload: e,
meta: id
});
}
};
// 사가들을 합치기
export function* postsSaga() {
yield takeEvery(GET_POSTS, getPostsSaga);
yield takeEvery(GET_POST, getPostSaga);
}
// 3번째 인자를 사용하면 withExtraArgument에서 넣어준 값들을 사용할 수 있다
export const goToHome = () => (dispatch, getState, {history}) => {
history.push('/');
};
// initialState 쪽도 반복되는 코드를 initial() 함수를 사용해 리팩토링
const initialState = {
posts: reducerUtils.initial(),
post: reducerUtils.initial()
};
export default function posts(state = initialState, action) {
switch (action.type) {
case GET_POSTS:
case GET_POSTS_SUCCESS:
case GET_POSTS_ERROR:
return handleAsyncActions(GET_POSTS, 'posts', true)(state, action);
case GET_POST:
case GET_POST_SUCCESS:
case GET_POST_ERROR:
return handleAsyncActionsById(GET_POST, 'post', true)(state, action);
default:
return state;
}
}
redux-thunk로 구현할때와의 차이점
- getPosts , getPost - thunk 함수가 아닌 순수 액션 객체를 반환하는 액션 생성함수로 구현 가능
- getPostSaga : 액션을 파라미터로 받아와서 해당 액션의 id 값 참조 가능
rootSaga에 postsSaga 등록
modules/index.js
import { combineReducers } from 'redux';
import counter, { counterSaga } from './counter';
import posts, { postsSaga } from './posts';
import { all } from 'redux-saga/effects';
const rootReducer = combineReducers({ counter, posts });
export function* rootSaga() {
yield all([counterSaga(), postsSaga()]); // all 은 배열 안의 여러 사가를 동시에 실행시킨다
}
export default rootReducer;
⇒ 이전처럼 잘 작동함!
1) 프로미스를 처리하는 사가 리팩토링
까다로운 작업을 할때는 사가 함수를 직접 작성하고, 간단한 비동기 작업을 처리할때는 redux-thunk 예제에서 했던것처럼 비슷한 방식으로 반복되는 로직들을 함수화 하여 재사용하면 훨씬 깔끔한 코드로 작성할 수 있다.
예제) createPromiseThunk , createPromiseSaga 대신 createPromiseSaga와 createPromiseSagaById를 작성
lib/asyncUtils.js 수정
import { call, put } from 'redux-saga/effects';
// 프로미스를 기다렸다가 결과를 디스패치하는 사가
export const createPromiseSaga = (type, promiseCreator) => {
const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
return function* saga(action) {
try {
// 재사용성을 위하여 promiseCreator 의 파라미터엔 action.payload 값을 넣도록 설정합니다.
const payload = yield call(promiseCreator, action.payload);
yield put({ type: SUCCESS, payload });
} catch (e) {
yield put({ type: ERROR, error: true, payload: e });
}
};
};
// 특정 id의 데이터를 조회하는 용도로 사용하는 사가
// API를 호출 할 때 파라미터는 action.payload를 넣고,
// id 값을 action.meta로 설정합니다.
export const createPromiseSagaById = (type, promiseCreator) => {
const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
return function* saga(action) {
const id = action.meta;
try {
const payload = yield call(promiseCreator, action.payload);
yield put({ type: SUCCESS, payload, meta: id });
} catch (e) {
yield put({ type: ERROR, error: e, meta: id });
}
};
};
// 리듀서에서 사용 할 수 있는 여러 유틸 함수들입니다.
export const reducerUtils = {
// 초기 상태. 초기 data 값은 기본적으로 null 이지만
// 바꿀 수도 있습니다.
initial: (initialData = null) => ({
loading: false,
data: initialData,
error: null
}),
// 로딩중 상태. prevState의 경우엔 기본값은 null 이지만
// 따로 값을 지정하면 null 로 바꾸지 않고 다른 값을 유지시킬 수 있습니다.
loading: (prevState = null) => ({
loading: true,
data: prevState,
error: null
}),
// 성공 상태
success: payload => ({
loading: false,
data: payload,
error: null
}),
// 실패 상태
error: error => ({
loading: false,
data: null,
error: error
})
};
// 비동기 관련 액션들을 처리하는 리듀서를 만들어줍니다.
// type 은 액션의 타입, key 는 상태의 key (예: posts, post) 입니다.
export const handleAsyncActions = (type, key, keepData = false) => {
const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
return (state, action) => {
switch (action.type) {
case type:
return {
...state,
[key]: reducerUtils.loading(keepData ? state[key].data : null)
};
case SUCCESS:
return {
...state,
[key]: reducerUtils.success(action.payload)
};
case ERROR:
return {
...state,
[key]: reducerUtils.error(action.payload)
};
default:
return state;
}
};
};
// id별로 처리하는 유틸함수
export const handleAsyncActionsById = (type, key, keepData = false) => {
const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
return (state, action) => {
const id = action.meta;
switch (action.type) {
case type:
return {
...state,
[key]: {
...state[key],
[id]: reducerUtils.loading(
// state[key][id]가 만들어져있지 않을 수도 있으니까 유효성을 먼저 검사 후 data 조회
keepData ? state[key][id] && state[key][id].data : null
)
}
};
case SUCCESS:
return {
...state,
[key]: {
...state[key],
[id]: reducerUtils.success(action.payload)
}
};
case ERROR:
return {
...state,
[key]: {
...state[key],
[id]: reducerUtils.error(action.payload)
}
};
default:
return state;
}
};
};
사가를 통해 비동기작업을 처리할때 API 함수의 인자는 액션에서부터 참조. 사용할 함수 인자의 이름은 payload로 통일.
특정 id를 위한 비동기작업을 처리하는 createPromiseSagaById, handleAsyncActionsById에서는 id값을 action.meta에서 참조한다.
modules/posts.js
import * as postsAPI from '../api/posts'; // api/posts 안의 함수 모두 불러오기
import {
reducerUtils,
handleAsyncActions,
handleAsyncActionsById,
createPromiseSaga,
createPromiseSagaById
} from '../lib/asyncUtils';
import { takeEvery } from 'redux-saga/effects';
/* 액션 타입 */
// 포스트 여러개 조회하기
const GET_POSTS = 'GET_POSTS'; // 요청 시작
const GET_POSTS_SUCCESS = 'GET_POSTS_SUCCESS'; // 요청 성공
const GET_POSTS_ERROR = 'GET_POSTS_ERROR'; // 요청 실패
// 포스트 하나 조회하기
const GET_POST = 'GET_POST';
const GET_POST_SUCCESS = 'GET_POST_SUCCESS';
const GET_POST_ERROR = 'GET_POST_ERROR';
export const getPosts = () => ({ type: GET_POSTS });
export const getPost = id => ({ type: GET_POST, payload: id, meta: id });
const getPostsSaga = createPromiseSaga(GET_POSTS, postsAPI.getPosts);
const getPostSaga = createPromiseSagaById(GET_POST, postsAPI.getPostById);
// 사가들을 합치기
export function* postsSaga() {
yield takeEvery(GET_POSTS, getPostsSaga);
yield takeEvery(GET_POST, getPostSaga);
}
// 3번째 인자를 사용하면 withExtraArgument 에서 넣어준 값들을 사용 할 수 있습니다.
export const goToHome = () => (dispatch, getState, { history }) => {
history.push('/');
};
// initialState 쪽도 반복되는 코드를 initial() 함수를 사용해서 리팩토링 했습니다.
const initialState = {
posts: reducerUtils.initial(),
post: reducerUtils.initial()
};
export default function posts(state = initialState, action) {
switch (action.type) {
case GET_POSTS:
case GET_POSTS_SUCCESS:
case GET_POSTS_ERROR:
return handleAsyncActions(GET_POSTS, 'posts', true)(state, action);
case GET_POST:
case GET_POST_SUCCESS:
case GET_POST_ERROR:
return handleAsyncActionsById(GET_POST, 'post', true)(state, action);
default:
return state;
}
}
⇒ 이전처럼 잘 작동한다!