krawaller


Walking through a Redux-TypeScript scaffold, set up for maximal in-editor help from minimal typing

A Redux-TypeScript setup

Tags: reduxtypescript

The premise

This post will explore a Redux TypeScript setup, using a tiny project as an example. My intention was to use this as a starting point whenever I start a new project, which so far has worked very well.

Apart from Redux we'll also use redux-actions and redux-thunk.

There's also a React part to this project, but I'll leave exploring that to an upcoming post. This one will revolve solely around TypeScript and Redux.

The scaffold state

Here's the AppState for my little example project:

type AppState = {
messaging: MessagingState;
/* ...imagine other domains here... */
};

Normally an app state would of course contain lots of top-level keys, but here I just have messaging, meant to deal with app-wide feedback to the user. While skinny, this will still be enough to get the TypeScript setup points across.

MessagingState looks like this:

type MessagingState = {
lastMessage: number;
messages: UIMessage[];
};

In other words, appState.messaging.messages is an array of UIMessage feedback items. They look like this...

type UIMessage = {
id: number;
text: string;
type: UIMessageType;
};

...and UIMessageType is simply an enum:

type UIMessageType = 'info' | 'success' | 'error';

The lastMessage part of MessagingState is used to create the ID for new messages, as well as fetching a reference to the last one. Normally I'd do that as a computed property with Reselect, but I didn't want to muddy the waters too much here.

Finally we make an initialState to seed our store with, containing a welcoming UI message:

const initialState: AppState = {
messaging: {
messages: [{type: 'info', text: 'Welcome!', id: 1}],
lastMessage: 1
}
};

Action creators

I wanted to use Redux-Actions, a pretty neat helper library to reduce boilerplate. Make sure to also get the typings (@types/redux-actions).

ReduxActions uses the Flux Standard Action format, meaning an action looks like this:

interface Action<Payload> {
type: string;
payload?: Payload;
error?: boolean;
}

In other words, eventual data passed with the action should go in the payload prop. The advantage of that is that we can simplify the API of action creators, as has been done in ReduxActions with the createAction helper.

In my simple scaffolding app there are only two possible actions; adding a message and removing a message.

Here's addUIMessage(text, type):

type addUIMessagePayload = {
text: string;
type: UIMessageType;
};
const addUIMessage = createAction<addUIMessagePayload, string, UIMessageType>(
ADD_UI_MESSAGE, (text, type) => ({text, type})
);

The ADD_UI_MESSAGE variable is merely a constant with the string, presumably imported from a constants file.

The signature of createAction looks like this:

createAction<TPayload, TArg1, TArg2, ...>(
STRINGCONSTANT, arg1, arg2, ...
) =>
TPayload

Note how the creator just needs to return the payload, not the full action object.

We dismissUIMessage(id) like this:

type dismissUIMessagePayload = number;
const dismissUIMessage = createAction<dismissUIMessagePayload, number>(
DISMISS_UI_MESSAGE, id => id
);

Here the payload is just the id of the message to dismiss.

The createAction helper also does another clever thing - it sets up the .toString method on the creator to return the action string type. In other words:

dismissUIMessage.toString() // DISMISS_UI_MESSAGE

We'll utilise this fact later on.

Thunk actions

What about creating thunks to work with the ReduxThunk middleware? Say we want to add a third action creator, addTempUIMessage(text, type), which adds a message that dismisses itself after a few seconds. Implementing it is easy enough, but how do we get type support?

Here's what it looks like with the official ReduxThunk typings (bundled with the library so no @types required):

import { ThunkAction } from 'redux-thunk';

const addTempUIMessage =
(text: string, type: UIMessageType): ThunkAction<null, IState, null> =>
(dispatch, getState) => {
dispatch(addUIMessage(text, type));
let id = getState().messaging.lastMessage;
setTimeout(() => dispatch(dismissUIMessage(id)), 2000);
};

Under the hood ThunkAction uses the official Redux typings, which also comes bundled.

The ThunkAction gets <ReturnVal, AppState, ExtraAPI> passed in. Some like to have ReturnVal be a promise, and ExtraAPI deals with the new thunk.withExtraArgument(api) feature added last year.

The main win from using ThunkAction is that it will correctly type dispatch, getState and the return value of getState:

I had good help of the discussion in this redux-thunk issue when wrapping my brain around this.

Since it is likely my thunk creators will all look the same, I set up an app-specific helper type...

type Thunk = ThunkAction<void, AppState, void>;

...which makes the code prettier still:

const addTempUIMessage = 
(text: string, type: UIMessageType): Thunk =>
// ...rest same as before

Reducer

We build our top-level reducer by combining subreducers for each domain as usual:

import { combineReducers } from 'redux';
const reducer = combineReducers<AppState>({
messaging: messagingReducer,
// ...imagine lots more reducers here...
});

