Sagas
This is actually
- A design pattern instead of a library specified for redux
- Even redux is also just a design pattern, it is not something unique for React
- Widely used in system design, especially for microservice
Back to React, practically, we should use stateless component instead of stateful component, local state is not preferred in data-driven architecture, and theoretically, functional programming should never have state.
So, instead of componentDidMount
or componentWillMount
, if a page required API response prior to UI display, we should use side-effect from saga to control both navigation flow and API call flow. You may pick one of the following three ways for your implementation
Reference for pessimistic approach - Saga Basic
// container
import { bindActionCreators as bind } from 'react-redux';
mapDispatchToProps: (dispatch) => {
openPurchaseDetail: bind(openPurchaseDetail, dispatch)
}
// presenter
onClick: () => {
openPurchaseDetail(purchase_id)
}
// saga
yield fork(watchPurchaseDetailStartSaga);
function* watchPurchaseDetailStartSaga(){
while(true){
const { purchase_id } = yield take(PURCHASE_DETAIL_START);
yield put({ type: LOADING }) // optional if you have global loading indicator in Root router
const response = yield call(API.fetchPurchaseDetail, purchase_id)
yield put({ type: PURCHASE_DETAIL_FETCHED, response, purchase_id});
yield call(history.goto, `/myaccount/purchasedetail/${purchase_id}`)
}
}
Reference for optimistic approach - Saga Advance
// ./app/reducers/AccountViewer.js
case PURCHASE_DETAIL_OPEN:
{
return {
selected_id: action.purchase_id,
optimistic: action.abstract
}
}
case PURCHASE_DETAIL_FETCHED:
{
return {
...state,
selected_id: action.purchase_id,
fetched: action.response.purchase_detail
}
}
// ./app/sagas/AccountViewer.js
yield fork(watchPurchaseDetailStartSaga);
function* watchPurchaseDetailStartSaga(){
while(true){
const { purchase_id } = yield take(PURCHASE_DETAIL_START);
yield call(history.goto, `/myaccount/purchasedetail/${purchase_id}`)
const response = yield call(API.fetchPurchaseDetail, purchase_id)
yield put({PURCHASE_DETAIL_FETCHED, response, purchase_id});
}
}
Reference for optimistic approach with interrupt - Saga Advance
// break into watcher and subroutine, let them race
yield fork(watchPurchaseDetailStartSaga);
function* watchPurchaseDetailStartSaga(){
while(true){
const { purchase_id } = yield take(PURCHASE_DETAIL_START);
yield call(history.goto, `/myaccount/purchasedetail/${purchase_id}`)
yield race({
response: yield call(fetchPurchaseDetail),
back: yield take(NAVIGATION_BACK)
})
}
}
function* fetchPurchaseDetail(){
const response = yield call(API.fetchPurchaseDetail, purchase_id);
yield put({PURCHASE_DETAIL_FETCHED, response, purchase_id});
}
Design principle
- Use optimistic update for
- InstantUiUpdate-case like upload an image to Telegram, bookmark a programme on Netflix
- When user finished editing his user profile, and click "Save", AppState is updated prior to API call
- Alert is only available when API returns failure, yet, in some case, we may even suppress it
- Use pessimistic update for
- OnlyAfterSuccess-case, MissionCritical-case like payment or transaction
- When user is paying for his ticket, transaction result must be returned prior to UI update
- Success is only displayed upon positive response from API, yet, in most case, timeout may regarded as failure
- Framework like MeteorJS etc provides a fancy OptimisticUpdater layer wrapping the store, always utilise those things if available.
- You can make your own optimistic layer using nested reducers
Reference
// ./app/reducers/AccountViewer.js
/*
AccountViewer:
purchase_detail:
selected_id: 'f34r83r34'
fetched:
date: 23421231234
items:
- title
cost
total: 234
optimistic:
date: 23421231234
total: 234
*/
// ./app/selectors/AccountViewer.js
getCurrentPurchaseDetail = ({purchase_detail}) => purchase_detail.optimistic.merge(purchase_detail.fetched);
isPurchaseDetailOptimistic = ({purchase_detail}) => !purchase_detail.fetched;