Typing React (3) Redux
In previous articles we have already explained how to use TypeScript in regular React and with Material-UI. In this article I will show the most important and difficult part: redux.
Previous articles can be found here:
The packages used in this articles are:
$ npm install --save redux react-redux typesafe-actions \
rxjs redux-observable lodash reselect \
@types/react-redux
The common and recommended way of using redux is with the combination ofredux
+ typesafe-actions
+ redux-observable
. The configuration is a bit complicated so I will explain piece by piece.
Basic Concepts
In this tutorial I will use the following directory structure:
src/
|- store
| |- actions
| |- reducers
| |- epics
| `- selectors
`- services
The directory names should be pretty self-explained.
The following figure shows the overall concept of redux
+ typesafe-action
+ redux-observable
.
- The core part of the redux store is an action stream. An action is simply a piece of data with two fields:
{ type: string; payload: any }
. As it is a stream, it can be represented withrxjs
Observable
. - Actions are generated in two ways: dispatched by a Component, or generated by an Epic.
- A Component can dispatch an action at any time. Usually this is triggered by an event, e.g. dispatch a
LIST
event after page is loaded, or dispatch aSAVE
event after user clicks a button. - An Epic is an object that watches the action stream. When a specific action appears in the stream, the epic will do something (usually a side effect) and then push zero or more actions (usually one) back to the stream. Here the word “side effect” simply means something that depends on something outside the store, e.g. an API call, a DOM operation, or even printing a log message.
- A reducer watches the action stream and transit the state as necessary.
This seems complicated, so it would benefit to explain by example. Suppose we want to retrieve all the Todo items from API:
- The Component dispatches an action with type
TODO:LIST:REQUEST
. - The Epic sees this action and triggers an API call. After the API call returns, it extracts the Todo list from response, assembles an
TODO:LIST:SUCCESS
action with the Todo list data, then push this action back to the stream. - The reducer receives
TODO:LIST:SUCCESS
action and extracts the Todo list data from it, then update global state with the Todo list.
Actions
Let’s start with the actions. There are two categories:
- Standard action, simply an action;
- Asynchronized action, consists of three actions:
REQUEST
,SUCCESS
, andFAILURE
, and is used for asynchronized calls.
import { createAsyncAction, createStandardAction } from 'typesafe-actions';
import { Todo } from 'Models';// Standard action
export const setNote = createStandardAction('NOTE:SET_NOTE')<string>();// Asynchronized actions
export const listTodo = createAsyncAction(
'TODO:LIST:REQUEST',
'TODO:LIST:SUCCESS',
'TODO:LIST:FAILURE',
)<void, Todo[], Error>();
The type parameters after the function call (e.g. <string>
, and <void, Todo[], Error>
) are the payload types of the actions.
Reducers
The following code example shows a normalized store consists of two major fields: byId
and allIds
. Several points covered by this code are:
- You need to declare a state type
TodoState
to define the shape of the state. Note all the objects should be marked asReadonly
to indicate that state is immutable. Particularly, nested objects should be marked asReadonly
as well. - Use
TodoState['byId']
to access the attribute type. RootAction
is an aggregated type which we will explain later. Now all you need to know is that it represents all possible actions.
import _ from 'lodash';
import { getType } from 'typesafe-actions';
import { combineReducers } from 'redux';
import { Todo } from 'Models';
import { listTodo } from '../actions/todo';
import { RootAction } from 'StoreTypes';
export type TodoState = Readonly<{
byId: Readonly<{ [key: number]: Todo }>;
allIds: number[];
loading: boolean;
}>;
const initialState: TodoState = {
byId: {},
allIds: [],
loading: false,
};
const byId = (state: TodoState['byId'] = initialState.byId, action: RootAction) => {
switch (action.type) {
case getType(listTodo.success):
return _.keyBy(action.payload, 'id');
default:
return state;
}
};
const allIds = (state: TodoState['allIds'] = initialState.allIds, action: RootAction) => {
switch (action.type) {
case getType(listTodo.success):
return _.map(action.payload, 'id');
default:
return state;
}
};
const loading = (
state: TodoState['loading'] = initialState.loading,
action: RootAction,
) => {
switch (action.type) {
case getType(listTodo.request):
return true;
case getType(listTodo.success):
case getType(listTodo.failure):
return false;
default:
return state;
}
};
export default combineReducers({ byId, allIds, loading });
Epics
Epics are functions that watch the action stream and do something when special action appears. Usually, it is an actions$.pipe()
call with a sequence of oeprators.The first operator is usually a filter(isOfType(getType(action)))
to filter out the action we interested in. And the operator sequence should eventually return zero or more actions, which will be pushed back to the action stream.
export const ListTodoEpic: RootEpic = (actions$, store, { todos }) =>
actions$.pipe(
filter(isOfType(getType(listTodo.request))),
mergeMap(action =>
todos.listTodos$().pipe(map(listTodo.success)),
catchError(err => of(listTodo.failure(err))),
);
Be careful that an Epic should NEVER return the action that it interested in! This will create an infinity loop. For example:
// DON'T DO THIS!
export const InfinityLoopEpic: RootEpic = (actions$, store, { todos }) =>
actions$.pipe(
filter(isOfType(getType(listTodo.request))),
mergeMap(action => action),
);
Types
To make typing easier, we can declare some global types. This is done by adding an index.ts
to reducers
, actions
and epics
directories, and a types.d.ts
to declare the types.
// =================================
// actions/index.ts
import * as TodoActions from './todo';export default {
todos: TodoActions,
};
// =================================
// reducers/index.ts
import todos from './todo';
import { combineReducers } from 'redux';
export default combineReducers({
todos,
});// =================================
// epics/index.ts
import { combineEpics } from 'redux-observable';
import * as todoEpic from './todo';
export default combineEpics(
...Object.values(todoEpic),
);
Note that combineEpics
and combineReducers
take different parameters. combineEpics
takes a list of single epics (that’s why we need to destruct imported object values), while combineReducers
takes a tree structure.
The code below shows how to define the root types.
// store/types.d.ts
declare module 'StoreTypes' {
import { StateType, ActionType } from 'typesafe-actions';
import { Services } from 'ServiceTypes';
import { Epic } from 'redux-observable';
export type Store = StateType<typeof import('./index').default>;
export type RootAction = ActionType<typeof import('./actions').default>;
export type RootState = StateType<ReturnType<typeof import('./reducers').default>>;
export type RootEpic = Epic<RootAction, RootAction, RootState, Services>;
}
Create Store
Now reducers and epics are ready, we can write code to create the store:
import { applyMiddleware, createStore, compose } from 'redux';
import { createEpicMiddleware } from 'redux-observable';import { RootAction, RootState } from 'StoreTypes';
import { Services } from 'ServiceTypes';import rootReducer from './reducers';
import rootEpic from './epics';
import services from '../services';export const epicMiddleware = createEpicMiddleware<
RootAction,
RootAction,
RootState,
Services
>({ dependencies: services });export const composeEnhancers =
(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;// configure middlewares
const middlewares = [epicMiddleware];
// compose enhancers
const enhancer = composeEnhancers(applyMiddleware(...middlewares));// rehydrate state on app start
const initialState = {};// create store
const store = createStore(rootReducer, initialState, enhancer);epicMiddleware.run(rootEpic);// export store singleton instance
export default store;
Selectors
Typing of selectors is pretty straightforward.
import { createSelector } from 'reselect';
import { RootState } from 'StoreTypes';export const selectTodoEntities = (state: RootState) => state.todos.byId;
export const selectTodoAllIds = (state: RootState) => state.todos.allIds;
export const selectTodoLoading = (state: RootState) => state.todos.loading;export const selectTodoList = createSelector(
[selectTodoEntities, selectTodoAllIds],
(entities, allIds) => allIds.map(id => entities[id]),
);
Component
The last part is to map the store to the component. Since we need to access the attributes mapped by mapStateToProps
and mapDispatchToProps
, we need these attributes defined in the IProps
type. So we can define IProps
like this:
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { RootState } from 'StoreTypes';
import { selectTodoList } from '../store/selectors/todo';
import { Dispatch } from 'redux';
import { listTodo } from '../store/actions/todo';
import TodoItem from './TodoItem';const mapStateToProps = (state: RootState) => ({
todos: selectTodoList(state),
});const mapDispatchToProps = (dispatch: Dispatch) => ({
listTodo: () => dispatch(listTodo.request()),
});export interface IProps
extends ReturnType<typeof mapStateToProps>,
ReturnType<typeof mapDispatchToProps> {}const TodoList = (props: IProps) => {
const { todos, listTodo } = props; useEffect(() => {
listTodo();
}, []); return (
<div>
{todos.map(todo => (
<TodoItem todo={todo} />
))}
</div>
);
};export default connect(mapStateToProps, mapDispatchToProps)(TodoList);
That’s all for typing redux. Thanks for reading!