How to build a type-safe React Redux store with TypeScript

Adding a type checking feature to your React application can help you catch lots of bugs at compile time. In this tutorial, we’ll demonstrate how to build a type-safe React Redux app by examining a real-world example.

To illustrate these concepts, we’ll create a sample e-commerce app like the one shown below.

E-Commerce App E-Cart Example

E-Commerce App E-Cart Example

Completed E-Commerce App E-Cart Example

Without further ado, let’s get started!

Building a type-safe Redux app

React is a component library developers commonly use to build the frontend of modern applications. As an application expands and evolves, it often becomes increasingly difficult to manage the data. That’s where Redux comes in. Basically, Redux is a state management library that is popular in the React ecosystem.

Let’s start by building an e-commerce application workflow. Here, we have two important domains in the wireframe: inventory and cart.

Diagram Showing Inventory and Cart Domains

First, we’ll create the essential Redux building blocks — namely, action creator, reducer, and store. Since we know the application domains, we’ll structure our app based on that.
Create a react application using this command:

npx create-react-app react-redux-example --template typescript

This will create a React application boilerplate with TypeScript files. Next, install the dependencies for React Redux and its types.

npm i react-redux redux redux-saga typesafe-actions
npm i --save-dev @types/react-redux 

The above command should install the redux and react-redux libraries, which handle the React and Redux connection. Next, install [typesafe-action](https://github.com/piotrwitek/typesafe-actions), which helps to create an action with type check.

Now it’s time to create a file structure for our Redux store.

Redux Store File Structure
The application store is structured based on the domain. You can see that all the actions, reducers, and sagas of the inventory domain are maintained in one folder, whereas the actions, reducers, and sagas of the cart domain are maintained in an another folder.

Inventory domain

Let’s start with the inventory domain. We need to create actions, reducers, sagas, and types for the inventory domains. I always start with domain type because that way, I can define the structure of the specified domain at an early stage.

The type will contain the redux state, action types, and domain.

 export interface Inventory {
  id: string;
  name: string;
  price: string;
  image: string;
  description: string;
  brand?: string;
  currentInventory: number;
}
export enum InventoryActionTypes {
  FETCH_REQUEST = "@@inventory/FETCH_REQUEST",
  FETCH_SUCCESS = "@@inventory/FETCH_SUCCESS",
  FETCH_ERROR = "@@inventory/FETCH_ERROR"
}
export interface InventoryState {
  readonly loading: boolean;
  readonly data: Inventory[];
  readonly errors?: string;
}

A few notes about the code above:

  • The Inventory interface determines the specified domain data
  • The InventoryActionTypes enum determines the action types
  • The Inventory state handles the type of domain state

Now, it’s time to create an action for the inventory store.

import { action } from "typesafe-actions";
import { InventoryActionTypes, Inventory, InventoryState } from "./types";

export const fetchRequest = () => action(InventoryActionTypes.FETCH_REQUEST);

export const fetchSuccess = (data: Inventory[]) =>
  action(InventoryActionTypes.FETCH_SUCCESS, data);

export const fetchError = (message: string) =>
  action(InventoryActionTypes.FETCH_ERROR, message);

First, import typesafe-actions for action typechecks. Next, define actions such as fetchRequest, fetchSuccess, and fetchError.

Two important things to note here:

  • The fetchSuccess function takes an array as an argument that should have a type of Inventory. Otherwise, it will throw an error in compile time
  • The fetchError function takes an argument that should be of type string

After that, create the sagas for the inventory domain.

import { all, call, fork, put, takeEvery } from "redux-saga/effects";
import { InventoryActionTypes } from "./types";
import { fetchError, fetchSuccess } from "./action";
import inventory from "../../mockdata";
function* handleFetch() {
  try {

    //this is a mock data.. replace this with real api
    const data = inventory;
    yield put(fetchSuccess(data));
  } catch (err) {
    if (err instanceof Error && err.stack) {
      yield put(fetchError(err.stack));
    } else {
      yield put(fetchError("An unknown error occurred."));
    }
  }
}
function* watchFetchRequest() {
  yield takeEvery(InventoryActionTypes.FETCH_REQUEST, handleFetch);
}
function* inventorySaga() {
  yield all([fork(watchFetchRequest)]);
}
export default inventorySaga;

If you’re new to redux-saga, check out the excellent documentation for an in-depth look. For our purposes, I’ll describe it at a high level.

Here, we will take every fetch request of inventory action and execute a generator function that calls an API and returns the data.

The final part of the inventory store is the reducer. Let’s create that file.

import { Reducer } from "redux";
import { InventoryActionTypes, InventoryState } from "./types";
export const initialState: InventoryState = {
  data: [],
  errors: undefined,
  loading: false
};
const reducer: Reducer<InventoryState> = (state = initialState, action) => {
  switch (action.type) {
    case InventoryActionTypes.FETCH_REQUEST: {
      return { ...state, loading: true };
    }
    case InventoryActionTypes.FETCH_SUCCESS: {
      console.log("action payload", action.payload);
      return { ...state, loading: false, data: action.payload };
    }
    case InventoryActionTypes.FETCH_ERROR: {
      return { ...state, loading: false, errors: action.payload };
    }
    default: {
      return state;
    }
  }
};
export { reducer as InventoryReducer };

First, define an initial state that has a type of InventoryState.

export const initialState: InventoryState = {
  data: [],
  errors: undefined,
  loading: false
};

After that, create a reducer with a state type of inventoryState. It’s very important to define the types for each reducer because you want to identify issues at compile time rather than run time.

const reducer: Reducer<InventoryState> = (state = initialState, action) => {
  switch (action.type) {
    case InventoryActionTypes.FETCH_REQUEST: {
      return { ...state, loading: true };
    }
    case InventoryActionTypes.FETCH_SUCCESS: {
      console.log("action payload", action.payload);
      return { ...state, loading: false, data: action.payload };
    }
    case InventoryActionTypes.FETCH_ERROR: {
      return { ...state, loading: false, errors: action.payload };
    }
    default: {
      return state;
    }
  }
};

Here, we handle all the actions of the inventory domain and update the state.

Cart domain

It’s time to implement the redux functionalities for the cart. The functionalities of the cart domain are similar to those of the inventory domain.

First, create a file named types.ts and add the following code.

import { Inventory } from "../inventory/types";
export interface Cart {
  id: number;
  items: Inventory[];
}
export enum CartActionTypes {
  ADD_TO_CART = "@@cart/ADD_TO_CART",
  REMOVE_FROM_CART = "@@cart/REMOVE_FROM_CART",
  FETCH_CART_REQUEST = "@@cart/FETCH_CART_REQUEST",
  FETCH_CART_SUCCESS = "@@cart/FETCH_CART_SUCCESS",
  FETCH_CART_ERROR = "@@cart/FETCH_CART_ERROR"
}
export interface cartState {
  readonly loading: boolean;
  readonly data: Cart;
  readonly errors?: string;
}

This represents the cart domain attributes, cart action types, and cart state of Redux.

Next, create action.ts for the cart domain.

import { action } from "typesafe-actions";
import { CartActionTypes, Cart, cartState } from "./types";
import { Inventory } from "../inventory/types";

export const fetchCartRequest = () =>
  action(CartActionTypes.FETCH_CART_REQUEST);

export const fetchSuccess = (data: Cart) =>
  action(CartActionTypes.FETCH_CART_SUCCESS, data);

export const fetchError = (message: string) =>
  action(CartActionTypes.FETCH_CART_ERROR, message);

export const addToCart = (item: Inventory) =>
  action(CartActionTypes.ADD_TO_CART, item);

action.ts contains all the actions that handle the cart domain functionalities.

Once the action is dispatched, it needs to be received by the saga. Create a file called sagas.ts and add the following code.

import { all, call, fork, put, takeEvery, select } from "redux-saga/effects";
import { CartActionTypes } from "./types";
import { fetchError, fetchSuccess } from "./action";
export const getCart = (state: { cart: any }) => state.cart;
function* handleFetch() {
  try {
    const data = yield select(getCart);
    yield put(fetchSuccess(data));
  } catch (err) {
    if (err instanceof Error && err.stack) {
      yield put(fetchError(err.stack));
    } else {
      yield put(fetchError("An unknown error occurred."));
    }
  }
}
function* watchFetchRequest() {
  yield takeEvery(CartActionTypes.FETCH_CART_REQUEST, handleFetch);
}
function* cartSaga() {
  yield all([fork(watchFetchRequest)]);
}
export default cartSaga;

The saga takes every FETCH_CART_REQUEST action type and executes the specified handler for the action.

Finally, write the code for the cart domain reducer. Create a file, name it reducer.ts, and add the following code.

import { Reducer } from "redux";
import { CartActionTypes, cartState } from "./types";
export const initialState: cartState = {
  data: {
    id: 0,
    items: []
  },
  errors: undefined,
  loading: false
};
const reducer: Reducer<cartState> = (state = initialState, action) => {
  switch (action.type) {
    case CartActionTypes.FETCH_CART_REQUEST: {
      return { ...state, loading: true };
    }
    case CartActionTypes.FETCH_CART_SUCCESS: {
      return { ...state, loading: false, data: action.payload };
    }
    case CartActionTypes.FETCH_CART_ERROR: {
      return { ...state, loading: false, errors: action.payload };
    }
    case CartActionTypes.ADD_TO_CART: {
      return {
        errors: state.errors,
        loading: state.loading,
        data: {
          ...state.data,
          id: state.data.id,
          items: [...state.data.items, action.payload]
        }
      };
    }
    case CartActionTypes.REMOVE_FROM_CART: {
      return {
        errors: state.errors,
        loading: state.loading,
        data: {
          ...state.data,
          id: state.data.id,
          items: state.data.items.filter(item => item !== action.payload.id)
        }
      };
    }
    default: {
      return state;
    }
  }
};
export { reducer as cartReducer };

So far, we have created actions, sagas, and reducers for each domain. Now it’s time to configure the store for our application.

Configure store

Create a file named configureStore.ts in the root directory and add the following code.

import { Store, createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { routerMiddleware } from 'connected-react-router';
import { History } from 'history';
import { ApplicationState, createRootReducer, rootSaga } from './store';
export default function configureStore(
  history: History,
  initialState: ApplicationState
): Store<ApplicationState> {
  const sagaMiddleware = createSagaMiddleware();
  const store = createStore(
    createRootReducer(history),
    initialState,
    applyMiddleware(routerMiddleware(history), sagaMiddleware)
  );
  sagaMiddleware.run(rootSaga);
  return store;
}

We created a function called configureStore, which takes history, and initialState as an argument.

We need to define the type for arguments such as history and initialState. initialState should have the type of ApplicationStore, which is defined in the store. The configureStore function returns the type Store, which contains the ApplicationState.

After that, create a store that takes the root reducer, initialStore, and middlewares. Next, run the saga middleware with the root saga.

We’re finally done with the Redux part. Next we’ll demonstrate how to implement the components for it.

Components structure

Let’s zoom in on our components.

Redux Store Component Structure

  • HomePage handles the main page, which renders the ProductItem component
  • Navbar renders the navbar and cart items count
  • Cart contains the list items that are added to the cart

Once you know how to structure a type-safe redux application, implementing components is fairly straightforward. Take the component part as an exercise and leave a comment below with your GitHub link.

You can find the complete source code for reference on GitHub.

Summary

Adding a type check can help you avoid issues at compile time itself. For further reading, an understanding of the following concepts will help you along your type checking journey.

#reactjs #redux #typescript #webdev #javascript

How to build a type-safe React Redux store with TypeScript
105.00 GEEK