Create type-safe React Redux store with TypeScript

Create type-safe React Redux store with TypeScript

React Redux just released version 7.1.0 which includes React hooks (FINALLY!!). I may take this opportunity to write about how to write a type-safe React Redux store with TypeScript (No more any).

It will be how I will implement it. There may be some better way to do it.

Here it will be what you get in the final result.

Action type checking
Action type checking

Action's payload type hit
Action's payload type hit

Prerequisite

  • Knowledge about TypeScript, React, React Redux
  • A working TypeScript React project

Basic definitions

First, we need to create some basic definitions which is needed no matter how we define our store.

// src/store/type.ts

import { Action as ReduxAction, Store as ReduxStore } from "redux";
import { ThunkAction, ThunkDispatch } from "redux-thunk";

type AnyFunction = (...args: any[]) => any;
type StringMap<T> = { [key: string]: T };

export type Action<T extends string = string, P = void> = P extends void
  ? ReduxAction<T>
  : ReduxAction<T> & Readonly<{ payload: P }>;

export type ActionsUnion<A extends StringMap<AnyFunction>> = ReturnType<
  A[keyof A]
>;

export type State = {};

export type Store = ReduxStore<State, Action> & {
  dispatch: Dispatch;
};

export type Dispatch = ThunkDispatch<State, void, Action>;

export type Actions = undefined;

export type DispatchAction<T = void> = ThunkAction<
  Promise<T>,
  State,
  void,
  Action
>;
// src/store/action.ts

import { Action } from "./type";

function isPayloadAction<T extends string, P>(action: {
  type: T;
  payload?: P;
}): action is Action<T, P> {
  return action.payload !== undefined;
}

export function createAction<T extends string>(type: T): Action<T>;

export function createAction<T extends string, P>(
  type: T,
  payload: P
): Action<T, P>;

export function createAction<T extends string, P>(
  type: T,
  payload?: P
): Action<T> | Action<T, P> {
  const action = { type, payload };
  return isPayloadAction(action) ? action : { type };
}
// src/store/reducer.ts

import { combineReducers, Reducer } from "redux";

import { Actions, State } from "./type";

export const reducer: Reducer<State, Actions> = combineReducers({});
// src/store/provider.tsx

import React, { ReactNode } from "react";
import { applyMiddleware, compose, createStore } from "redux";
import { Provider } from "react-redux";
import thunkMiddleware from "redux-thunk";

import { reducer } from "./reducer";
import { Store } from "./type";

const composeEnhancers =
  process.env.NODE_ENV === "development"
    ? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
    : compose;

const store: Store = createStore(
  reducer,
  composeEnhancers(applyMiddleware(thunkMiddleware))
);

type Props = {
  children: ReactNode;
};
export function StoreProvider(props: Props): JSX.Element {
  const { children } = props;
  return <Provider store={store}>{children}</Provider>;
}
// src/store/index.ts

export { StoreProvider } from "./provider";

That is a lot of code. Let's explain what we have done.

type.ts contains only types which will used for typings for others.

  • Action is a Redux action which contains type and payload. payload may not exist on some actions.
  • ActionsUnion is all the actions of a reducer.
  • State is the type of the Redux store.
  • Actions is all the possible action types of Redux Store.

action.ts contains implementation on creating a Redux action. You can see that createAction is overloaded. Basically, it is a utility function for creating actions.

reducer.ts contains the root reducer of the Redux store.

provider.tsx contains the Redux store provider for wrapping the application. It uses redux-thunk to allow async operation when dispatching actions. Also, __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ is to allow Redux Chrome extension for debugging.

Reducer Definitions

After creating basic definitions, it is time to create some reducers. I prefer separating reducers to their own folder.

src/
└── store/
    ├── action.ts
    ├── index.ts
    ├── provider.tsx
    ├── reducer.ts
    ├── type.ts
    ├── reducer1/
    │   ├── action.ts
    │   ├── index.ts
    │   ├── reducer.ts
    │   └── select.ts
    └── reducer2/
        ├── action.ts
        ├── index.ts
        ├── reducer.ts
        └── select.ts

Here are the definitions.

// src/store/reducer1/action.ts

import { ActionsUnion, DispatchAction } from "../type";
import { createAction } from "../action";

export enum ActionTypes {
  Action1 = "Action1",
  Action2 = "Action2"
}

export type Action1Options = {
  foo: string;
};
export const Actions = {
  action1: (options: Action1Options) =>
    createAction(ActionTypes.Action1, options),
  action2: () => createAction(ActionTypes.Action2)
};

export type Actions = ActionsUnion<typeof Actions>;

