An isomorphic/universal asynchronous tasks solution for redux.
You may already using redux-thunk for your asynchronous tasks. Put thunk, function or promise into an action makes it not pure, which means the action may not be serialized or replayed well.
A better asynchronous task practice is: create your action as pure object, do asynchronous tasks in your own redux middlewares. This practice keep all your actions creators and reducers pure and clean, make your application more isomorphic or universal. The only place you put asynchronous codes are redux middlewares....Or, a better place: redux service (a.k.a. re-service).
A redux service means: an asynchronous task triggered by a start service action. After it done, the result will be dispatched as another service done action.
Re-service provides a good practice for all your asynchronous tasks, includes:
- A helper function to create service action creator. (the action is FSA compliant)
- A redux middleware to:
- handle the service action
- At client side, transport action to server then get result.
- At server side, execute the service then get result.
- then dispatch service result action
- handle the service action
- An express middleware to deal with transported service actions.
npm install reservice --save
You will need these polyfills for older browsers or other environments:
A Service
// req is optional, you can receive req to deal with session based tasks.
const myService = (payload, req) => {
// Do any async task you like, return a promise or result.
// You can not know any redux related things,
// but you can access the express request object here.
...
return result;
}
A Service Action Creator
import { createService } from 'reservice';
// Check redux-actions to know more about payloadCreator
// https://github.com/acdlite/redux-actions#createactiontype-payloadcreator--identity-metacreator
const doSomeThing = createService('DO_SOMETHING', payloadCreator);
expect(doSomeThing('good')).toEqual({
type: 'CALL_RESERVICE',
payload: 'good',
reservice: {
name: 'DO_SOMETHING',
state: 'CREATED',
},
});
Define Service List
const serviceList = {
[doSomeThing]: myService,
[anotherServiceCreator]: theCodeOfAnotherService,
...
}
Setup Express Application
// your server.js
import { createMiddlewareByServiceList } from 'reservice';
import bodyParser from 'body-parser';
...
// reservice middleware need to access body as json
app.use(bodyParser.json());
// Add this line to declare serviceList and use the express middleware
app.use(createMiddlewareByServiceList(serviceList));
The Reducer
import { handleActions } from 'redux-actions';
// create a reducer
const myReducer = handleActions({
[doSomeThing]: (state, action) => { ... },
[anotherServiceCreator]: anotherReducer,
...
}, initialState);
// If you also want to take care service start, try this
import { ACTION_TYPE_RESERVICE } from 'reservice';
const nowLoadingReducer = (state = initialState, action) => {
if (action.type !== ACTION_TYPE_RESERVICE) {
return state;
}
// service started, remeber to set nowLoading to false in yourown reducers
// you can set different loading states by checking action.reservice.name
return { ...state, nowLoading: true };
}
Setup Redux Store
import { createStore, applyMiddleware } from 'redux';
import { serviceMiddleware, settleRequest } from 'reservice';
const store = createStore(
myReducer,
applyMiddleware(serviceMiddleware)
);
// Optional: If you like to access request in service
// You need to do this.
store.dispatch(settleRequest(req));
Please check example to get a deeper guide.
- reservice already adopt debug , you can export DEBUG=... to show debug log:
reservice:start
: show service name, payloadreservice:receive
: show service name, payload when client side dispatch receivedreservice:success
: show service name, payload and result when service successedreservice:fail
: show service name, payload and error when service failedreservice:error
: show service name, payload and error.stack when service failedreservice:select
: show service name, payload and selected result when service successed, refer to selector.
You can change default service path and method by this way:
import { setupServiceEndpoint } from 'reservice';
setupServiceEndpoint('/mypath/my_service/');
// Or also change http method
setupServiceEndpoint('/another/path/ok', 'POST');
If you plan to migrate from redux-thunk and prefer to reuse your action type, you may specify the startType and endType when you createService.
const doSomeThing = createService({
endType: 'DO_SOMETHING',
startType: 'DO_SOMETHING_STARTED',
payloadCreator,
metaCreator,
});
store.dispatch(doSomeThing('good'));
expect(store.dispatch.calls.argsFor(0)).toEqual([{
type: 'CALL_RESERVICE',
payload: 'good',
reservice: {
name: 'DO_SOMETHING',
start: 'DO_SOMETHING_STARTED',
state: 'CREATED',
},
}]);
// After the reservice middleware handled the action,
// another action will be dispatched.
expect(store.dispatch.calls.argsFor(1)).toEqual([{
type: 'DO_SOMETHING_STARTED',
payload: 'good',
reservice: {
name: 'DO_SOMETHING',
start: 'DO_SOMETHING_STARTED',
state: 'CREATED',
...
},
}]);
// After the service is done, this action will be dispatched.
expect(store.dispatch.calls.argsFor(2)).toEqual([{
type: 'DO_SOMETHING',
payload: 'the result...',
error: false,
reservice: {
name: 'DO_SOMETHING',
start: 'DO_SOMETHING_STARTED',
state: 'END',
...
},
}]);
In most case you may try to refine API response data by selector function then put it into redux store. You can do it inthe service, or do it in the reducer.
If you run selector in the service, you dispatch only selected data to reducer. This practice save time and space when the smaller service result be transmitted from server to client, but prevent you to see full API response in network or redux debugging tools.
If you run selector in the reducer, you dispatch full data to reducer. This practice may take more time and space to transmit result from server to client, but you can see full API response in debug tools.
Reservice provide two small functions to help you adopt all these two practices in development and production environments:
// The selector function
const mySelector = result => ({
data: result.body.data.reduce(refineMyData, {}),
error: result.body.error
});
// In a service
import { prodSelect } from 'reservice';
// Run your selector only when in production environment, keep original result when in development environment.
const myProdSelector = prodSelect(mySelector);
const myService = payload => callAPI(payload).then(result => myProdSelector(result));
// In a reducer
import { devSelect } from 'reservice';
// Run your selector only when in development environment, keep original result when in production environment.
const myDevSelector = devSelect(mySelector);
const myReducer = (state = initialState, action) => {
switch (action.type) {
case 'MY_SERVICE':
return { ...state, ...myDevSelector(action.payload) }
}
return state;
}
Or, you can define service as { service, selector } , reservice will keep full result in action.reservice.full_payload for debugging when not in production environment. And, the selected result still be placed in action.payload.
// Original Service code with result selector
const selectResult = (result) => result.body.items;
export myService = (payload, req) => callSomeApi({ ...payload, req }).then(selectResult);
// change export from function into { service , selector } for better debugging info
export myService = {
service: (payload, req) => callSomeApi({ ...payload, req }),
selector: (result) => result.body.items,
};
Here is a migration example to adopt reservice selector.