Event-driven State Management
Prerequisite: Some experience with Redux and Redux Toolkit (v2.2.3)
There’s an interesting middleware in Redux Toolkit that’s often overlooked: the Listener Middleware (createListenerMiddleware).
I’ll start by saying that this is not a tutorial on how to use createlistenerMiddleware
, the Redux Toolkit doc has covered that. This is only to showcase some interesting use cases. Also, please note that I’ll be using the Redux Toolkit v2 API.
So what is createListenerMiddleware exactly? according to redux toolkit documentation,
A Redux middleware that lets you define “listener” entries that contain an “effect” callback with additional logic, and a way to specify when that callback should run based on dispatched actions or state changes.
So basically, the listener middleware listens for when a specified action is dispatched or when a particular state changes, triggering an effect callback in response. This is particularly useful when you have a side effect that needs to be executed after an action. For example, let’s consider a scenario where we have a form embedded in a modal, and we want to close the modal after a successful form submission.
Normally, we would dispatch the actions/functions in parallel like this:
const submitForm = async () => {
try {
await dispatch(submitForm(data)).unwrap();
dispatch(showModal(false));
} catch(error) {
console.log(error)
}
}
This will work perfectly fine, but consider a scenario where you have. multiple modals dispatching different actions but you want to close the modal when that action is dispatched or the promise is fulfilled. In such a case, you would need to dispatch (showModal(false))
in each modal/component where each action is dispatched.
A better approach is to centralize the side effect and listen for all events that require the modal to be closed after the action is dispatched or the promise is fulfilled. Here is a simple example:
//formSlice.js
import { createListenerMiddleware } from '@reduxjs/toolkit'
import { submitCompleteForm } from '../api'
export const formSlice = createAppSlice({
name: "form",
initialState: {
status: 'idle',
showModal: false,
},
reducers: create => ({
showModal: create.reducer((state, action) => {
state.showModal = action.payload
}),
submitForm: create.asyncThunk(
async (data) => {
const response = await submitCompleteForm(data)
return response.data
},
{
...
fulfilled: (state, action) => {
state.status = "idle"
},
...
},
),
}),
})
export const {
showModal,
submitForm
} = formSlice.actions
const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
actionCreator: createAction("form/submitForm/fulfilled"),
effect: async (action, listenerApi) => {
listenerApi.dispatch(showModal(false))
},
})
The main point of interest here is the listenerMiddleware.startListening({})
method. We used createAction
to define the redux action type because submitForm
is an async thunk, and we cannot use it directly. Essentially, the listener middleware effect callback dispatches showModal(false)
when submitForm
is fulfilled. There’s little benefit to using a listener middleware here other than moving the side-effect out of the component.
Listener middleware truly shines when the same side-effect needs to run upon the dispatch of any of the multiple action creators we’re listening for. Let’s consider the scenario where we have a modal for updating the user profile using updateUserProfile
async thunk and we also want the modal to close upon the successful fulfilment of updateUserProfile
. In such a case, we can update our listenerMiddleware.startListening({})
as follows:
//formSlice.js
import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'
import { updateProfile } from '../api'
export const formSlice = createAppSlice({
name: "form",
initialState: {...},
reducers: create => ({
showModal: create.reducer(...),
submitForm: create.asyncThunk(...),
updateUserProfile: create.asyncThunk(
async (data) => {
const response = await updateProfile(data)
return response.data
},
{
...
fulfilled: (state, action) => {
state.status = "idle"
},
...
},
),
}),
})
export const {
showModal,
submitForm,
updateUserProfile
} = formSlice.actions
const submitFormFulfilled =
createAction("form/submitForm/fulfilled");
const updateUserProfileFulfilled =
createAction("form/updateUserProfile/fulfilled");
const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
matcher: isAnyOf(
submitFormFulfilled,
updateUserProfileFulfilled
),
effect: async (action, listenerApi) => {
listenerApi.dispatch(showModal(false))
},
})
Now this is a more reasonable use case, here we’re listening for either submitForm
or updateUserProfile
and when either of the thunks is fulfilled, showModal(false)
is dispatched to close the modal. This structure makes it easier to maintain the code in a central location and allows for the reuse of the same side effect with several action creators without putting the side effect in the component.
Another interesting example is to log events to analytic tools like Google Analytics. I created a simple JavaScript object to act as my analytic tool; all it does is record the number of times an action type is called. See the example below in the sandbox.
You should open the Sandbox and convert it to a Devbox, then simply play with the buttons and observe the logged activities under the “Analytics” header when you click each button.
In Conclusion, this is just a demonstration of writing a more centralized, reusable and maintainable Redux setup with Listener Middleware. For more information and advanced use cases, you can always refer to the Redux Toolkit documentation.
Thanks for reading!