Redux is a powerful tool for structuring front-end logic, and Redux Thunk extends its capabilities to support asynchronous actions in a simple way. However, without a clear, consistent, and reusable pattern to follow, it’s easy for a team to write Thunk actions in different ways and add mental strain to understanding the codebase.
Flux Standard Actions and Redux Actions
Flux Standard Action and its corresponding implementation, Redux Actions, are great inspirations when looking for typical ways to structure Flux/Redux actions.
While I love the idea behind Flux Standard Actions, their handling of asynchronous behavior feels like an afterthought. Their point about action types like LOAD_SUCCESS
mixing concerns is excellent, but they resolve it by adding a Boolean error
flag to indicate success or failure.
This discards all steps of the “global” action sequence
except the end. When the UI needs to show a button, display a spinner, disable the button once the request has started, and still handle success or failure, this flag is insufficient.
Redux Thunk Async Action
Instead of just an error
flag, I use a status
enum. I also implemented generic creators and handlers of these async actions to reduce boilerplate when creating new actions. Although the examples are in TypeScript, the pattern is language-agnostic.
First, I defined the possible statuses.
// app/actions/asyncAction.ts
export enum AsyncActionStatus {
UNSTARTED = 'UNSTARTED',
STARTED = 'STARTED',
SUCCEEDED = 'SUCCEEDED',
FAILED = 'FAILED',
}
Then, I defined types for the various statuses an action could set.
// app/actions/asyncAction.ts
interface StartedAsyncAction<T> {
type: T;
status: AsyncActionStatus.STARTED;
}
interface SucceededAsyncAction<T, P = any> {
type: T;
status: AsyncActionStatus.SUCCEEDED;
payload: P;
}
interface FailedAsyncAction<T> {
type: T;
status: AsyncActionStatus.FAILED;
payload: Error;
}
export type AsyncAction<T, P = any> = StartedAsyncAction<T> | SucceededAsyncAction<T, P> | FailedAsyncAction<T>;
With the types defined, I made a couple of helper action creators for the statuses.
// app/actions/asyncAction.ts
function startedAsyncAction<T>(type: T): StartedAsyncAction<T> {
return {
type,
status: AsyncActionStatus.STARTED,
};
}
function succeededAsyncAction<T, P>(type: T, payload: P): SucceededAsyncAction<T, P> {
return {
type,
status: AsyncActionStatus.SUCCEEDED,
payload,
};
}
function failedAsyncAction<T>(type: T, error: Error): FailedAsyncAction<T> {
return {
type,
status: AsyncActionStatus.FAILED,
payload: error,
};
}
Finally, I wrote the async Thunk action creator.
// app/actions/asyncAction.ts
export function async<T, P>(type: T, action: (...args: any[]) => Promise<P>, ...args: any[]) {
return async (dispatch: any) => {
dispatch(startedAsyncAction(type));
try {
const payload = await action(...args);
dispatch(succeededAsyncAction(type, payload));
} catch (error) {
dispatch(failedAsyncAction(type, error));
}
};
}
The async Thunk action created by this function always dispatches a StartedAsyncAction
, followed by either a SucceededAsyncAction
or FailedAsyncAction
.
The action
argument is not a Redux action; it is the actual asynchronous function that the Redux action represents. Therefore, action
is an accurate, albeit confusing, parameter name.
With this abstraction of async actions, I was able to abstract out an async action status reducer, as well.
// app/reducers/asyncActionStatusReducer.ts
import { AsyncAction, AsyncActionStatus } from '../actions/asyncAction';
export type ReduxState = AsyncActionStatus;
export const initialState = AsyncActionStatus.UNSTARTED;
export function reduceAsyncActionStatusOf<T extends string>(type: T) {
return (state: ReduxState = initialState, action: AsyncAction<T>): ReduxState => {
if (action.type === type) {
return action.status;
}
return state;
};
}
This function is effectively an “async action status reducer creator,” rather than being a reducer itself. It returns a reduce function that handles specific action types such as LOAD
.
Payload is also ignored here. Reducers returned by this function are intended only to handle the statuses, while payloads are handled in the normal manner. At this point, the global action sequence of a particular action type is decoupled from its payload.
Loading Screen Example
Let’s implement a loading screen as an example of how to use the previous code.
First, we make a typed async action that calls loadingService.load
, a service method that returns LoadingData
.
// app/actions/loadAction.ts
import { LoadingData, loadingService } from '../services/loadingService';
import { async, AsyncAction } from './asyncAction';
export const LOAD = 'LOAD';
export type LoadAction = AsyncAction<typeof LOAD, LoadingData>;
export function loadAction() {
return async(LOAD, loadingService.load);
}
Now we can dispatch this action from the loading screen container and expose state.loadStatus
to our loading screen component, allowing it to perform some action when the load succeeds–or handle errors if load fails.
// app/containers/LoadingScreen.ts
import { connect } from 'react-redux';
import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { loadAction } from '../actions/loadAction';
import LoadingScreen, { Props } from '../components/LoadingScreen';
import { ReduxState } from '../reducers';
type StateProps = Pick<Props, 'loadStatus'>;
type DispatchProps = Pick<Props, 'load'>;
function mapStateToProps(state: ReduxState): StateProps {
return {
loadStatus: state.loadStatus,
};
}
const mapDispatchToProps = {
load: loadAction,
}
export default connect(mapStateToProps, mapDispatchToProps)(LoadingScreen);
We then funnel the load status through our root reducer, using reduceAsyncActionStatusOf
to create a typed async action status reducer. A tutorialCompleteReducer
has been included to demonstrate handling the payload.
// app/reducer/tutorialCompleteReducer.ts
import { AsyncActionStatus } from '../actions/asyncAction';
import { LOAD, LoadAction } from '../actions/loadAction';
export type ReduxState = boolean;
export const initialState = false;
type TutorialCompleteAction = LoadAction;
export function reduce(state: ReduxState = initialState, action: TutorialCompleteAction): ReduxState {
if (action.type === LOAD && action.status === AsyncActionStatus.SUCCEEDED) {
return action.payload.tutorialComplete;
}
return state;
}
// app/reducers/index.ts
import { combineReducers } from 'redux';
import {
initialState as initialAsyncActionStatusState,
reduceAsyncActionStatusOf,
ReduxState as AsyncActionStatusReduxState,
} from './asyncActionStatusReducer';
import {
initialState as initialTutorialCompleteState,
reduce as reduceTutorialComplete,
ReduxState as TutorialCompleteReduxState,
} from './tutorialCompleteReducer'
import { LOAD } from '../actions/loadAction';
export interface ReduxState {
loadStatus: AsyncActionStatusReduxState;
tutorialComplete: TutorialCompleteReduxState;
}
export const initialState: ReduxState = {
loadStatus: initialAsyncActionStatusState,
tutorialComplete: initialTutorialCompleteState,
};
export const reducer = combineReducers({
loadStatus: reduceAsyncActionStatusOf(LOAD),
tutorialComplete: reduceTutorialComplete,
});
That is all we need to access the status of asynchronous actions!
This pattern really shines when you need to add new async actions. Simply make a typed async action, add new async action status reducers to the root reducer via reduceAsyncActionStatusOf
, and you can dispatch the action and listen to its status in your Redux containers.
Updated 2018-11-19: Previously, mapDispatchToProps
was defined as
function mapDispatchToProps(dispatch: ThunkDispatch): DispatchProps {
return {
load: () => {
dispatch(loadAction());
},
};
}
It has been updated to use “object shorthand” form per Mark Erikson’s suggestion.
Updated 2019-05-06: The tutorialCompleteReducer
part of the example was added to clarify usage per Ron’s question.
FYI, the `mapDispatch` function can be simplified using the “object shorthand” form:
`const mapDispatch = {load : loadAction}`.
See our new docs page on dispatching actions via `connect`, at https://react-redux.js.org/docs/using-react-redux/connect-dispatching-actions-with-mapDispatchToProps .
Thanks for the heads up! Post has been updated.
Thanks for this awesome post! Surprisingly, the best part for me here was seeing enums in use. I’m somewhat new to TypeScript, so I hadn’t seen those before and it’s exactly what I’ve needed!
I discovered your post because I was doing something very similar, when I ran into an issue with how to handle errors while remaining FSA compliant for my “thunkActionCreator”. I was also working on creating a single “makeActionCreator” function for all actionCreators too.
I have a couple questions:
1) Why did you leave the “getState” parameter off of the function returned by your thunk action creator?
2) Couldn’t these transitions of “started”, “succeeded”, “and “failed” be attached to the meta property mentioned in the FSA docs? I haven’t seen example of what is put on the meta tag.
Glad you found the post helpful.
1) The thunk action creator is generic and doesn’t use the state, so I just left ‘getState’ out for brevity. Additionally, interacting with the state within a thunk is a code smell to me.
For example, see the Redux Thunk’s conditional dispatch example. I prefer my actions to be “verbs without conditions” and for the conditions to be owned by the caller. Therefore, I would pass ‘counter’ and an ‘increment’ function from the redux container to the component and do the following in the component.
2) The statuses could be handled with the ‘meta’ property, but nothing about that property suggests that a developer should look there for the status. My statuses are more akin to the ‘error’ property.
In general, I feel the ‘meta’ property should be used for weakly established patterns. For example, you may want to track timestamp of dispatches, so you start to include it in the ‘meta’. Later, you realize you want all actions to have timestamp, so it might make more sense to pull it out of the ‘meta’ into its own property at that point.
Awesome feedback. Thanks for replying so quickly!
I really like the idea of having the conditions owned by the caller. I’ll likely adopt that too.
This is great! One thing I still don’t quite understand though is this comment?
“Payload is also ignored here. Reducers returned by this function are intended only to handle the statuses, while payloads are handled in the normal manner. At this point, the global action sequence of a particular action type is decoupled from its payload.”
Would another reducer have to be set up to access the payload?
You would, indeed, have another reducer set up to access the payload. I’ve edited the post to include an example.
The first reason for doing so is that the Redux state may be updated from other actions. In the modified example,
tutorialComplete
may have been set by a user action and also needs its data loaded when the user returns to the page.This leads to the second reason: the structure of the payload may be different from that of the Redux state. Having each reducer derive its state from the payload reduces leaks through the abstraction layer.
Great thank you!
Hello! Thanks for this article!
Do you have a complete example/repo of these pieces put together? I cant seem to get things to work when connecting to components using the new hooks useSelector and useDispatch?
This great article is very inspiring!
Do you think it should a good idea to use a custom redux middleware instead of the async Thunk action creator you propose? This middleware could take its decision based on the meta property of the FSA (e.g. meta: { async: true}).
Looking forward reading your feedback.
Thanks
After using this for a few months I don’t really recommend it. This biggest downside is it requires additional logic to be placed in the reducer because your action creators can no longer parse the async response.
When we request data from an endpoint, it is common to parse the data (eg. normalize). With this pattern, you would need to normalize the data in the reducer. This isn’t the reducers responsibility. Reducers should receive plain objects.
As other comments recommend, simply use middleware if you want’ to manage async action status’.