How to Use Redux Middleware to Better Control Your Data and Write Cleaner Code
Redux itself follows a step-by-step synchronous process. It synchronously dispatches actions, updates the state via the root reducer and finally propagates data changes through the application. The action/reducer flow is great for updating the state of an application in a predictable manner.
Given that the code runs linearly, your reducers cannot contain side effects. According to Redux, a side effect is “any change to state or behavior that can be seen outside of returning a value from a function.” However, at times, we may need to run side effects without disrupting this flow.
For example, what if we want to send an API request or perform some very expensive operation with a background worker? These are asynchronous operations and don’t fit into the flow that Redux has. Most real-world applications require some asynchronous operation to occur (HTTP requests, saving files, etc.), so how do we incorporate them with Redux?
When finding yourself in this situation, attempting to do some asynchronous process with Redux, middleware can come in handy. Let’s take a look at what middleware is.
What Does Middleware Do?
With middleware, you are able to intercept a dispatched action before it reaches the reducer. This allows you to make changes to the action or just cancel the action altogether. These changes that you can make are the side effects that you are trying to incorporate.
Each middleware can be thought of as a layer of code that runs right after you’ve dispatched a Redux action but before the reducer gets executed. Using the concept of promises in Javascript, you’re able to run asynchronous code within the Redux action/reducer flow.
In order to better understand what this means, let’s talk about the flow Redux goes through with and without middleware.
How Redux Executes Middleware
Without any middleware, whenever you call a Redux action, the data will go through this flow:
- Some event occurs on your application
- An action is dispatched as a result
- Reducer creates a new state based on data passed through the action
- The new state is passed back into the React app via props and selectors
Keep in mind, all of the code that runs this process is synchronous, meaning that it runs on the client’s computer and the code runs step-by-step. There’s no way to change or cancel an action once it’s been dispatched.
Now, let’s look at the flow when the middleware is used:
- Some event occurs on your application
- An action is dispatched as a result
- Middleware receives the action and returns the next action
- Reducer creates a new state based on data passed through the action
- The new state is passed back into the React app via props and selectors
With this flow, you are able to run your own code before the reducer gets the final data that it uses to generate the next state. You are also able to completely cancel the action from reaching the reducer as well.
Your code can run whatever asynchronous operation that it needs to run as long as it returns another function for the next middleware or reducer to process.
Benefits of Middleware
The main problem that middleware solves is being able to run some piece of code between the action and reducer, allowing you to perform asynchronous operations. In solving this problem, utilizing middleware in your project brings the following benefits:
- Write Maintainable Code — With each layer of middleware, you can split responsibilities in a scalable manner. Each layer is only responsible for one thing. For example, one layer could be just for logging actions, another just to check cached data and so on.
- Flow Control — Since you know the order of when each middleware will be executed, you can easily control the flow of actions coming into your application. For example, you could have a middleware layer to make sure a user is logged in with an additional layer to check if the user has permission to run a particular action.
- Make Debugging Easier — Since you only write your logic code once in a middleware layer, you can confidently debug programs and make sure that things will work how they should.
By combining multiple simple middleware layers, you can build an extremely complicated flow. That’s where the magic happens.
There are many third-party libraries out there that you can use as middleware for Redux in your project. For example, redux-thunk
allows you to perform network requests and other asynchronous operations with Redux. Others include loggers, caching systems and more.
Applying Middleware to Redux
Whenever we want to use middleware, whether it be a third-party library or your own code, Redux provides us with an API function called applyMiddleware
which allows us to use middleware.
For example, if we wanted to use redux-thunk
with Redux, we would have to initialize our Redux store in the following manner:
import { createStore, applyMiddleware } from 'redux';
import rootReducer from './reducers/index';
import thunk from 'redux-thunk';const store = createStore(rootReducer, applyMiddleware(thunk));
Now, whenever we dispatch a Redux action, it will apply the middleware defined in the redux-thunk
library.
But what if we wanted to make our own middleware in addition to using redux-thunk
? Say we wanted to make our own custom logging system that integrates with Sentry.
Making Custom Middleware
Before we make our custom middleware, it’s important that you know what higher-order functions are. It’s important to understand this because your middleware code will use this concept. If you’ve just copied some code from the internet to make a custom middleware but don’t really understand this concept, you will be limiting what you can do with middleware.
In simple terms, higher-order functions basically take in some inputs, do some operation and return a new function. In a similar fashion, middleware takes in the action and state as inputs, your code does some operation and it returns a new function.
I would take the time to understand this concept intuitively because it will serve you beyond just making custom middleware.
Making a Custom Logging Middleware
Each middleware function receives the Redux store’s dispatch()
function and getState()
function. The dispatch()
function allows you to dispatch a new action while the getState()
function gives you access to data about the current state in Redux.
Given that, let’s build our custom logging middleware. To make the example simple, the Sentry-related code will be pseudo-code.
import { createStore, applyMiddleware } from 'redux';
import rootReducer from './reducers/index';
import * as Sentry from '@sentry/browser';
import thunk from 'redux-thunk';const sentryLogMiddleware = ({ getState }) => {
return next => action => {
Sentry.captureMessage('Action:', action);
Sentry.captureMessage('State:', getState());
return next(action);
}
}const store = createStore(
rootReducer,
applyMiddleware([sentryLogMiddleware, thunk])
);
Notice how the sentryLogMiddleware
function takes in the getState()
function, which allows you to access the current state of Redux.
Furthermore, if we did not include the return next(action)
line, the action would not ultimately change state. This is because each middleware layer basically must continue the chain of events for Redux. If your middleware does not, ultimately your global state will not change.
Conclusion
Middleware allows you to run your own code whenever an action is dispatched to Redux. While Redux is synchronous in its execution, using middleware allows you to run asynchronous code within Redux’s flow.
The main benefits of using middleware include writing maintainable asynchronous code, having better control of the flow of data and having an easier time debugging issues.
Applying middleware to Redux is as easy as using the applyMiddleware
API function that Redux provides when creating the store. From there, you are able to control the order of execution for your middleware layers as well.