Allow you building react and redux based web apps with less pain, by removing the needs for writing lots of action types, reducers.
------------------------------
| 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.
https://hanzhixing.github.io/redux-saga-mate/
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
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
{
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
}
}
{
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,
}
}
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
}
}
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';
Normalize your data in the API layer. It's the only right place.
export const restfulGetManyPosts = args => fetch(...).then(data => normalize(data, YOUR_SCHEMA))
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)),
...
});
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}
)),
]);
}
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';
// 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);
};
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);
You have two options.
Use withAsyncActionStateHandler
const withAsyncAction = withAsyncActionStateHandler(({actionIds, setActionId, unsetActionId}) => ({
actionIds,
onTrackAsyncAction: setActionId,
onUntrackAsyncAction: unsetActionId,
}));
export default compose(
...
withAsyncAction,
...
withRedux,
...
)(PostList);
// 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);
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);