Integrating TypeScript and redux-thunk
- 1/21/2017
- ·
- #typescript
- #howto
- #redux
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 ApiActionGroup
s 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.