.When I first started out using Redux, I learned very quickly how to use (read: abuse) Redux actions. Actions and middleware used to be simple: if it’s synchronous, put it in an action, but if it’s asynchronous then you should use middleware.
Unfortunately for us, redux-thunk
was created. Now don’t get me wrong; Thunk is great! It allows you to make asynchronous calls inside redux actions. This can be much simpler than using middleware in a lot of cases. But you have to know where to draw the line.
When should you put asynchronous code in an action vs. middleware? We’ll find out.
If you’re unfamiliar with the redux architecture, consider reading the section on the flux architectural pattern in this analysis of architectures I did (Redux is inspired by the Flux architecture).
But first, let’s do some review. In the examples below, I’ll assume you have some knowledge of Redux and its architecture. If you haven’t, consider checking out my series on building a web app from the ground up, where I show you how to get started with React and Redux.
Synchronous Actions on Redux
Synchronous actions are the purest form of a Redux action. They may perform some or no special logic, then dispatch an action which reducers listen for. Actions have a type and typically have a payload.
In the below code, it increments a value and then dispatches an action with that newly incremented value. This action would probably than be observed by a reducer, but we’re not going to go into reducers today.
function updateValue(oldValue) { return (dispatch) { dispatch({ type: types.RECEIVE_VALUE, value: oldValue + 1 }); }; }
This is one of the simplest possible actions you can have. But what happens when we start making API requests?
Async Actions with Middleware
Before thunk became so popular, many resorted to using middleware to make their API requests. This is fine, but adds a lot more complexity for simple API requests.
In the below example, the updateValue
function (first seen above) now just dispatches an action with the old value. This action is seen by the middleware, which makes an API request to get the new value. Furthermore, the middleware dispatches a new action with the updated value.
//actions.js export function receiveValue(value) { return (dispatch) { dispatch({ type: types.RECEIVE_VALUE, value: value }); }; } export function updateValue(value) { return (dispatch) { dispatch({ type: types.UPDATE_VALUE, value: value }); }; } //middleware.js import {receiveValue} from './path/to/actions'; const customMiddleware = store => next => action => { if(action.type === types.UPDATE_VALUE) { fetch('http://example.com/value') .then(response => response.json()) .then(json => { store.dispatch(receiveValue(json.value)); }); } return next(action); }; export default customMiddleware;
This is how one might have made an asynchronous API request before thunk came into the light. I have two big problems with this: It’s extra logic that needs to be performed for a simple task, and it segregates a fairly straightforward action from the rest of your actions.
Enter Thunk.
Async Actions with Thunk
Redux-thunk allows you to return functions from your action creators. This means that we can return promises from our action creators, as long as they eventually resolve with an action.
To accomplish the same thing as the above, we can follow the example below.
This example uses the same receiveValue function. However, instead of dispatching an action for UPDATE_VALUE
, we can just perform the API request directly in our updateValue
function.
//actions.js export function receiveValue(value) { return { type: types.RECEIVE_VALUE, value: value }; } export function updateValue(value) { return (dispatch) { return fetch('http://example.com/value') .then(response => response.json()) .then(json => { return store.dispatch(receiveValue(json.value)); }); }; }
I like performing my API requests this way, along with other asynchronous actions. In fact, I have a couple of reasons I think this is better.
- It’s less code, and more readable
- It keeps all simple actions grouped together in the same file/folders
- It reduces the amount of configuration and extra layers necessary.
- This looks much easier to test than 3 functions spread across two files and two layers of the architecture.
So why do we ever use middleware? Why don’t we just rely on redux-thunk for every asynchronous action?
Things get messy.
Drawing the Line Between Actions and Middleware
If using redux-thunk is so great, then why would I ever want to use middleware for asynchronously dispatching actions again?
Well, consider the following: I have an authentication mechanism. This method of authentication expires my token every 10 minutes. So with each authenticated API request I make, I also want to refresh my token. We can do this much easier using Middleware than we can by using actions. It’s likely that any solution using actions and thunk would likely include duplicate code, calling the same function to refresh the token every time. Using a middleware solution, this could be as simple as dispatching an API_REQUEST
action, which gets observed by the middleware, which makes a request to refresh the token.
Here are a few scenarios where I would recommend using middleware over actions:
Nested Asynchronous Functions
When you’re using nested asynchronous functions, your action has likely lost it’s simplicity. The following block of code shows what I’m talking about.
function performActions() { return (dispatch) => { return fetch('http://example.com/request1') .then(response => response.json()) .then(json => { // get something out of the json let val = json.val; // use it in another request return fetch('http://example.com/request2/?val='+val) .then((response) => response.json) .then(json => { return dispatch(receiveValue(json)); }); }); }; }
This is a pretty straightforward example, but it’s still an eyesore. Keep in mind that we would also likely want to add error handling, and additional action dispatches here. It could grow to become quite messy by the time it’s complete. This could be done a lot more neatly using middleware.
I would organize this code more like the following block:
//actions.js function requestValue1() { return { type: types.REQUEST_VALUE_1 }; } function receiveValue1(json) { return (dispatch) => { return dispatch({ type: types.RECEIVE_VALUE_1, value: json.val }); }; } function receiveValue2(json) { return (dispatch) => { return dispatch({ type: types.RECEIVE_VALUE_2, value: json.val }); }; } function performActions() { return (dispatch) => { return dispatch(requestValue1()); }; } //middleware.js const customMiddleware = store => next => action => { switch(action.type) { case types.REQUEST_VALUE_1: fetch('http://example.com/request1') .then(response => response.json()) .then(json => store.dispatch(receiveValue1(json)); break; // when we receive value 1, we can request value 2 case types.RECEIVE_VALUE_1: fetch('http://example.com/request2/?val='+action.val) .then(response => response.json()) .then(json => store.dispatch(receiveValue2(json)); break; } return next(action); };
This code is much easier to read than the first attempt. It also has the advantage that it’s much easier to test. Since we’ve extracted all inter-dependencies to be through the redux store, we can pretty easily make a mock store and test each individual piece of this code if we wanted to.
Writing/Reading localStorage and Cookies
Another scenario where I recommend using middleware instead of actions is when reading and writing to cookies and localStorage. Alternatively, if you’re simply saving your entire state to localStorage, you could use a subscriber as Dan Abramov suggests here.
Actions should not contain logic to persist state. Furthermore, reducers shouldn’t have side-affects outside of the redux store. This leaves us with the only logical places to perform this kind of logic being in middleware or in a subscriber. I very rarely save my entire state to localStorage. So, I typically end up doing this in middleware rather than a subscriber so I can perform additional logic.
This is a rather simple example, but I’ll include it anyway.
The below code saves some to localStorage within an action.
function saveValue(value) { return (dispatch) { localStorage.setItem('VALUE', value); return dispatch({ type: types.RECEIVE_VALUE, value: value, }); }; }
As you can see, the action above is saving a value to localStorage, than dispatching an action so that a reducer can save the value as well. Doesn’t it seem odd that we would dispatch an action to save something to our redux store, but not to localStorage? It seems weird to me.
Like I said before, actions shouldn’t handle the saving of data. To better structure this, we could dispatch our action like normal, then listen for it in a middleware.
//actions.js function saveValue(value) { return (dispatch) => { return dispatch({ type: types.SAVE_VALUE, value: value }); }; } //middleware.js const customMiddleware = store => next => action => { if(action.type === types.SAVE_VALUE) { localStorage.setItem('VALUE', action.value); } return next(action); };
This is much better in my mind, and this structure blends well with the redux architecture.
FYI if you want to mock localStorage for tests, you can do that using a package like jest-localstorage-mock.
Performing Logic for Multiple Actions
Ah. Now, this is truly a situation that middleware excels at.
Now, you could simply call an alternative helper function from all of your actions that you need the logic for, but doing it in middleware is just so much cleaner!
For instance, take the example below where we use a method to perform some logging functionality. In the example below, the logging function is being called during each action. As the number of actions we want to add logging functionality for grows, so does the complexity of the code.
function action1() { return (dispatch) { logToServer(something); dispatch({ type: types.ACTION1 }); }; } function action2() { return (dispatch) { logToServer(something); dispatch({ type: types.ACTION2 }); }; }
A better way to implement logging functionality, and any functionality where we need to do logic based on multiple functions, would be in middleware. This time, it’s as easy as setting up a switch statement and doing our logic after all the cases we put.
//actions.js function action1() { return (dispatch) { dispatch({ type: types.ACTION1 }); }; } function action2() { return (dispatch) { dispatch({ type: types.ACTION2 }); }; } //middleware.js const customMiddleware = store => next => action => { switch(action.type) { case: types.ACTION1: case: types.ACTION2: logToServer(action); } return next(action); };
Conclusion
Redux middleware can tremendously simplify a lot of complex functionality. I tried to keep the examples above pretty straightforward, but you can imagine how some of those instances could easily get out of control unless implemented correctly. Just like any technology though, redux middleware has to be implemented correctly.
If you have a gut feeling that a specific action, thunk, or middleware is not being implemented in the best way possible, do research and seek feedback. I’ll even give you some feedback if you want to just send a message my way.
As always, if you have another use case where you think redux middleware makes a great design choice, comment with it below!