Typed Redux
How Flowtype can save your life
One of the most popular examples on using Flowtype with Redux is the F8 app, open-sourced by Facebook last year.
It deeply integrates with Flow, most notably:
- It replaces constants in favour of a single union type
Action
that describes the type and payload of every action that can be dispatched, - Later in reducers, typed
Action
helps developers to reason about the purpose of action and what could be done with its payload, - Each branch of the state has a corresponding type that helps with transforming it.
I have been using similar approach in most of my apps recently. However, I quickly noticed that some of these do not scale well as the app grows, especially when there are several developers working on the app. The setup I want to share with you today is a result of the tweaks and improvements that we added over time to the original F8 App
approach.
I hope you’ll find it useful!
#1 — Keep types in a single file
Our codebases tend to grow quickly as we add new reducers, actions and selectors. Having types spread across multiple files makes it harder to see them all at a glance and import to your components. It also creates a lot of cross references between files when you need to build a type that composes others inside.
In most of our apps, we tend to keep all the types in a single file. Usually we call it types.js
and place in the root folder. Types that it contains are just named exports, like below:
/**
* @flow
*/export type Dispatch = Function;
that can be later imported in a very easy way:
import type { Dispatch } from '../types';
#2 — Describe your state
Although we promote keeping reducers small and atomic, they still contain the logic neccessary to change the app state in a response to an action. Things get tricky when we fetch details from the API. We need to describe initial state and the lack of value as the request is pending.
Take the following example of two reducers’ state:
type Friend = {
name: string,
};type FriendsState = {
list: Array<Friend>,
loading: boolean,
};type AppState = {
isMenuOpen: boolean,
};
It has flow types for the friends.js
and app.js
reducers. All good — now we can use them inside our reducers like below:
/**
* @flow
*/
import type { FriendState } from '../types';type State = FriendsState;const initialState = {
loading: false,
list: null,
};export function friends(
state: State = initialState,
action: Object
): State {
return state;
}
Did you actually notice that the above code has a type error?
src/reducers/friends.js:9
9: list: null,
^^^ null. This type is incompatible with
2: list: Array<{ name: string }>,
^^^^^^^^^^^^^^^^^^^^^ array type. See: src/types.js:2
We declared that the friends.list
in state will always be an array, either empty or containing some fetched details. However, in our initialState
, it’s default value is null
. That would potentially cause an exception, since it changes the contract that was settled at the beginning.
Thanks to Flow and the state having a corresponding type, we were able to catch that tiny mistake as early as it happened. It’s these trivial mistakes that can potentially turn our app into an inconsistent or broken state. That’s why it is important to describe the state controlled by a reducer, so that you can catch these things as early as possible.
#3 — Connect like a pro
When working with Redux, we use connect
to map state to props of our containers and let them react to changes when they happen.
A simple example could be:
export default connect(
(state) => ({
list: state.friends.list,
})
)(Container);
How many times writing something similar you had to step back, check reducers and see the state shape? I guess a lot.
Now, since each of our reducers has its own type, like FriendsState
as we saw in the previous paragraph, we can easily introduce yet another one, called State
:
type State = {
friends: FriendsState,
app: AppState,
};
and use it in our connect:
/**
* @flow
*/
import { connect } from 'react-redux';
import type { State } from '../types';export default connect(
(state: State) => ({
list: state.friends.list,
})
)(Container);
This pattern helps us to reason about the entire app state as well as eliminate common issues, like misspelling the property names. Thanks to that, we can catch them early, as we type, instead of debugging it during runtime.
#4 — Be careful with typing actions
Following the F8 app
approach, we describe our actions as a single, union type, called Action
:
type Action =
| { key: 'LOAD_FRIENDS' }
| { key: 'LOAD_FRIENDS_SUCCESS', payload: { list: Array<Object> } }
However, union type holding information about all the actions, in the form as above, was something that stopped working for us as the app grew.
The main problem was that we were using redux-promise-middleware
to automatically dispatch success and failure case as the promise was either resolved or rejected.
What we really wanted is to have each action, especially the one that’s a result of an API call, to be fully typed, so that it’s easier to map response body of an HTTP request to the state. At the same time, we didn’t want to repeat things like key
and payload
every time, since effectively the only thing that’s changing is the response body itself.
So, instead of focusing on typing actions, we decided to create one generic type, called ApiAction
:
type ApiAction<T> = {
key: string,
payload: T,
};
that can be used inside reducers to map an action with the payload.
Thanks to that, we could focus on typing payloads instead, as in this example:
type Friends = Array<Friend>
Note: We already have a
Friend
andFriends
type that we are using insideFriendsState
, so it’s less typing for the same security!
Finally, implementing it in our reducer is as simple as this:
/**
* @flow
*/
import type {
Friends,
Friend,
ApiAction,
Handler,
FriendsState,
} from '../types';const initialState = {
loading: false,
list: null,
};const handlers: Handler<FriendsState> = { ['LOAD_FRIENDS_SUCCESS'](state, action: ApiAction<Friends>) {
// here, action is typed with response
return s;
},};export createReducer(initialState, handlers);
In this example, we define handlers as an object where each key is a type of an action to handle and function is a transform to be applied to the state. It can be described with the following Flow type:
type Handler<T> = {
[key: string]: (state: T, action: Action<*>): T
};
If you are interested in an implementation of createReducer
, check this gist!
#5 — Use constants or not
Last important concept are constants. I haven’t payed too much attention to them just yet, mainly because they are not the main bottleneck inside the app, as it grows.
The focus we had was always on ensuring that it’s as easy for the developer to work with the action payload inside reducer rather than checking whether the action key is correct.
Having said so, up to this point, we tend to define constants in a separate file the old school way, however sometimes we define an enum, called ActionType
that can be used in all the above types we mentioned as a replacement to string
:
type ActionType =
| 'LOAD_FRIENDS'
| 'LOAD_FRIENDS_SUCCESS'
| 'LOAD_FRIENDS_FAILURE';
Flow will warn us in case of a spelling mistake.
Wrapping up
There’s definitely a lot of fun when it comes to typing Redux with Flow. The amount of possible solutions, especially with actions, constants and reducers opens up a room for a lot of discussing and improvements. As always in this case, pick the solution that works for you and gives you the most benefits in that very project you are working. I hope the tips that I shared today will give you yet another point of view on using Flow with Redux!
Peace, Mike.
Thanks to Max Stoiber and Nader Dabit for making sure it can be understood by more than just me.