TypeScript and Async Redux Actions
- 9/26/2016
- ·
- #typescript
- #redux
- #howto
This is the third of several short posts about building Redux applications with TypeScript. Part one introduced a simple counter application, which we then dressed up with a simple React UI. If you’re in a hurry, skip on over to the finished project on Github; everyone else, read on!
We’ve previously put TypeScript together with Redux, then added a toy React application on top of it. What we haven’t considered yet is the world outside. Even toys, however, may need to store their state for safekeeping and recall at a later date. If you’re just looking to see this in action, check out the example project over on github. For the play-by-play, though, read on!
Actions
Our Redux application won’t be making direct calls to the API. Instead, we’ll dispatch actions to indicate that certain conditions have happened:
- an asynchronous API call has been requested (
X_REQUEST
) - an asynchronous API call has succeeded (
X_SUCCESS
) - an asynchronous API call has failed (
X_ERROR
)
So far, we’re straight straight out of the excellent redux documentation. The only tricky question is, “how should we represent these actions in TypeScript?” And with TypeScript 2.0, we’re edging closer to a satisfying answer: “as a discriminate union.”
Here’s the idea. When the same string literal is present across multiple types, TypeScript can use it to narrow down the shape of the type:
type Foo = { type: 'FOO', str: string }
type Bar = { type: 'BAR': num: number }
type Action = Foo | Bar
function handle (a: Action): string {
switch (a.type) {
case 'FOO':
return a.str + ' is a string!'
case 'BAR':
return a.num.toString() + 'is a number!'
}
}
The catch–and it’s a big one–is that string literals mean quite a bit of boilerplate upfront. We need to explicitly declare the set of actions (one string literal), we won’t be able to set up dynamic (generic) action creators, and we need to rewrite the string any time it turns up in a loop or conditional expression.
There will probably be ways around this in the future. There may be ways around it now (and if you have one, I would love to hear from you). But for the time being we’re going to be doling out some redundant code.
In return for all of that cutting and pasting (incidentally, something that IDEs are really good at for–we’ll gain reasonable static guarantees throughout the async action flow.
Anyway, to the actions themselves. Now that we’re using discriminant unions,
all actions will be attached to a single type. Call it Action
. Since we’ve
already touched on the three events generally involved with asynchronous
actions, we can extend the union describing our actions so far with
their implementations.
export type Action =
// UI actions
{ type: 'INCREMENT_COUNTER',
delta: number }
| { type: 'RESET_COUNTER' }
// Async actions...
| ({ type: 'SAVE_COUNT_REQUEST',
request: { value: number } })
| ({ type: 'SAVE_COUNT_SUCCESS',
request: { value: number },
response: {})
| ({ type: 'SAVE_COUNT_ERROR',
request: { value: number },
error: Error })
There’s a general structure here that we’ll keep for all “groups” of actions describing asynchronous events.
- every action includes a
request
field describing the original request - success actions include a
response
field to hold the asynchronous result - error actions include an
error
field to hold any errors that arise
It may make sense to structure these another way, depending on preference and application, but they should be consistent. As we’ll see in a moment, homogeneity here will simplify matters in other parts of the application.
Before we get there, note that we’ve already built up some of the boilerplate I promised. To keep things tidy as the list of actions grows, we can alias commonly-used types and use intersections to compose them.
type Q<T> = { request: T }
type S<T> = { response: T }
type E = { error: Error }
Here, Q<T>
expresses actions containing requests, S<T>
expresses those with responses, and E
those that contain errors.
We can then add a few aliases for reused types of requests and responses.
type QEmpty = Q<null>
type QValue = Q<{ value: number }>
Here are the SAVE_COUNT_X
actions rewritten using the
tighter-if-marginally-more-opaque aliases. And since we’ll need them in a moment
anyway, here are some additional actions (LOAD_COUNT_X
) for comparison’s sake.
export type Action =
// ...
| ({ type: 'SAVE_COUNT_REQUEST' } & QValue)
| ({ type: 'SAVE_COUNT_SUCCESS' } & QValue & S<{}>)
| ({ type: 'SAVE_COUNT_ERROR' } & QValue & E)
| ({ type: 'LOAD_COUNT_REQUEST' } & QEmpty)
| ({ type: 'LOAD_COUNT_SUCCESS' } & QEmpty & S<{ value: number }>)
| ({ type: 'LOAD_COUNT_ERROR' } & QEmpty & E)
Async Action Creators
There’s an obvious relationship between the SAVE_COUNT_X
actions, but we
haven’t yet made it explicit to the type system. Let’s fix that.
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
}
export 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 const loadCount: ApiActionGroup<null, { value: number }> = {
request: (request) =>
({ type: 'LOAD_COUNT_REQUEST', request: null }),
success: (response, request) =>
({ type: 'LOAD_COUNT_SUCCESS', request: null, response }),
error: (error, request) =>
({ type: 'LOAD_COUNT_ERROR', request: null, error }),
}
Now, when we need to refer to reference async actions, we can find them
conveniently grouped within the saveCount
and loadCount
action groups.
We’re unfortunately packing on even more boilerplate. The action creators must
return defined Action
s using explicit string-literal types. We could
conceivably work around this using dynamic action creators and a few
generic-type hijinks, but as the code here is relatively simple and cut-and-paste
operations are relatively cheap it may not be worth the trouble.
API
Our API will normally exist outside the redux application, or at least be the
subject of a parallel design conversation. The persistence API for the counter
has no surprises, however, so we’ve put off building it until now. We can use
localStorage
to achieve per-session persistence but still expose the same sort
of promise-based API we might use to fetch
data from a REST
API or GraphQL server.
// ./api.ts
export const api = {
// Save counter state
save: (counter: { value: number }): Promise<null> => {
localStorage.setItem('__counterValue', counter.value.toString())
return Promise.resolve(null)
},
// Load counter state
load: (): Promise<{ value: number }> => {
const value = parseInt(localStorage.getItem('__counterValue'), 10)
return Promise.resolve({ value })
},
}
We’re leaning pretty heavily on the counter’s “toy” status to excuse the lack of validation, error handling, and formal request/response types. But, taking the focus back in the redux application, we now have something to call.
Middleware
Remember that the core application only dispatches actions, never interacting directly with the API? That’s a job for middleware. This is where we’ll finally see the awesomeness of those discriminant unions at work–and even make some API requests while we’re at it.
We’ll start with the code:
// ./middleware/index.ts
import * as redux from 'redux'
import { api } from '../api'
import {
Action,
saveCount,
} from '../actions'
export const apiMiddleware = ({ dispatch }: redux.MiddlewareAPI<any>) =>
(next: redux.Dispatch<Action>) =>
(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 switch
can be dressed up quite a bit; it may even go away entirely. We
could build up a map of action groups to api methods, for instance, and iterate
over them each time the middleware is called. But it helps to illustrate some of
the magic that all of our legwork has been building towards.
Before, if we wanted to extract an action’s payload, we would need
to switch
on its type and then assert its type:
// No longer needed!
type SaveCountRequestAction = {
type: 'SAVE_COUNT_REQUEST'
request: { value: number }
}
// ...
case 'SAVE_COUNT_REQUEST':
api.save({ value: (action as SaveCountRequestAction).request.value })
// ...
This required us to re-establish the association between the action’s type and various other fields: redundant, intuitively unnecessary, and prone to fat fingers.
But using the union type, we can now reference action.request
without a type
assertion! Because both SAVE_COUNT_REQUEST
and LOAD_COUNT_REQUEST
actions
do have .request
fields, and TypeScript is able to narrow the set of
matching types in each case
based on its type
, it also recognizes the shape
of the corresponding action. If we wanted to, we could even extract the { value }
attached to SAVE_COUNT_REQUEST
without complaint from the compiler:
case 'SAVE_COUNT_REQUEST':
const { value } = action.request
api.save({ value })
// ...
Conclusion
From there on out, things go back to normal. We map the actions’ dispatch
es
to components, add reducers to update state, and map changes back to the
components. The hum-drum is over in the example project.
But we’ve seen some pretty good stuff! We’ve set up asynchronous actions, and–in exchange for some boilerplate–gained reasonable static assurance that both the middleware and the core application have a handle on their shape and structure. The counter’s still just a toy, but the same strategies outlined here can be (in fact are being) used in much more sophisticated applications. Not a bad day’s work.
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.
Up next: We’re now ready to shore things up with Jest-powered unit tests. Read on!