export function action1(options: Action1Options): DispatchAction {
  return async dispatch => {
    dispatch(Actions.action1(options));
  };
}
export function action2(): DispatchAction {
  return async dispatch => {
    const response = await fetch("https://example.com"); // some async action
    dispatch(Actions.action2());
  };
}
// src/store/reducer1/reducer.ts

import { Actions } from "../type";

import { ActionTypes } from "./action";

export type Reducer1State = {
  foo?: string;
};
const initialState: Readonly<Reducer1State> = {};

export function reducer(
  state: Reducer1State = initialState,
  action: Actions
): Reducer1State {
  switch (action.type) {
    case ActionTypes.Action1: {
      const { foo } = action.payload;
      return { foo };
    }
    case ActionTypes.Action2:
      return { ...initialState };
    default:
      return state;
  }
}
// src/store/reducer1/select.ts

import { createSelector } from "reselect";

import { State } from "../type";

import { Reducer1State } from "./reducer";

export function selectReducer1State(state: State): Reducer1State {
  return state.reducer1;
}

export const selectFoo = createSelector(
  selectReducer1State,
  state => state.foo
);
// src/store/reducer1/index.ts

export * from "./action";
export { reducer } from "./reducer";
export * from "./select";

You will see some error because we have not update the definitions in src/store/type.ts and src/store/reducer.ts.

// src/store/type.ts

import { Action as ReduxAction, Store as ReduxStore } from "redux";
import { ThunkAction, ThunkDispatch } from "redux-thunk";

import * as reducer1 from "./reducer1";

// Skipped some code

export type State = {
  reducer1: ReturnType<typeof reducer1.reducer>;
};

// Skipped some code

/*
 If you have more than 1 reducer, you write like the following:
export type Actions =
  | reducer1.Actions
  | reducer2.Actions;
*/
export type Actions = reducer1.Actions;

// Skipped some code
// src/store/reducer.ts

import { combineReducers, Reducer } from "redux";

import { Actions, State } from "./type";
import { reducer as reducer1 } from "./reducer1";

export const reducer: Reducer<State, Actions> = combineReducers({ reducer1 });

Let's see what we have done.

action.ts contains the codes about Redux action.

  • ActionTypes are the type of Redux action. It must be unique in your Redux store.
  • Actions are the collection of action factories of this reducer.
  • action1 & action2 are implementations on what the actions are doing when they are dispatched. They will be used for dispatching.

reducer.ts contains the reducer implementation.

select.ts contains the selector of the reducer. I recommend to use this pattern. It provides 2 benefits.

  1. Hide the implementation details of a reducer. When you select a value from Redux store, you want to use selectFoo instead of state.reducer1.foo. This allow you to change the structure of the reducer with affect other part of the code. It also provide a central place for you to manipulate the value (e.g. provide default value, mapping the value).
  2. It uses reselect which cached the result based on the parameters of the selector. For example, if the values in the state is not changed, it will return the cached result.

Usage

It is just standard way to use React Redux but we can now have type safety.

// src/component/component1/index.tsx

import React from "react";

import { connect } from "react-redux";

import { Dispatch, State } from "../../store/type";
import { action1, Action1Options } from "../../store/redcuer1";

const mapState = (state: State) => ({
  foo: selectFoo(state)
});
const mapDispatch = (dispatch: Dispatch) => ({
  action1: (options: Action1Options) => dispatch(action1(options))
});

type ComponentProps = { text: string };
type StateProps = ReturnType<typeof mapState>;
type DispatchProps = ReturnType<typeof mapDispatch>;
type Props = ComponentProps & StateProps & DispatchProps;

function Component1Impl(props: Props): JSX.Element {
  const { text, foo, action1 } = props;
  // Do anything you want
  return <button>{text}</button>;
}

export const Component1 = connect<
  StateProps,
  DispatchProps,
  ComponentProps,
  State
>(
  mapState,
  mapDispatch
)(Component1Impl);

React Hook

Who don't want to use the latest features, let see how to implement React hook.

// src/store/reducer1/hook.ts

import { useSelector } from "react-redux";

import { selectFoo } from "./select";

export const useFoo = () => useSelector(selectFoo);
// src/store/reducer1/index.ts

export * from "./action";
export { reducer } from "./reducer";
export * from "./select";
export * from "./hook";
// src/component/component1/index.tsx

import React, { useCallback } from "react";
import { useDispatch } from "react-redux";

import { Dispatch, State } from "../../store/type";
import { action1, useFoo } from "../../store/redcuer1";

type Props = { text: string };

export function Component1(props: Props): JSX.Element {
  const { text } = props;
  const dispatch = useDispatch<Dispatch>();
  const foo = useFoo();
  const callback = useCallback(() => dispatch(action1("bar")), [dispatch]);
  // Do anything you want
  return <button>{text}</button>;
}

You can see it has much clearer code with React hook than connect.