DEV Community

Cover image for Set Up a Typescript React Redux Project
Nick Scialli (he/him)
Nick Scialli (he/him)

Posted on

Set Up a Typescript React Redux Project

types

Introduction

This post provides a way to type your React Redux project with Typescript.

Using the Ducks Pattern

This post loosely uses the Redux Ducks proposal, which groups Redux "modules" together rather than by functionality in Redux. For example, all of the Redux code related to the users piece of state lives in the same file rather than being scattered across different types, actions, and reducer folders throughout your app. If this isn't quite clear yet, you'll see what I mean shortly!

Example App

As an example, let's pretend we're making a shopping cart app where we have a user that may or may not be logged in and we have products. These will serve as the two main parts of Redux state.

Since we're focused on Redux typings, let's bootstrap our app using create-react-app so we can get up and running quickly. Remember to give it the --typescript flag when you create the project.

yarn create react-app shopping-cart --typescript
Enter fullscreen mode Exit fullscreen mode

Great! Now, let's go into our app directory and instal Redux and its types.

yarn add redux react-redux @types/redux @types/react-redux
Enter fullscreen mode Exit fullscreen mode

Setting Up our First Module

Let's create the user module. We'll do this by creating a src/redux/modules/user.ts file. We can define our UserState type and a couple action creators: login and logout.

Since we're not going to worry about validating passwords, we can just assume we only have a username prop on our user state that can either be a string for a logged-in user or null for a guest.

src/redux/modules/user.ts

type UserState = {
  username: string | null;
};

const initialState: UserState = { username: null };

const login = (username: string) => ({
  type: 'user/LOGIN';
  payload: username;
});

const logout = () => ({
  type: 'user/LOGOUT'
});
Enter fullscreen mode Exit fullscreen mode

Note that the user/login is a rough adaptation of the Redux Ducks proposal to name your types in the format app-name/module/ACTION.

Next, let's create a user reducer. A reducer takes the state and an action and produces a new state. We know we can type both our state argument and the reducer return value as UserState, but how should we type the action we pass to the reducer? Our first approach will be taking the ReturnType of the login and logout action creators.

src/redux/modules/user.ts

type UserState = {
  username: string | null;
};

const initialState: UserState = { username: null };

const login = (username: string) => ({
  type: 'user/LOGIN',
  payload: username,
});

const logout = () => ({
  type: 'user/LOGOUT',
});

type UserAction = ReturnType<typeof login | typeof logout>;

