For reference, here is a standard synchronous Redux action creator:
export const simpleAction = () => {
return {
type: actionTypes.PERFORM_SIMPLE_ACTION
};
}
Things to note:
- it does not know anything about how the action is performed
- it is up to some other part of the application to determine what to do with this action
Redux documentation on async actions
This is a stripped down example from the Redux documentation:
import * as actionTypes from './action-types';
export const fetchPosts = () => {
// thunk middleware - allow action creators to return functions
return (dispatch) => {
dispatch({
type: actionTypes.FETCH_POSTS_REQUEST
});
dataService.getPosts()
.then(posts => {
dispatch({
type: actionTypes.FETCH_POSTS_SUCCESS,
posts
});
})
.catch(() => {
dispatch({
type: actionTypes.FETCH_POSTS_ERROR
});
});
};
};
-
The 'action creator' becomes the 'action coordinator'
Unlike normal actions, this pattern breaks the idea that the action create function creates an action description. The action creator
fetchPosts
actually coordinates a lot of the action -
This action must always call
dataService.getPosts()
For this action creator to work
dataService.getPosts()
need to exist and behave in a particular way. This becomes tricky when you want to:- have a different
dataService
for a different consumption points. For example amockDataService
This raises the problem of getting the correct
dataService
dependency into the file. Conditional dependencies is a way to solve this problem but it not ideal.- not do anything with the action
What if a consumer of one of our components does not have a
dataService
and wants to do something different, or nothing at all with the action? - have a different
-
No application level control over the
dataService
The action creator
fetchPosts
does not have access to the store state. It cannot make a decision to not calldataService.getPosts()
based on the current state. The responsibility for this would be up to the caller of the action creator.
Our action creator becomes beautiful again:
// action-creators.js
export fetchPosts = () => {
return {
type: actionTypes.FETCH_POSTS_REQUEST
};
};
Middleware
// middleware.js
import * as actionTypes from './action-types';
const fetchPostsMiddleware = store => next => action => {
// let the action go to the reducers
next(action);
if (action.type !== actionTypes.FETCH_POSTS_REQUEST) {
return;
}
// do some async action
setTimeout(() => {
// sort of lame because it will actually call
// this middleware again
store.dispatch({
type: actionTypes.FETCH_POSTS_SUCCESS,
posts: [1, 2, 3]
});
});
};
- you can have multiple middleware for the same action type, or none to ignore it.
- removing responsibility for coordinating the action away from the action creator
fetchPosts
-
Gives too much power to something that is just responding to actions
In middleware you can:
- dispatch new actions
- control the flow of data to the reducers
- perform other powerful tasks
This is a lot of power and responsibility given to something that previously had very little power. Notice that the middleware needs to call
next(action);
. This releases the action to the reducers. This would be something that is easy to forget and could cause a lot of pain. -
Recursive dispatch can be confusing
store.dispatch({ type: actionTypes.FETCH_POSTS_SUCCESS, posts: [1, 2, 3] });
This dispatch would fire a new action that would hit the
fetchPostsMiddleware
again! It would not dispatch a second action because of this check:if (action.type !== actionTypes.FETCH_POSTS_REQUEST) { return; }
However, it is possible to create infinite loops if you are not careful (I have!). It can also add additional cognitive load to trying to understand how your application works; where as the original pattern is quite simple to reason about.
-
Middleware is synchronous
If you did some processing before calling
next(action);
you would be adding overhead to every action that goes through your application.
We still have a beautiful action creator
// action-creators.js
export fetchPosts = () => {
return {
type: actionTypes.FETCH_POSTS_REQUEST
};
};
A 'listener' definition
// data-service-listener.js
import * as actionTypes from './action-types';
import dataService from './data-service';
export default {
[actionTypes.FETCH_POSTS_REQUEST]: (action, dispatch, state) => {
// in this listener we get some posts
// but we could do anything we want
dataService.getPosts()
.then(posts => {
dispatch({
type: actionTypes.FETCH_POSTS_SUCCESS,
posts
});
})
.catch(() => {
dispatch({
type: actionTypes.FETCH_POSTS_ERROR
});
});
}
// could add other types to this listener if you like
};
New middleware to handle listeners
// listener-middleware.js
export default (...listeners) => store => next => action => {
// listeners are provided with a picture of the world
// before the action is applied
const preActionState = store.getState();
// release the action to reducers before firing
// additional actions
next(action);
// always async
setTimeout(() => {
// can have multiple listeners listening
// against the same action.type
listeners.forEach(listener => {
if (listener[action.type]) {
listener[action.type](action, store.dispatch, preActionState);
}
});
});
};
Constructing a store
// store.js
import listenerMiddleware from './listener-middleware';
import dataServiceListener from './data-service-listener';
import reducer from './reducer';
import loggerMiddleware from './logger-middleware';
const store = createStore(
reducer,
applyMiddleware(
// add other middleware as normal
loggerMiddleware,
// add as many listeners as you like!
listenerMiddleware(dataServiceListener)
)
);
Gives too much power to something that is just responding to actions
- Listeners do not need to know anything about
next(action);
or the flow of the application. - Listeners get information and they can do stuff with it only through
dispatch
. - Listeners are only called when they are relevant by using
action.TYPE
as a key (eg[actionTypes.FETCH_POSTS_REQUEST]
)
Recursive dispatch can be confusing
Listener does not get called recursively (unless you dispatch an action with a matching key of course)
Middleware is synchronous
We made a strong stance that listeners should always be async. This avoids them blocking the reducers and render of your application. Doing this makes it harder for a listener to get itself into trouble.
The 'middleware listener' pattern is a powerful way to take coordination logic out of action creators. It allows different applications to decide what they want to do with particular actions rather than letting the action decide. We already do this with reducers - why not do it with async actions as well!