Async Dispatch Chaining with Redux-Thunk
Jscrambler
Posted on July 3, 2020
Asynchrony in React-Redux is often done via a thunk. This thunk function is middleware that unlocks async operations by deferring execution. In this take, we’ll dive into what happens when there is more than a single async request. Async code is unpredictable because completion is not known ahead of time and multiple requests complicate things.
The use case is not unheard of - Ajax calls often fire at initial page load. The app then needs to know when all calls finish to allow user interaction. Firing multiple requests from different parts of the Redux store and knowing when it’s ready is hard.
The complete sample code is available on GitHub.
Begin with npm init
and add this to the package.json
file:
"scripts": {
"start": "node index.js",
"server": "json-server --watch db.json"
}
Then, put in place all dependencies:
npm i redux redux-thunk axios json-server --save
For the json-server
create a db.json
file and create these resources:
{
"posts": [{"id": 1, "title": "dispatch chaining"}],
"profile": {"name": "C R"}
}
This completes the back-end API. Now, imagine a React/Redux app that has profile and post info in the store. We’ll create actions to mutate data, a flag to know when it’s finished, and a reducer.
The typical code might look like:
const redux = require('redux');
const thunk = require('redux-thunk').default;
const UPDATE_POSTS = 'UPDATE_POSTS';
const UPDATE_PROFILE = 'UPDATE_PROFILE';
const UPDATE_DONE = 'UPDATE_DONE';
const updatePosts = (posts) => ({type: UPDATE_POSTS, payload: posts});
const updateProfile = (profile) => ({type: UPDATE_PROFILE, payload: profile});
const updateDone = () => ({type: UPDATE_DONE});
const reducer = (state = {}, action) => {
switch (action.type) {
case UPDATE_POSTS:
return {...state, posts: action.payload};
case UPDATE_PROFILE:
return {...state, profile: action.payload};
case UPDATE_DONE:
return {...state, isDone: true};
default:
return state;
}
};
const store = redux.createStore(reducer, {}, redux.applyMiddleware(thunk));
const unsubscribe = store.subscribe(async () => console.log(store.getState()));
Because this runs in node, CommonJS is useful for including modules via require
. The rest of this code should not surprise anyone who has written React/Redux code before. We’ve created a store with redux.createStore
and applied the thunk middleware. As mutations ripple through the store, store.subscribe
spits out what’s in the store to the console output.
The problem in multiple endpoints
One question that comes to mind is, what happens when we have more than one endpoint? We need two async operations and a way to know when both are done. Redux has a way to do this that appears simple on the surface but becomes deceptive.
A naive implementation might look like this:
const axios = require('axios');
const ROOT_URL = 'http://localhost:3000';
const loadPosts = () => async (dispatch) => {
const response = await axios.get(ROOT_URL + '/posts');
return dispatch(updatePosts(response.data));
};
const loadProfile = () => async (dispatch) => {
const response = await axios.get(ROOT_URL + '/profile');
return dispatch(updateProfile(response.data));
};
// Done is always set to true BEFORE async calls complete
const actions = redux.bindActionCreators({loadPosts, loadProfile, updateDone}, store.dispatch);
actions.loadPosts();
actions.loadProfile();
actions.updateDone(); // <-- executes first
The problem at hand lies in the fact Redux has no way of knowing when both async operations finish. The dispatched action updateDone
mutates state before post and profile data are in the store. This makes async/await unpredictable since we don’t know when a dispatch with response data executes. We can wait for a response via await
inside the thunk itself but lose all control outside of the function.
One potential solution is to lump all async code into a single thunk:
// Illustration only, AVOID this
const combinedThunk = () => async (dispatch) => {
const responsePosts = await axios.get(ROOT_URL + '/posts');
dispatch(updatePosts(responsePosts.data));
const responseProfile = await axios.get(ROOT_URL + '/profile');
dispatch(updateProfile(response.data));
dispatch(updateDone());
};
This is not ideal because of tight-coupling between concerns and less reusable code. Post and profile data may not live in the same place in the Redux store. In Redux, we can combine reducers and separate parts of the store into state objects. This combined thunk throws the code into chaos because we may need to duplicate code all over the store. Duplicate thunk code then becomes a high source of bugs or a maintenance nightmare for the next developer.
Async chaining
What if I told you this problem is already partially solved? The keen reader may have noticed a return
statement at the end of each thunk. Go ahead, take a second look:
return dispatch(updatePosts(response.data));
return dispatch(updateProfile(response.data));
This returns an actionable promise in Redux that can be chained. The beauty here is we can chain and reuse as many thunks while keeping store state predictable. These chains can be as long as necessary if it makes sense in the code.
With this in mind, it is possible to chain dispatched thunks:
const dispatchChaining = () => async (dispatch) => {
await Promise.all([
dispatch(loadPosts()), // <-- async dispatch chaining in action
dispatch(loadProfile())
]);
return dispatch(updateDone());
};
const actions = redux.bindActionCreators({dispatchChaining}, store.dispatch);
actions.dispatchChaining().then(() => unsubscribe()); // <-- thenable
Note that as long as there is a return these dispatches are thenable. The bonus here is we can fire async dispatches in parallel and wait for both to finish. Then, update isDone
knowing both calls are done without any unpredictable behavior. These reusable thunks can live in different parts of the store to maintain separation of concerns.
Below is the final output:
{ posts: [ { id: 1, title: 'dispatch chaining' } ] }
{
posts: [ { id: 1, title: 'dispatch chaining' } ],
profile: { name: 'C R' }
}
{
posts: [ { id: 1, title: 'dispatch chaining' } ],
profile: { name: 'C R' },
isDone: true
}
Conclusion
Asynchrony in JavaScript is hard and unpredictable.
Redux/Thunk has a nice way to quell this complexity via dispatch chaining. If a thunk returns an actionable promise with async/await, then chaining is possible. This makes async code in different parts of the Redux store easier to work with and more reusable.
Finally, don't forget to pay special attention if you're developing commercial JavaScript apps that contain sensitive logic. You can protect them against code theft, tampering, and reverse engineering by starting your free Jscrambler trial - and don't miss our guide for protecting React apps.
Originally published on the Jscrambler Blog by Camilo Reyes.
Posted on July 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.