The ‘middleware listener’ pattern: better asynchronous actions in Redux
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
Existing async pattern
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
});
});
};
};
Problems
1. 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
2. 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 a `mockDataService`
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?
3. 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 call `dataService.getPosts()` based on the current state. The responsibility for this would be up to the caller of the action creator
Middleware async pattern
What would it look like if we used middleware to help us decompose things?
Our action creator becomes beautiful again:
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]
});
});
};
Good things about this approach
- 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`
Bad things about this approach
1. 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 is something that is easy to forget and can cause a lot of pain.
2. 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.
3. 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.
Middleware async pattern
What would it look like if we used middleware to help us decompose things?
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
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)
)
);
Issues addressed with using middleware
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.
Conclusion
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!