export function userReducer(
  state = initialState,
  action: UserAction
): UserState {
  switch (action.type) {
    case 'user/LOGIN':
      return { username: action.payload };
    case 'user/LOGOUT':
      return { username: null };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, we have a couple problems. First, we're getting the following Typescript compilation error: Property 'payload' does not exist on type '{ type: string; }'. This is because our attempted union type isn't quite working and the Typescript compiler thinks we may or may not have an action payload for the login case.

The second issue, which turns out to cause the first issue, is that the Typescript compiler doesn't detect an incorrect case in our switch statement. For example, if added a case for "user/UPGRADE", we would want an error stating that it's not an available type.

How do we solve these issues?

Function Overloads and Generics to the Rescue!

It turns out we can solve this issue by using Typescript function overloads and generics. What we'll do is make a function that creates typed actions for us. The type created by this function will be a generic that extends string. The payload will be a generic that extends any.

src/redux/modules/user.ts

export function typedAction<T extends string>(type: T): { type: T };
export function typedAction<T extends string, P extends any>(
  type: T,
  payload: P
): { type: T; payload: P };
export function typedAction(type: string, payload?: any) {
  return { type, payload };
}

type UserState = {
  username: string | null;
};

const initialState: UserState = { username: null };

export const login = (username: string) => {
  return typedAction('user/LOGIN', username);
};

export const logout = () => {
  return typedAction('user/LOGOUT');
};

type UserAction = ReturnType<typeof login | typeof logout>;

export function userReducer(
  state = initialState,
  action: UserAction
): UserState {
  switch (action.type) {
    case 'user/LOGIN':
      return { username: action.payload };
    case 'user/LOGOUT':
      return { username: null };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Success! We're now free of our compilation errors. Even better, we can be sure our cases are restricted to actual types we've created.

types

Creating Our RootReducer and Store

Now that we have our first module put together, let's create our rootReducer in the src/redux/index.ts file.

src/redux/index.ts

import { combineReducers } from 'redux';
import { userReducer } from './modules/user';

export const rootReducer = combineReducers({
  user: userReducer,
});

export type RootState = ReturnType<typeof rootReducer>;
Enter fullscreen mode Exit fullscreen mode

If you're familiar with Redux, this should look pretty standard to you. The only slightly unique piece is that we're exporting a RootState using the ReturnType of our rootReducer.

Next, let's create our store in index.tsx and wrap our app in a Provider. Again, we should be familiar with this if we're familiar with Redux.

src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { rootReducer } from './redux';

const store = createStore(rootReducer);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

Adding a Module with Thunks

Often, we'll need some async functionality in our action creators. For example, when we get a list of products, we'll probably be performing a fetch request that will resolve its Promise at some future time.

To allow for this asynchronous functionality, let's add redux-thunk and its types, which lets us return thunks from our action creators.

yarn add redux-thunk @types/redux-thunk
Enter fullscreen mode Exit fullscreen mode

Next, let's make sure to add this middleware when creating our store.

src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { rootReducer } from './redux';
import thunk from 'redux-thunk';

const store = createStore(rootReducer, applyMiddleware(thunk));

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

Great! We can now create our products module, which will have the ability to return thunks from its action creators.

The product piece of our state will be a litte more complicated. It'll have a products prop, a cart prop, and a loading prop.

src/redux/modules/products.ts

// TODO: We should move typedAction elsewhere since it's shared
import { typedAction } from './users';
import { Dispatch, AnyAction } from 'redux';

type Product = {
  id: number;
  name: string;
  price: number;
  img: string;
};

type CartItem = {
  id: number;
  quantity: number;
};

type ProductState = {
  products: Product[];
  loading: boolean;
  cart: CartItem[];
};

const initialState: ProductState = {
  products: [],
  loading: false,
  cart: [],
};

const addProducts = (products: Product[]) => {
  return typedAction('products/ADD_PRODUCTS', products);
};

export const addToCart = (product: Product, quantity: number) => {
  return typedAction('products/ADD_TO_CART', { product, quantity });
};

// Action creator returning a thunk!
export const loadProducts = () => {
  return (dispatch: Dispatch<AnyAction>) => {
    setTimeout(() => {
      // Pretend to load an item
      dispatch(
        addProducts([
          {
            id: 1,
            name: 'Cool Headphones',
            price: 4999,
            img: 'https://placeimg.com/640/480/tech/5',
          },
        ])
      );
    }, 500);
  };
};

type ProductAction = ReturnType<typeof addProducts | typeof addToCart>;

export function productsReducer(
  state = initialState,
  action: ProductAction
): ProductState {
  switch (action.type) {
    case 'products/ADD_PRODUCTS':
      return {
        ...state,
        products: [...state.products, ...action.payload],
      };
    case 'products/ADD_TO_CART':
      return {
        ...state,
        cart: [
          ...state.cart,
          {
            id: action.payload.product.id,
            quantity: action.payload.quantity,
          },
        ],
      };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

There's a lot going on here, but the real novelty is in loadProducts, our action creator that returns a thunk. Our setTimeout function is simulating a fetch without having to actually perform a fetch.

We now need to register the productsReducer with our rootReducer. At this point, it's as easy as adding the respective key.

src/redux/index.ts

import { combineReducers } from 'redux';
import { userReducer } from './modules/user';
import { productsReducer } from './modules/products';

export const rootReducer = combineReducers({
  user: userReducer,
  products: productsReducer,
});

export type RootState = ReturnType<typeof rootReducer>;
Enter fullscreen mode Exit fullscreen mode

Using In Our App

We're ready to use our Redux store! We've already added the Provider to our index.tsx file, so all we have to do is connect individual components.

Let's first connect an Auth component. We'll want to access the user.username prop from our state as well as the login and logout action creators.

src/Auth.tsx

import React from 'react';
import { RootState } from './redux';
import { login, logout } from './redux/modules/user';
import { connect } from 'react-redux';

const mapStateToProps = (state: RootState) => ({
  username: state.user.username,
});

const mapDispatchToProps = { login, logout };

type Props = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps;

const UnconnectedAuth: React.FC<Props> = props => {
  // Do auth things here!
  return <>{props.username}</>;
};

export const Auth = connect(
  mapStateToProps,
  mapDispatchToProps
)(UnconnectedAuth);
Enter fullscreen mode Exit fullscreen mode

Note that we define mapStateToProps and mapDispatchToProps at the to, which helps us derive the Props type using ReturnType. We now have access to props.username, props.login, and props.logout in our component.

Dispatching Thunks

One wrinkle is when we want to map in an action creator that returns a thunk. We can use map in our loadProducts action creator as an example. In this case, we use Redux's handy bindActionCreators function!

src/Products.tsx

import React from 'react';
import { RootState } from './redux';
import { loadProducts } from './redux/modules/products';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';

const mapStateToProps = (state: RootState) => ({
  cart: state.products.cart,
});

const mapDispatchToProps = (dispatch: Dispatch) => {
  return bindActionCreators(
    {
      loadProducts,
    },
    dispatch
  );
};

type Props = ReturnType<typeof mapStateToProps> &
  ReturnType<typeof mapDispatchToProps>;

const UnconnectedProducts: React.FC<Props> = props => {
  // Do cart things here!
  return <>Your Cart</>;
};

export const Products = connect(
  mapStateToProps,
  mapDispatchToProps
)(UnconnectedProducts);
Enter fullscreen mode Exit fullscreen mode

Conclusion

And that's it! Not too bad to get the state management goodness of Redux with the type safety of Typescript. If you want to see a similar app in action, please check out the associated github repo.

Top comments (0)