in Front End

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 of redux + 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.

Concept of Redux store
  • 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 with rxjs 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 a SAVE 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, and FAILURE, 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 as Readonly to indicate that state is immutable. Particularly, nested objects should be marked as Readonly 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!


Originally posted at https://itnext.io/typing-react-3-redux-84e73e41db7f .

Write a Comment

Comment