Integrating TypeScript and redux-thunk

This is the fifth entry in a short series about bolstering Redux applications with TypeScript. Part one introduced a simple counter application, which we then dressed up with a simple React UI and API interactions. If you’re in a hurry, skip on over to the finished project on Github; everyone else, read on!

When last we dealt with the outside world, a redux middleware was helping insulate our counter application from the messy details of API interaction. This has the advantage of letting us consider business logic independent of the transport layer; it was also convenient for illustration. But it was also rather verbose, as the middleware added an additional step of indirection on top of redux’s existing data flow.

We can do better, and a community-supported middleware–redux-thunk–can help. As its name suggests, redux-thunk allows us to dispatch actions defined as functions (the thunk). In our counter application, this will allow us to collapse the API interactions currently contained in our middleware into constituent action creators.

If you’re just looking to see the refactored application in action, check out the example project over on github. For everyone else, let’s get started.

The beginning

Our middleware set out to solve the challenge of separating our mostly-pure redux application from the vagaries of network I/O. API requests were initiated in response to specific actions (X_REQUEST), and their success or failure expressed in terms of additional actions dispatched to the application. In code, it looked like this:

export const apiMiddleware = ({ dispatch }: redux.MiddlewareAPI<any>) =>
  (next: redux.Dispatch<any>) =>
    (action: Action) => {
      switch (action.type) {
        case 'SAVE_COUNT_REQUEST':
          api.save(action.request)
            .then(() => dispatch(saveCount.success({}, action.request)))
            .catch((e) => dispatch(saveCount.error(e, action.request)))
          break

        case 'LOAD_COUNT_REQUEST':
          api.load()
            .then(({ value }) => dispatch(loadCount.success({ value }, action.request)))
            .catch((e) => dispatch(loadCount.error(e, action.request)))
          break
      }

      return next(action)
    }

This allowed us to define logic on top of our actions without imposing side-effects on either the redux store or individual react components.

But we could achieve a very similar result if our actions were wrapped in functions that had could dispatch further actions. In other words, we’d love to be able to define an action-creator, saveCount, that will wrap the entire API interaction:

type Q<T> = { request: T }
type S<T> = { response: T }
type E = { error: Error }

type QValue = Q<{ value: number }>

export type Action =
  ({ type: 'SAVE_COUNT_REQUEST' } & QValue)
| ({ type: 'SAVE_COUNT_SUCCESS' } & QValue & S<{}>)
| ({ type: 'SAVE_COUNT_ERROR'   } & QValue & E)
// ...

export type ApiActionGroup<_Q, _S> = {
  request: (q?: _Q)         => Action & Q<_Q>
  success: (s: _S, q?: _Q)  => Action & Q<_Q> & S<_S>
  error: (e: Error, q?: _Q) => Action & Q<_Q> & E
}

const _saveCount: ApiActionGroup<{ value: number }, {}> = {
  request: (request) =>
    ({ type: 'SAVE_COUNT_REQUEST', request }),
  success: (response, request) =>
    ({ type: 'SAVE_COUNT_SUCCESS', request, response }),
  error: (error, request) =>
    ({ type: 'SAVE_COUNT_ERROR',   request, error }),
}

export function saveCount(request: { value: number }) {
  return (dispatch: redux.Dispatch<Store.All>) => {
    dispatch(_saveCount.request(request))
    api.save(request)
      .then((response) => dispatch(_saveCount.success(response, request)))
      .catch((e: Error) => dispatch(_saveCount.error(e, request)))
  }
}

Inside connected components, we would dispatch this action just as before.

// ...
onSaveClick: (value: number) =>
  dispatch(saveCount({ value })),

Not only will redux-thunk let us do this, but it will make it almost trivial. We just need to install it.

First, grab it from NPM:

$ npm install --save redux-thunk

Next, import 'redux-thunk' and include it in the middleware stack of the main redux store:

import thunk from 'redux-thunk'

import {
  reducers,
  Store,
} from './reducers'

// ...

let store: redux.Store<Store.All> = redux.createStore(
  reducers,
  {} as Store.All,
  redux.applyMiddleware(thunk),
)

Could it be that easy? It is!

Fire up the counter, and voilà: we can now dispatch the saveCount action creator like any other action.

Refactoring

Breaking the middleware into its constituent (and easily-tested) parts is a win, but we’re not done yet. As written, saveCount depends on both a rather unwieldy type definition, and ApiActionGroup, in addition to the action creator itself. There’s not much to do about the definitions, but the creator at least can be generalized in terms of the corresponding ApiActionGroup and API request.

type apiFunc<Q, S> = (q: Q) => Promise<S>

function actionCreator<Q, S>(x: ApiActionGroup<Q, S>, go: apiFunc<Q, S>) {
  return (request: Q) => (dispatch: redux.Dispatch<Store.All>) => {
    dispatch(x.request(request))
    go(request)
      .then((response) => dispatch(x.success(response, request)))
      .catch((e: Error) => dispatch(x.error(e, request)))
  }
}

With actionCreator added to our action module, it’s trivial to prepare existing ApiActionGroups for export:

export const saveCount = actionCreator(_saveCount, api.save)

Conclusion

redux-thunk has tidied things up nicely, while the original middleware continues working as before. That means that we could phase actionCreator in gradually without affecting the overall behavior of the project: not a big concern for our toy counter, but a boon for integration with any larger project.

The completed counter project is available for reference on github, build, code, tests, and all. And I’m looking forward to your suggestions, experiences, and feedback over on twitter.

Let’s keep in touch

Reach out on Twitter or subscribe for (very) occasional updates.

Hey, I'm RJ: sometimes-writer and intermittent micropoet. Broadcasts live from the Rose City.