INTRO
The Redux Thunks offer a good way of separating logic from UI components. Within a thunk, you can: access the entire state, dispatch actions and thunks, generate side effects (contrary to reducers)... Redux Toolkit contains the redux-thunk middleware and adds it during standard store configuration, nice.
While dispatching actions and accessing state in thunks is easy (every thunk receives dispatch and getState functions as arguments), it's also easy to misuse the API and operate on outdated state, especially when async operations are involved.
In this post I'll show you two possible mistakes, one about referencing stale state object and another related to order of async operations.
CODE SAMPLE
Full code is available in this GitHub repository (vite 5.2.0, react 18.2.0, react-redux 9.1.0, @reduxjs/toolkit 2.2.3, typescript 5.2.2): https://github.com/morzel85/blog-post-redux-thunk-state-access
Here's the Redux Toolkit slice that I'll use to discuss the issues (with the thunks skipped as these will be shown later).
import type { PayloadAction } from '@reduxjs/toolkit';
import type { AppThunk } from './store';
import { createSlice } from '@reduxjs/toolkit';
export type ExampleState = {
count: number;
text: string;
};
const initialState: ExampleState = {
count: 0,
text: ''
};
// Thunks skipped...
export const exampleSlice = createSlice({
name: 'example',
initialState,
reducers: {
resetCount: state => {
console.log('Reducer resetting count');
state.count = 0;
},
resetText: state => {
console.log('Reducer resetting text');
state.text = '';
},
increment: state => {
console.log(`Reducer incrementing count ${state.count}`);
state.count += 1;
},
appendText: (state, action: PayloadAction<string>) => {
console.log(`Reducer appending text ${action.payload}`);
state.text += action.payload;
}
}
});
export const { resetCount, resetText, increment, appendText } = exampleSlice.actions;
export default exampleSlice.reducer;
As you can see, the slice is really simple, it manages one number and one string.
STALE STATE
The second argument passed to a thunk is getState function. It gives you access to the entire state which you can use directly or through selectors...
In case you need to access the state multiple times, it's tempting to grab the state with single getState call, but don't do it. If you dispatch an action in a thunk, Redux will run the reducers and ensure that updated state will be available further in the thunk code, but to get it you must invoke the getState function again.
Take a look at this thunk:
export const thunkWithStateAccess = (): AppThunk => (dispatch, getState) => {
console.log('thunkWithStateAccess START');
dispatch(resetCount());
// This value will be stale!
const state = getState();
console.log(`state.example === getState().example: ${state.example === getState().example}`); // true
dispatch(increment());
console.log(`count in thunk with state: ${state.example.count}`); // 0
console.log(`count in thunk getState(): ${getState().example.count}`); // 1
console.log(`state.example === getState().example: ${state.example === getState().example}`); // false
dispatch(increment());
console.log(`count in thunk with state: ${state.example.count}`); // 0
console.log(`count in thunk getState(): ${getState().example.count}`); // 2
console.log('thunkWithStateAccess END');
};
This is the console output:
thunkWithStateAccess START
Reducer resetting count
state.example === getState().example: true
Reducer incrementing count 0
count in thunk with state: 0
count in thunk getState(): 1
state.example === getState().example: false
Reducer incrementing count 1
count in thunk with state: 0
count in thunk getState(): 2
thunkWithStateAccess END
Notice that initially the state.example and getState().example reference the same object. This changes after increment action is dispatched. Reducer modifies the state and the latest value is no longer available under the state const (it doesn't matter that it's declared as const, same would happen with let).
ASYNC THUNKS
Now let's see what the state access situation looks like when async thunks are involved (here I'm talking about asynchronous thunks in general sense, not about the createAsyncThunk from RTK):
export const thunkThatAppendsB = (): AppThunk => dispatch => {
dispatch(appendText('B'));
};
export const asyncThunkThatAppendsCWithDelay = (): AppThunk => async dispatch => {
const value = await new Promise<string>(resolve => setTimeout(resolve, 1000, 'C'));
dispatch(appendText(value));
};
export const asyncThunkThatAppendsDWithoutDelay = (): AppThunk => async dispatch => {
const value = await Promise.resolve('D');
dispatch(appendText(value));
};
export const thunkWithoutAwait = (): AppThunk => async dispatch => {
console.log('thunkWithoutAwait START');
dispatch(resetText());
dispatch(appendText('A'));
dispatch(thunkThatAppendsB());
dispatch(asyncThunkThatAppendsCWithDelay()); // No await: E and D will be first!
dispatch(asyncThunkThatAppendsDWithoutDelay()); // No await: E will be first!
dispatch(appendText('E'));
console.log('thunkWithoutAwait END'); // This will happen before D and C are appended!
};
This is the console output of thunkWithoutAwait:
thunkWithoutAwait START
Reducer resetting text
Reducer appending text A
Reducer appending text B
Reducer appending text E
thunkWithoutAwait END
Reducer appending text D
Reducer appending text C
The text state ends up as: "ABEDC", what a mess!
Here the letter A is appended simply by dispatching appendText action. The letter B is added by dispatching synchronous thunk, that internally dispatches appendText action. So far so good, state contains the "AB" text.
Problems start when asynchronous thunks are dispatched without await. Notice that appending E happens right after B and that "thunkWithoutAwait END" is logged before the async thunks can modify the state. This happens even for the async thunk which resolves without delay.
The important thing is that both the asyncThunkThatAppendsCWithDelay and the asyncThunkThatAppendsDWithoutDelay are awaiting promise resolution, and this is enough for the browser to let the invoking function (thunkWithoutAwait) continue. It has nothing to do with Redux, this is how JavaScript promises work (even if you were to switch from await to classic then the effect would be the same).
Fortunately the solution is simple: don't forget the await if you expect serialized behavior of your thunks:
export const thunkWithAwait = (): AppThunk => async dispatch => {
console.log('thunkWithAwait START');
dispatch(resetText());
dispatch(appendText('A'));
dispatch(thunkThatAppendsB());
await dispatch(asyncThunkThatAppendsCWithDelay());
await dispatch(asyncThunkThatAppendsDWithoutDelay());
dispatch(appendText('E'));
console.log('thunkWithAwait END');
};
Now the console output is:
thunkWithAwait START
Reducer resetting text
Reducer appending text A
Reducer appending text B
Reducer appending text C
Reducer appending text D
Reducer appending text E
thunkWithAwait END
and the text state ends up as sane: "ABCDE".
That's it, just don't get too crazy and remember that thunks are not meant to dispatch tens of actions or handle persistent connections...