TypeScript in a Redux Frontend

Christian Förg

Introduction

When we started our new product (Relay, a platform for service providers), we first decided to use TypeScript for the server-side code. Since this went out quite well for us, we also introduced TypeScript for the frontend very early. Now our user interface’s code is entirely written in TypeScript.

Somehow we were never completely comfortable with TypeScript in our frontend. It didn’t feel very type-safe although every single file was written in TypeScript. That’s because the core of the application’s data was missing types when it came to reducers. A classic redux reducer is a function that has two arguments, the current state and an action that was dispatched and can be handled now. But the action’s payload’s type was of type any, a type we should avoid when we want to use TypeScript correctly.

So we looked for a library to boost our redux code and finally have type-safe reducers. That’s where we learned about typesafe-actions, tried it out and added some custom functions that suit our needs.

And action …

In this section we will have a look how our code base changed using that neat library.

Before

Let’s have a look how we previously would have implemented the redux part of a signup form.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// actions.ts
export const constants = {
SET_FIRST_NAME: "SET_FIRST_NAME",
SET_LAST_NAME: "SET_LAST_NAME",
SET_EMAIL_ADDRESS: "SET_EMAIL_ADDRESS",
SET_PASSWORD: "SET_PASSWORD"
};

export const setFirstName = (payload: string) => ({
type: constants.SET_FIRST_NAME,
payload
});
export const setLastName = (payload: string) => ({
type: constants.SET_LAST_NAME,
payload
});
export const setEmailAddress = (payload: string) => ({
type: constants.SET_EMAIL_ADDRESS,
payload
});
export const setPassword = (payload: string) => ({
type: constants.SET_PASSWORD,
payload
});

As you can see we created the type constants and action creators like in usual good old JavaScript (plus type declarations). The reducers file in TypeScript also doesn’t differ much from one written in JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// reducers.ts
import { combineReducers } from "redux";
import { constants } from "./actions.ts";

export interface SignupFormState {
firstName: string;
lastName: string;
emailAddress: string;
password: string;
}

export const reducers = combineReducers<SignupFormState>({
firstName: (state = "", action) => {
switch (action.type) {
case constants.SET_FIRST_NAME:
return action.payload;
default:
return state;
}
},
lastName: (state = "", action) => {
switch (action.type) {
case constants.SET_LAST_NAME:
return action.payload;
default:
return state;
}
},
emailAddress: (state = "", action) => {
switch (action.type) {
case constants.SET_EMAIL_ADDRESS:
return action.payload;
default:
return state;
}
},
password: (state = "", action) => {
switch (action.type) {
case constants.SET_PASSWORD:
return action.payload;
default:
return state;
}
}
});

There is an interface SignupFormState declared for the redux state of our signup form. It is used in the angle brackets of redux’s combineReducers.

The argument state in each reducer is now correctly inferred from SignupFormState. Unfortunately, each second argument action is of type any. Of course we could set the type of the possible action(s) for each reducer individually, but this would require us to declare and export the interface of the action that would result from each action creator. This approach didn’t look very appealing to us, because it causes a lot of boilerplate code in order to have a nice TypeScript DX. Luckily, typesafe-actions is out there and provides a different way of writing actions and reducers.

After

1
2
3
4
5
6
7
8
9
// actions.ts
import { createStandardAction } from "typesafe-actions";

export const setFirstName = createStandardAction("set_first_name")<string>();
export const setLastName = createStandardAction("set_last_name")<string>();
export const setEmailAddress = createStandardAction("set_email_address")<
string
>();
export const setPassword = createStandardAction("set_password")<string>();

Now this is much more concise than before, right? This is because there is no need to additionally define constants for the types and export them for the reducers. Let’s see how the reducers deal with dispatched actions when we do not reuse the type constants.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// reducers.ts
import { combineReducers } from "redux";
import { createReducer } from "typesafe-thunk-actions";
import {
setFirstName,
setLastName,
setEmailAddress,
setPassword
} from "./actions.ts";

export interface LoginFormState {
firstName: string;
lastName: string;
emailAddress: string;
password: string;
}

export const reducers = combineReducers<LoginFormState>({
firstName: createReducer<string>("").handleAction(
setFirstName,
(state, action) => action.payload
),
lastName: createReducer<string>("").handleAction(
setFirstName,
(state, action) => action.payload
),
emailAddress: createReducer<string>("").handleAction(
setFirstName,
(state, action) => action.payload
),
password: createReducer<string>("").handleAction(
setFirstName,
(state, action) => action.payload
)
});

We still use combineReducers they way we used it before, but the code in the function call clearly changed. Reducers are no longer done by switch-case functions, instead createReducer does this job now. It takes one generic argument which will be used to determine the return type of the reducer and one parameter argument which will serve as the reducer’s initial state. Now, to take care of dispatched actions you can chain handleAction calls on the reducer. The first argument is an action creator and the second one is a reducer-like callback function that infers its parameter types from the action creator.

We didn’t import the createReducer function from typesafe-actions but from typesafe-thunk-actions. Both of them export such a function with very similar behaviors. The one from typesafe-actions needs a type definition that is a union of all possible actions (you can read about that in their documentation) for each reducer (or you have a root action type) and then add a handler for that action. But we wanted to reduce boilerplate code to have a nice DX.

That’s why we started a project called typesafe-thunk-actions that comes with its won createReducer function. A specification for the action types is not needed, because handleAction can infer the action’s payload. But there is a drawback in this implementation. It can only infer standard actions, i.e. actions with attributes type, payload? and meta?. If that doesn’t match your needs, you should consider going with the createReducer function from typesafe-actions.

But the module also contains a function called createAsyncThunkAction that deals with async action creators in a standardized way. Imagine we want to send our signup form to the backend and deal with the response in redux.

1
2
3
4
5
6
7
8
9
// actions.ts
import { createAsyncThunkAction } from "typesafe-thunk-actions";

export const submitSignupForm = createAsyncThunkAction<SignupSubmitResponse>(
"submit_signup",
(arg: void, dispatch, getState: () => State) => {
return api.post("/api/v1/signup", getState().signupForm);
}
);

submitSignupForm is itself a thunk action and therefore can be directly dispatched. Also it has properties request, successand failure which can be passed to the handleAction function of a reducer. The generic parameters take one argument which mirrors the async function’s return type. You don’t have to specify it when it can be inferred from the async function.
The async function takes up to 3 arguments. The first one is the one you will call the resulting action creator later. In this case we specified void. That means we can dispatch the function without an argument:

1
dispatch(submitSignupForm());

The dispatch and getState arguments are the same you might know from redux-thunk. In the example we use getState to pass the form data from our redux state to the server.

Conclusion

Not only the reducers are really typesafe now, we also managed to write less code at the same time. typesafe-actions and typesafe-thunk-actions come in really handy with their create*Action and createReducer functions. They enhance your DX by bringing type safety to redux apps. For detailed API descriptions please visit the GitHub repositories of typesafe-actions and typesafe-thunk-actions.

  • page 1 of 1