320H.13 - The Redux Store and Middleware
Learning Objectives
By the end of this lesson, learners will be able to:
- Configure the Redux store using
configureStore
. - Describe the purpose of middleware in Redux.
- Create and add middleware to the Redux store configuration.
- Describe the purpose of "thunks" in programming.
- Use thunks in Redux to handle side effects, asynchronous logic, and more.
CodeSandbox
This lesson uses CodeSandbox as one of its tools.
If you are unfamiliar with CodeSandbox, or need a refresher, please visit our reference page on CodeSandbox for instructions on:
- Creating an Account
- Making a Sandbox
- Navigating your Sandbox
- Submitting a link to your Sandbox to Canvas
Configuring the Redux Store
The Redux Toolkit provides many abstractions over the standard Redux core functions that handle typical use cases and reduce boilerplate - configureStore
is another one of those abstractions.
We have seen configureStore
's simplest usage several times already:
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({ reducer: rootReducer })
However, it is important to know what you gain when using configureStore
over createStore
, other than it being less verbose.
We know that configureStore
accepts a single object as its parameter, but we have not explored any of the options available to us (other than reducer
). Here is a full breakdown of all of the parameters, taken from the documentation reference:
reducer
is either a single function used as the root reducer for the store, or an object of slice reducers that is automatically passed tocombineReducers
in order to create a root reducer.middleware
is an optional array of Redux middleware functions (more on this soon).configureStore
automatically passes these functions toapplyMiddleware
. If this option is not provided,configureStore
callsgetDefaultMiddleware()
and uses the array of middleware functions it returns (which is why our previous stores have had some middleware enabled without our specification).devTools
is a simple boolean that enables or disables support for the Redux DevTools browser extension.preloadedState
takes an optional initial state value.enhancers
is an optional array of store enhancers (similar to reducer enhancers). Middleware and the DevTools extension are both types of store enhancers, for example, but they are handled separately.
type ConfigureEnhancersCallback = (
defaultEnhancers: EnhancerArray<[StoreEnhancer]>
) => StoreEnhancer[]
interface ConfigureStoreOptions<
S = any,
A extends Action = AnyAction,
M extends Middlewares<S> = Middlewares<S>
> {
/**
* A single reducer function that will be used as the root reducer, or an
* object of slice reducers that will be passed to `combineReducers()`.
*/
reducer: Reducer<S, A> | ReducersMapObject<S, A>
/**
* An array of Redux middleware to install. If not supplied, defaults to
* the set of middleware returned by `getDefaultMiddleware()`.
*/
middleware?: ((getDefaultMiddleware: CurriedGetDefaultMiddleware<S>) => M) | M
/**
* Whether to enable Redux DevTools integration. Defaults to `true`.
*
* Additional configuration can be done by passing Redux DevTools options
*/
devTools?: boolean | DevToolsOptions
/**
* The initial state, same as Redux's createStore.
* You may optionally specify it to hydrate the state
* from the server in universal apps, or to restore a previously serialized
* user session. If you use `combineReducers()` to produce the root reducer
* function (either directly or indirectly by passing an object as `reducer`),
* this must be an object with the same shape as the reducer map keys.
*/
preloadedState?: DeepPartial<S extends any ? S : S>
/**
* The store enhancers to apply. See Redux's `createStore()`.
* All enhancers will be included before the DevTools Extension enhancer.
* If you need to customize the order of enhancers, supply a callback
* function that will receive the original array (ie, `[applyMiddleware]`),
* and should return a new array (such as `[applyMiddleware, offline]`).
* If you only need to add middleware, you can use the `middleware` parameter instead.
*/
enhancers?: StoreEnhancer[] | ConfigureEnhancersCallback
}
function configureStore<S = any, A extends Action = AnyAction>(
options: ConfigureStoreOptions<S, A>
): EnhancedStore<S, A>
These options allow you to configure the behavior of the Redux store for your particular uses. A proper store configuration can make your application more performative and allow you access to features that would otherwise be difficult to implement.
Aside: Middleware
What is middleware?
If you are unfamiliar with the term, you are almost certainly familiar with the concept. Middleware is simply code that goes between the framework receiving a request and the framework generating a response (it goes in the middle).
In the context of Redux, middleware provides an extension point between dispatching an action (the framework receiving a request) and the moment that action reaches the reducer (the framework generating the response).
Middleware can be used for a very wide variety of purposes, including but not limited to logging, crash reports, communications with an async API, and routing.
Default Middleware
By default, configureStore
chooses a configuration that works well for most Redux applications (as is the intention of the Redux Toolkit in general). This includes a list of default middleware intended to prevent common mistakes and enable commonly used features.
Some of this middleware is only included in the development builds of applications, in order to provide runtime checks for some of the most common issues, including:
- Immutability: The immutability middleware does a deep comparison of state to check for mutations. If mutations occur during or between dispatched actions, it will throw an error and indicate the path for the mutated value within the state tree.
- Serializability: The serializability middleware looks for non-serializable values (functions, Promises, Symbols, etc.) within state or dispatched actions, and logs non-serializable values to the console.
- Action Creators: The action creator middleware simply checks if you have dispatched an action creator instead of the action created by calling it:
dispatch(actionCreator)
(incorrect) instead ofdispatch(actionCreator())
(correct).
In a production environment, these middlewares are not included, as it is assumed that you've handled all of these common errors before deploying to production (hopefully!).
Instead, the only default middleware included during production (and development) is the thunk middleware, which is the basic recommended middleware for side effects in Redux. We'll discuss thunks shortly.
If you need to customize the default middleware, you can do so with a function that accepts getDefaultMiddleware
as a parameter, and then returns it. getDefaultMiddleware
accepts an options object that allows you to customize the default middleware by either setting its cooresponding field to false
, which disables it, or by passing an options object into its cooresponding field:
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducer'
import { myCustomApiService } from './api'
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
thunk: {
extraArgument: myCustomApiService,
},
serializableCheck: false,
}),
})
The details of each individual piece of default middleware (and other existing middleware) are for you to explore as you continue your journey. There is a lot of middleware out there, and you may run into a use case for developing your own as well.
Custom Middleware
As an example of how to design middleware, should you need to do so, let's look at the issue of logging. When creating and debugging our application, it might be nice to log every action that is dispatched to the store and the resulting state change. This would make it very easy to see which actions result in incorrect state changes.
So, could we just log everything manually?
const action = addTodo('Use Redux')
console.log('dispatching', action)
dispatch(action)
console.log('next state', store.getState())
Sure we could! This works; however, it would get quite cumbersome to do this with every action we dispatch, especially in a larger application.
Remember DRY? Don't repeat yourself! One of the very first tools you were introduced to in order to avoid repetition was functions, so can we just make this into one?
function dispatchAndLog(store, action) {
console.log('dispatching', action)
dispatch(action)
console.log('next state', store.getState())
}
Sure we could! You can use this instead of calling dispatch
every time, but we still need to import this special function into every file that wants to use it.
store
, however, is just an object with some methods, so can't we just replace the default dispatch
?
const next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
Sure we could! This is called "monkey patching," and it is a way to override the runtime behavior of a piece of code without altering its source (because we definitely would not want to dive into the Redux source and modify that). This is considered a hack, not a recommended solution.
This hack, however, seems to address the problem that we were trying to solve, but... it's not middleware. So what's wrong with it that makes middleware necessary?
What if we wanted to add more than one piece of this type of functionality, like also adding crash reporting? Ideally, these are two separate pieces of code, but they would both need to change the dispatch
function. There are ways to do this with monkeypatching, but in essence they're just hiding the original problem, not fixing it.
In order to address this, middleware takes a next()
function, and returns its own function which serves as next()
to the middleware after it, and so on. This is the essential way middleware works, by chaining each piece until an eventual end is reached.
Here's how that would look with our logging example:
const logger = store => next => action => {
console.log('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
return result
}
Now, this is middleware! This very simple piece of middleware is being used to illustrate a concept, but the pattern remains the same at scale.
The Redux documentation provides some other examples of valid Redux middleware, included below. Not all of these examples are practical or particularly useful, but they demonstrate the basic principle so that you can confidently configure your Redux store with middleware when desired.
Take some time to digest these examples, and then we'll provide a live example via CodeSandbox.
/**
* Logs all actions and states after they are dispatched.
*/
const logger = store => next => action => {
console.group(action.type)
console.info('dispatching', action)
let result = next(action)
console.log('next state', store.getState())
console.groupEnd()
return result
}
/**
* Sends crash reports as state is updated and listeners are notified.
*/
const crashReporter = store => next => action => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
Raven.captureException(err, {
extra: {
action,
state: store.getState()
}
})
throw err
}
}
/**
* Schedules actions with { meta: { delay: N } } to be delayed by N milliseconds.
* Makes `dispatch` return a function to cancel the timeout in this case.
*/
const timeoutScheduler = store => next => action => {
if (!action.meta || !action.meta.delay) {
return next(action)
}
const timeoutId = setTimeout(() => next(action), action.meta.delay)
return function cancel() {
clearTimeout(timeoutId)
}
}
/**
* Schedules actions with { meta: { raf: true } } to be dispatched inside a rAF loop
* frame. Makes `dispatch` return a function to remove the action from the queue in
* this case.
*/
const rafScheduler = store => next => {
const queuedActions = []
let frame = null
function loop() {
frame = null
try {
if (queuedActions.length) {
next(queuedActions.shift())
}
} finally {
maybeRaf()
}
}
function maybeRaf() {
if (queuedActions.length && !frame) {
frame = requestAnimationFrame(loop)
}
}
return action => {
if (!action.meta || !action.meta.raf) {
return next(action)
}
queuedActions.push(action)
maybeRaf()
return function cancel() {
queuedActions = queuedActions.filter(a => a !== action)
}
}
}
/**
* Lets you dispatch promises in addition to actions.
* If the promise is resolved, its result will be dispatched as an action.
* The promise is returned from `dispatch` so the caller may handle rejection.
*/
const vanillaPromise = store => next => action => {
if (typeof action.then !== 'function') {
return next(action)
}
return Promise.resolve(action).then(store.dispatch)
}
/**
* Lets you dispatch special actions with a { promise } field.
*
* This middleware will turn them into a single action at the beginning,
* and a single success (or failure) action when the `promise` resolves.
*
* For convenience, `dispatch` will return the promise so the caller can wait.
*/
const readyStatePromise = store => next => action => {
if (!action.promise) {
return next(action)
}
function makeAction(ready, data) {
const newAction = Object.assign({}, action, { ready }, data)
delete newAction.promise
return newAction
}
next(makeAction(false))
return action.promise.then(
result => next(makeAction(true, { result })),
error => next(makeAction(true, { error }))
)
}
/**
* Lets you dispatch a function instead of an action.
* This function will receive `dispatch` and `getState` as arguments.
*
* Useful for early exits (conditions over `getState()`), as well
* as for async control flow (it can `dispatch()` something else).
*
* `dispatch` will return the return value of the dispatched function.
*/
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action)
Configuring the Store with Custom Middleware
When adding custom middleware alongside the default middleware (which is the most common use case), it is recommended to use chained .concat()
or .prepend()
methods on the array returned from getDefaultMiddleware()
rather than using the spread operator, as the latter can lose type information under some circumstances.
Here's how that might look:
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
})
In order to demonstrate this in action, we have...
Another counter example, this time with logging middleware! Check out the console while you interact with this application:
Note that is is generally advisable to export middleware from separate files and import it into your store configuration file for organizational purposes, but since this is a small example we wanted to demonstrate everything in once place.
Thunks
We briefly mentioned thunks earlier, since they are included as part of the default middleware the configures the Redux store. So what's a "thunk"?
In programming, "thunk" simply refers to a piece of code that does some delayed work. Instead of executing the code immediately, we write some code that can be used to perform the work later.
In the context of Redux, "thunks" are a pattern of writing functions with logic inside that can interact with a Redux store's dispatch
and getState
methods.
Thunks are most commonly used for data fetching and writing async logic, but they can contain both synchronous and asynchronous logic. They allow us to write Redux logic separate from the UI layer, which can include side effects (like async requests or generating random values) or logic that requires dispatching multiple actions or access to the state of the store.
Since reducers cannot contain side effects, thunks provide a place to put them.
We will not discuss thunks in detail during this lesson, as a future lesson will cover asynchronous Redux patterns and data fetching, but it is useful to know the basics of thunks at this stage, since they can inform many design decisions when planning and creating an application.
A thunk is very simple; it takes the store's dispatch
and getState
methods as arguments, and does some logic with them. That logic, having access to dispatch
and getState
, can either dispatch actions, read state, or both.
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}
dispatch(thunkFunction)
Thunks can contain any arbitrary logic, synchronous or asynchronous, and can call dispatch
or getState
at any time (even at an arbitrary point in time after the thunk is dispatched). This is what makes a thunk a thunk: it can do delayed work.
Here's a very simple practical example of what a synchronous thunk might be used for:
- The thunk dispatches an action.
- The thunk compares the state before and after the initial action is dispatched.
- The thunk dispatches a second action if the conditional logic is
true
.
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())
const secondState = getState()
if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}
This illustrates one very common way thunks are used.
Here's another thunk, this time with demonstrable purpose:
export const incrementAsync = (amount) => (dispatch) => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
Can you guess where this is headed? Examine the counter example below to get a feel for how this thunk was implemented!
This example also still includes our logging middleware, if you want to see how that behaves alongside thunks.
Summary
The Redux store can be configured easily by using Redux Toolkit's configureStore
function, which takes a single object parameter with multiple fields. The simplest configuration provides the reducers that the store will use, and no other fields:
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer,
}
})
However, the configuration object passed to configureStore
can also include fields for middleware
, devTools
, preloadedState
, and enhancers
.
Middleware in Redux provides an extension point between dispatching an action (the framework receiving a request) and the moment that action reaches the reducer (the framework generating the response). Middleware has access to the dispatch
and getState
methods of store
.
One piece of commonly used default middleware is thunks. The thunk middleware allows us to dispatch
thunk functions that handle side effects, asynchronous logic, or logic that requires dispatching multiple actions or accessing the Redux store.
A React + Redux Application: Part Three
With the introduction to Redux middleware and thunks, we're ready to continue working on part three of GLAB 320.11.1 - Redux State Management. We will complete the fourth part following the next lesson, at which point you'll have a fully-working (and optimized) application!