9 Comments

A Pattern for Redux Thunk Async Actions

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.