Redux Saga Mate
Allow you building react and redux based web apps with less pain, by removing the needs for writing lots of action types, reducers.
You should know before go on reading
Layers or Moments
------------------------------
| presentational components | ^
------------------------------- |
| container components (auto) | |
------------------------------- |
| react-redux connect() | |
------------------------------- |
| selectors (js) | |
------------------------------- |
| redux store (state) (js) | |
------------------------------- |
| redux reducers (js) | |
------------------------------- data flow
| redux action payloads (js) | ^
------------------------------- |
| normalization (js) | |
------------------------------- |
| remote API calls (js) | |
------------------------------- |
| Web API, WebSocket Endpoints | |
------------------------------- |
| Server State | |
------------------------------
-
action
s are about what happend, it's not about "what should be done", even if they were named in verbs. - It is
reducer
's job, that about "what should be done" and "how it should be done". - The
container
files you put in the "containers" directory are not actualcontainer
s, they are just connecting logics, actualcontainer
s are created automatically byconnect(YourComponent)
, you can only see them in the browser'sDeveloper Tools
. - In most situations, you should try hard to prevent putting JSX codes in the
container
files. Because they are about the UI. -
redux-thunk
changes the origin conceptual model of theaction
, by functions, and functions always about "what should be done", or "how it should be done". - The
action
is not equal to action types.Action Type
+Action Payload
=Action Instance
. - Tutorials or documentations of
redux
,redux-thunk
,redux-saga
, tell you track the async action stateby action type
, this is not what you want, in most of the time. - Actions you dispatch are always with payloads. Infomations in the payload affect the final call like http requests, and so the responses.
- Track async action states in
store
, it also means your components are fullycontrolled components
, the states and callbacks(handlers) are all passed as props. - Infomation synchronisation is the most difficult part in the computer science,
normalization
strategy is mean to solve this problem, even if that may not work perfectly. I hope you know how to use thenormalizr
library.
Demo
https://hanzhixing.github.io/redux-saga-mate/
Installation
Install the package.To use with node:
$ npm install redux-saga-mate --save
Install peer dependencies, you may already have these be installed.
npm install react redux redux-saga recompose reselect redux-actions
Recommended directory structure
src/
├── actions
│ └── types.js
├── api
│ └── index.js
├── components
│ ├── App
│ │ └── index.jsx
│ └── PostList
│ ├── index.jsx
│ └── index.module.scss
├── config.js
├── connects
│ └── PostList
│ ├── index.js
│ └── selectors.js
├── index.css
├── index.js
├── reducers
│ ├── index.js
│ └── ui
│ ├── index.js
│ └── posts.js
├── sagas
│ └── index.js
├── store
│ ├── configureStore.js
│ └── index.js
└── utils
└── index.js
Recommended state shape
{
session: { <--- current session based infomations
username: ...,
},
entities: { <--- normalized entities, again, learn to use the normalizr library
posts: {
1: {
...
}
2: {
...
}
}
},
ui: { <--- relation infomations between the entities and the UI.
home: {
latestPosts: {
...
}
}
}
actions: { <--- all action infomations
}
}
Something about internal implementation
Action (enhanced FSA for async)
{
type: 'YOUR_ACTION_TYPE',
payload: {...any infomation as object...},
error: true or false,
meta: { // this infomation will be managed automatically
id: uniq_hash(type + payload),
pid: parentOf(id), // not used yet
ctime: ISO8601,
utime: ISO8601,
phase: 'started'|'running'|'finished',
progress: integer between 1~100
uniq: true or false,
}
}
Normalized payloads
Recommend normalized your api data in the API layer.
{
request: {
data: {...}, // for POST, PUT, PATCH body (should be plain object)
params: {...}, // hint: react-router params
query: {...}, // hint: querystring.parse(location.search)
},
response: {
...normalize(data, schema), // see normalizr
}
}
Usage (Highly recommended you to read the source of demo)
actions/type.js
export const CLEANUP = 'CLEANUP';
// You need not split this to ASYNC_GET_MANY_POST_REQUEST, ASYNC_GET_MANY_POST_SUCCESS, ASYNC_GET_MANY_POST_FAILURE
export const ASYNC_GET_MANY_POST = 'ASYNC_GET_MANY_POST';
api/index.js
Normalize your data in the API layer. It's the only right place.
export const restfulGetManyPosts = args => fetch(...).then(data => normalize(data, YOUR_SCHEMA))
reducers/index.js
import {combineReducers} from 'redux';
import {concat, difference} from 'lodash/fp';
import {createActionsReducer, createEntitiesReducer, groupByComposeByEntityType} from 'redux-saga-mate/lib/reducer';
// there are only these two operations for state updating.
import {UPDATE, DELETE} from 'redux-saga-mate/lib/operation';
import * as ActionTypes from '../actions/types'; // It's ok, if you want to import action types explicitly.
// The keys is your entities keys in the store.
const EntityActionMap = {
posts: {
// the value part can be one single OPERATION(string), or tuple [OPERATION, yourMergeFunction]
[ActionTypes.ASYNC_GET_MANY_POST]: [
UPDATE,
// @see the 'mergeDeepWith' from 'ramda'
(k, l, r) => (k === 'commenters' ? concat(l, difference(r, l)) : r),
],
[ActionTypes.ASYNC_DELETE_ONE_POST]: DELETE,
[ActionTypes.ASYNC_PATCH_ONE_POST]: UPDATE,
...
},
users: {
...
},
...
// add your mapping rules instead of writing reducers
};
const locators = {
// define possible paths to entities in your action payload
UPDATE: [
['response', 'entities'],
['entities'],
['entities'],
],
// paths to primaryKey in your action payload, which will be used to delete the entity
DELETE: [
['request', 'params', 'id'],
],
};
export default combineReducers({
// tuple [ACTION_TYPE_FOR_CLEANUP, YOUR_ASYNC_ACTION_TYPE_REGEX]
actions: createActionsReducer([ActionTypes.CLEANUP, /^ASYNC_/]),
entities: combineReducers(
groupByComposeByEntityType(
createEntitiesReducer(locators, EntityActionMap),
{
...
/// put your own legacy reducers here, they will executed at the end of reducing
...
},
),
),
...
// If you are creating new app, codes above can be written like bellow
entities: combineReducers(createEntitiesReducer(locators, EntityActionMap)),
...
});
sagas/index.js
import {all, takeEvery} from 'redux-saga/effects';
import {makeCreateDefaultWorker} from 'redux-saga-mate/lib/saga';
import * as ActionTypes from '../actions/types';
import * as Api from '../api';
// you need to tell the Error Type for failure situation of the async action.
const createDefaultWorker = makeCreateDefaultWorker([MyError, ActionTypes.CLEANUP]);
// If you want to clear action state when success, you pass option object as the second argument.
// const createDefaultWorker = makeCreateDefaultWorker([MyError, ActionTypes.CLEANUP], {autoclear: true});
// Notice!
// If you need more complicated logic controls then the default worker saga,
// you need to implement your own worker sagas.
export default function* () {
yield all([
// create a worker saga with your remote call promise, you need only one line code.
takeEvery(ActionTypes.ASYNC_GET_MANY_POST, createDefaultWorker(Api.restfulGetManyPosts)),
// If you need infomations from state, before run the promise, you can prepare the payload.
// What you return will pass in to the remote call.
takeEvery(ActionTypes.ASYNC_GET_ONE_USER_BY_POST_ID, createDefaultWorker(
Api.getOneUser,
(state, action) => {
const {postId} = action.payload;
const {author} = state.entities.posts[postId];
return {id: author};
},
// If you want to disable action state autoclearing just for this worker
// {autoclear: false}
)),
]);
}
connects/PostList/index.js (or containers/PostList/index.js)
imports
import {connect} from 'react-redux';
import {compose, lifecycle, withState, mapProps} from 'recompose';
import {createSelector} from 'reselect';
import {createAction} from 'redux-actions';
import {createAsyncAction, idOfAction} from 'redux-saga-mate/lib/action';
import {
// You can use this,
withAsyncActionStateHandler,
// or this.
createAsyncActionContext,
// How they are different from each other, go on reading to the end.
} from 'redux-saga-mate/lib/hoc';
import {createSelectActions} from 'redux-saga-mate/lib/selector';
import PostList from '../../components/PostList';
import {selectPosts, selectPostsBuffer, selectModalAuthor} from './selectors';
import * as ActionTypes from '../../actions/types';
mapStateToProps
// The selector below is the same as the selector you got from reselect's createSelector.
const selectActions = createSelectActions(
(state, props) => state.actions, // provide actions selector from store
(state, props) => props.actionIds, // provide actionIds selector maybe from props
);
const makeSelectProps = () => createSelector(
selectPosts,
// Once your component is wrapped with 'withAsyncActionStateHandler', you can select out the actions.
// So as when you wrapped with 'withAsyncActionContextConsumer' created by 'createAsyncActionContext'.
selectActions,
(items, transients) => ({
items: posts,
transients, // in the ui component, you can examine the action by 'transients.onPage[page]'
...
}),
);
const makeMapStateToProps = () => {
const selectProps = makeSelectProps();
return (state, props) => selectProps(state, props);
};
mapDispatchToProps
const mapDispatchToProps = (dispatch, {onTrackAsyncAction}) => ({
onPage: page => {
// 1. Make your action Async with 'createAsyncAction'.
// 2. dispatch it.
// 3. take the action id with 'idOfAction'
const action = dispatch(createAsyncAction(ActionTypes.ASYNC_GET_MANY_POST)({
page,
}));
// you can pass single string, or path in array form for the first argument
// Seconds is the Action Id.
onTrackAsyncAction(['onPage', page], idOfAction(action));
},
});
const withRedux = connect(makeMapStateToProps, mapDispatchToProps);
export default compose(
...
withRedux,
...
)(PostList);
enhance with aysnc action tracking
You have two options.
Option1
Use withAsyncActionStateHandler
const withAsyncAction = withAsyncActionStateHandler(({actionIds, setActionId, unsetActionId}) => ({
actionIds,
onTrackAsyncAction: setActionId,
onUntrackAsyncAction: unsetActionId,
}));
export default compose(
...
withAsyncAction,
...
withRedux,
...
)(PostList);
createAsyncActionContext
Option2 Use // You may want to create these two hoc from a seperated file and import the provider or consumer.
// The benefit use context is you need not pass the props along the tree.
const {withAsyncActionContextProvider, withAsyncActionContextConsumer} = createAsyncActionContext();
export default compose(
...
withAsyncActionContextProvider,
...
withAsyncActionContextConsumer,
mapProps(({actionIds, setActionId, unsetActionId}) => ({ // It is just recompose's mapProps
actionIds, // off course the 'actionIds' must be matched with the key in the action selector: selectActions
onTrackAsyncAction: setActionId, // You can map the props like this.
onUntrackAsyncAction: unsetActionId,
}))
withRedux,
...
)(PostList);
Use different prop names
const mapActionProps = ({actionIds, setActionId, unsetActionId}) => ({
actionIds, // off course the 'actionIds' must be matched with the key in the action selector: selectActions
onTrackAsyncAction: setActionId, // You can map the props like this.
onUntrackAsyncAction: unsetActionId,
})
export default compose(
...
withAsyncActionContextConsumer,
mapProps(mapActionProps), // It is just recompose's mapProps, you can use withProps or mapProps.
withRedux,
...
)(PostList);