Note how the Redux typings let us pass our AppState type to combineReducers.

So how are the individual reducers constructed? In bare-bones Redux we simply make a pure function with a big switch statement. ReduxActions however has a handleActions helper that makes for less cruft. Here's how we can use that to make a reducer dealing with the two message-related actions:

const messagingReducer = handleActions<MessagingState>(
{
[addUIMessage.toString()]: (state, action: Action<addUIMessagePayload>): MessagingState => ({
nextId: state.nextId + 1,
messages: [{...action.payload, id: state.nextId}].concat(state.messages)
}),
[dismissUIMessage.toString()]: (state, action: Action<dismissUIMessagePayload>): MessagingState => ({
...state,
messages: state.messages.filter(m => m.id !== action.payload)
})
},
initialState.messaging
);

Note how we use the .toString of the action creators, saving us from having to import the constants.

And through the Action<actionCreatorPayload> typing we get the correct type for action.payload inside the handler:

This is kind of neat, but there are still two drawbacks here:

  • The typings don't cascade, we have to add MessagingState at the top and at every individual handler
  • We have to reference the action creators in both the key and the value, leaving room for mismatches.

The first point is discussed in this ReduxActions PR, where Leon Yu explains that the dictionary form prevents type cascade. He then suggests a builder-like solution, which also happens to solve the second point!

Here's how my implementation of his idea is used:

const messagingReducer = buildReducer<MessagingState>()
.handle(addUIMessage, (state, action) => ({
lastMessage: state.lastMessage + 1,
messages: [{...action.payload, id: state.lastMessage + 1}].concat(state.messages)
}))
.handle(dismissUIMessage, (state, action) => ({
...state,
messages: state.messages.filter(m => m.id !== action.payload)
}))
.done();

Far fewer typings (we don't even need to import the action payload types!), no room to connect the wrong handler with the wrong type, and still full support for state, payload and return type:

Granted, having to call .done() at the end might feel a bit boilerplaty, but I still much prefer this helper to the handleActions one from ReduxActions shown earlier.

Here's the source code for the buildReducer helper:

import { Reducer, Action } from 'redux-actions';
import { ActionCreator } from 'redux';

type builderObject<TState> = {
handle: <TPayload>(
creator: ActionCreator<Action<TPayload>>,
reducer: Reducer<TState, TPayload>
) =>
builderObject<TState>,
done: () => Reducer<TState, Action<any>>
};

function buildReducer<TState>(): builderObject<TState> {
let map: { [action: string]: Reducer<TState, any>; } = {};
return {
handle(creator, reducer) {
const type = creator.toString();
if (map[type]) {
throw new Error (`Already handling an action with type ${type}`);
}
map[type] = reducer;
return this;
},
done() {
const mapClone = Object.assign({}, map);
return (state: TState = {} as any, action: Action<any>) => {
let handler = mapClone[action.type];
return handler ? handler(state, action) : state;
};
}
};
}

The store

If we just create the store directly with createStore, the type will be inferred from the reducer (even if we didn't have initial state):

When using an enhancer such as created by applyMiddleware, we must type the variable ourselves:

const store: Store<AppState> = applyMiddleware(thunk, logger)(createStore)(reducer, initialState);

But since that's only ever done once, that isn't really an issue.

As for the middlewares themselves, Redux has a MiddlewareAPI<S> typing. So with a tiny app-specific helper type...

type API = MiddlewareAPI<AppState>;

...we can type the middlewares:

import {  }
const logger = (api: API) => next => action => {
console.log('dispatching', action.type, action);
let result = next(action);
console.log('next state', api.getState());
return result;
};

Wrapping up

I'm pretty happy with this setup - not much typings necessary, but I still get full TypeScript support throughout.

There are some itches I'd like to scratch further. For example, having to feed the action creator argument types up top to createAction is a bit iffy:

const addUIMessage = createAction<addUIMessagePayload, string, UIMessageType>(
ADD_UI_MESSAGE, (text, type) => ({text, type})
);

I would much prefer to do this:

const addUIMessage = createAction<addUIMessagePayload>(
ADD_UI_MESSAGE, (text: string, type: UIMessageType) => ({text, type})
);

This should be doable, I need to explore further.

Also I'm looking at exposing the actions on the store instance instead of having a dispatch function and a separate action object. Why let the user ever dispatch anything except what is born from an action creator?

Finally the Middleware typings are lacking. I'd like to do something like this up top:

const logger: Middleware<AppState> = // ...

...and just have everything below it, including next and action, be correctly inferred. But as middlewares are few and one-off, setting that up isn't really a priority.

Anyhow - I hope this setup is of use to you too, and if you have any feedback, please do comment or reach out!

And as initially stated, I'll detail the React parts of my setup in an upcoming post.