Redux and its powerful toolset, Redux Toolkit, are very popular global state management solutions in the JavaScript framework world, especially React. They enable scalable global state that handles both synchronous and asynchronous operations. Asynchronous Redux operations are called "thunks." A slice that will contain a thunk operation generally has three pieces to it: an isLoading
property that components can key off of to know if the operation is still ongoing, an error
property to know if the operation resulted in an error, and the data that the thunk received back from the server. That data can be within one or many properties depending on how your state is structured.
There are many use cases that demand that an operation occur once a thunk is finished. Here are a few examples. If a thunk results in an error you may want to log that error. If a form successfully submits, you may want to track that the user successfully submitted the form. You may also want to redirect the user to a specific page depending on what the thunk returns. In all of these examples, you must wait for the thunk to finish one way or another. How can you do that? In this blog we will take a look at some common scenarios and the various ways I have found that solve them.
Solutions
Before we dive into the specific side effects, lets establish the code that handles the thunk. The code below mimics the example from the RTK createAsyncThunks docs with a few tweaks to the payload contents and thunk cases.
// userSlice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
// First, create the thunk
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId: number, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
interface UserState {
isLoading: boolean;
userName: string;
error: string;
}
const initialState: UserState = {
isLoading: false,
userName: '',
error: '',
};
// Then, handle actions in your reducers:
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
// standard reducer logic, with auto-generated action types per reducer
},
extraReducers: (builder) => {
builder.addCase(fetchUserById.pending, (state, action) => {
state.isLoading = true;
state.error = '';
}),
builder.addCase(fetchUserById.fulfilled, (state, action) => {
state.isLoading = false;
// Set userName to fetched data
state.userName = action.payload;
}),
builder.addCase(fetchUserById.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message;
})
},
})
function userSelector(state: RootState): LeadState {
return state.user;
}
export { userSelector };
Plain, Old JavaScript
If the side effect to be performed is a standard function, the easiest solution would be to put it within the thunk itself. For side effects that are triggered on a successful async operation, one can simply put them after the async call. For side effects that are triggered if an error occurs, you would want to catch the error, perform, the side effect, and rethrow the error. Rethrowing the error is a very imporant part that cannot be forgotten since the extra reducers rely on that error to determine if they should run.
// userSlice.ts
import {trackSuccessfulFetch, trackFailedFetch} from 'some-tracking-sdk';
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId: number, thunkAPI) => {
try {
const response = await userAPI.fetchById(userId)
} catch (error) {
// Failure side effect logic goes here
trackFailedFetch(error.message);
throw error;
}
// Successful side effect logic goes here
trackSuccessfulFetch(response.data);
return response.data
}
)
Easy enough right?
But what happens if your side effects are sourced from hooks? What if you get trackSuccessfulFetch
and trackFailedFetch
from a provided React context like so:
const { trackSuccessfulFetch, trackFailedFetch } = useTrackingContext();
Since Redux is outside of the React lifecycle (which is required to call any hook), it is unable to call useTrackingContext
and is therefore unable to call trackSuccessfulFetch
and trackFailedFetch
. We need someway to call these side effects outside of the thunk and within our components which are within the React lifecycle.
useEffect
One quick and dirty solutions is to create a useEffect
that observes a specific piece of state that clues it into the result of a thunk. In our case those pieces of information is userName
and error
. They are initially empty strings and only change whenever the thunk completes.
// useTrackUserFetchResults.ts
import { useEffect } from 'react';
import { useTrackingContext } from 'some-tracking-sdk';
import { useSelector } from 'react-redux';
import {
userSelector,
} from '../reducers/userSlice';
function useTrackUserFetchResults() {
const { userName, error } = useSelector(userSelector);
const { trackSuccessfulFetch, trackFailedFetch } = useTrackingContext();
useEffect(() => {
// If userName is an empty string do nothing
if (!userName) {
return;
}
trackSuccessfulFetch();
}, [leadId]); // Fire whenever userName is changed
useEffect(() => {
// If error is an empty string do nothing
if (!error) {
return;
}
trackFailedFetch(error);
}, [error]); // Fire whenever error is changed
}
This solution gets the job done, but it doesn't look great. It feels kinda hacky, and there is a ton of boilerplate to accomplish what was done up above. One glaring problem is that we are now coupling our data state and our business logic. This is overly complex since the side effects are only concerned with the result of the async thunk. If we ever change the data state, we may also have to change the hook which would break the Open/Closed principle. It also feels disconnected from the actual calling of the thunk. This lives in a hook that could be called from anywhere in the application. How could we better associate the calling of the thunk with its side effects and remove the dependence on the userName
property?
Awaiting a thunk to finish
Whenever I first found this solution, I was shocked! I didn't realize that you can await thunks and get their values directly from them. We won't get into this, but you can even chain thunks together using this method if you have a thunk that relies on the success of another.
// FetchUser123Button.tsx
import { useTrackingContext } from 'some-tracking-sdk';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUserResult } from '../reducers/thunks/fetchUserResult';
function FetchUser123Button() {
const dispatch = useDispatch();
const { trackSuccessfulFetch, trackFailedFetch } = useTrackingContext();
async function fetchUser123(): Promise<void> {
const fetchUserResult = await dispatch(fetchUserById(123));
if (fetchUserById.rejected.match(fetchUserResult)) {
trackFailedFetch(fetchUserResult.error.message);
return;
}
trackSuccessfulFetch(fetchUserResult.payload.userName);
// You can then dispatch another thunk if needed
}
return (
<button onClick={fetchUser123}>Fetch user 123</button>
)
}
I like this approach better than the first for a few reasons. First, I think it reads better. It is natural to await an async action and then do other stuff (i.e. side effects). Second, there is no need for this arbitrary chain reaction that the first solution had. Third, we can now change the interface of the thunk/slice and be assured that the side effects will remain unaffected therefore satisfying the Open/Closed principle. Last, the side effect code lives right next to the code that caused it.
There are a few problems though. If you need to trigger side effects for each of the thunk states (pending
, fulfilled
, and rejected
), you will potentially need three if
statements to handle each set of side effects. If you then have multiple thunks called on the same user action, the code can bloat relatively quickly, and the function will begin to violate the Single Responsibility principle. How can we compartmentalize each of the side effects while maintaining the natural correlation between the thunk and the side effects?
Listener middleware
Now we get to the fun part. A core part of Redux is listener or observer functionality. Whenever an action is dispatched, the action's listeners get notified and perform the corresponding state updates. We can actually tap into that same action/listener pattern by using createListenerMiddleware, but instead of performing state operations, we will be performing our side effects.
To add our own custom listener middleware, the first thing we need to do is configure our store.
// store.ts
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'
// Create the middleware instance and methods
const listenerMiddleware = createListenerMiddleware()
const store = configureStore({
reducer: {
// Add reducers here
},
// Add the listener middleware to the store.
// NOTE: Since this can receive actions with functions inside,
// it should go before the serializability check middleware
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})
We can then add our listener with our side effect code dynamically at run time. The following hook would be called from within the component where the thunk is dispatched.
// useTrackFetchUserSuccess.ts
import { useEffect } from 'react';
import { useTrackingContext } from 'some-tracking-sdk';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUserResult } from '../reducers/thunks/fetchUserResult';
function useTrackFetchUserSuccess() {
const dispatch = useDispatch();
const { trackSuccessfulFetch } = useTrackingContext();
useEffect(() => {
// Could also just `return dispatch(addListener())` directly, but showing this
// as a separate variable to be clear on what's happening
const unsubscribe = dispatch(
addListener({
actionCreator: fetchUserResult.fufilled,
effect: (action, listenerApi) => {
trackSuccessfulFetch(action.payload.userName);
},
})
);
// Unsubscribe from the listener when the component unmounts
return unsubscribe;
}, []);
}
A similar hook could be made for the rejected
case. This solution is great because it compartmentalizes the side effects into their own hooks. It also allows for multiple side effects to be performed for each of the thunk states. Another benefit is that this hook could be colocated alongside the thunk and the state cases in the same file so that all logic related to the thunk is near each other. The only downside is that it is a bit more verbose than the previous solutions. It also requires a bit more knowledge of Redux to understand what is happening.
I should also mention that the Redux docs do not necessarily recommend this approach. While this is an admittedly valid technique, Redux emphasizes behavior based on state.
My opinion is that this is a great solution for side effects that are not directly related to state.
Tanstack Query
The last solution I want to mention is completely outside of the Redux ecosystem and is Tanstack Query. Tanstack has a different philosophy to server/client state management which I am warming up to more and more.
However, I am not going to go into the details of Tanstack Query here. I will just mention that it is a great solution for state management and handles side effects very well. It is also very easy to use and understand. I highly recommend checking it out.
Conclusion
There are many ways to handle side effects from your thunks. The best solution will depend on your use case. I hope this blog has given you some ideas on how to handle side effects in your own applications. If you have any questions or comments, please feel free to reach out to me on LinkedIn. Thanks for reading!