Simplifying Connected Props with Redux and TypeScript

When using Redux-connected components, there can be as many as three sources of props:

  • props passed from the parent component,
  • props returned from mapStateToProps,
  • props returned from mapDispatchToProps.

When used with TypeScript, all those props need to have types. If it's a stateful class-based component, the state needs to be typed as well. This is a lot of manual type declaration, which has to be also maintained in the future. Luckily, starting from the version 7.1.2 of @types/react-redux package it's possible to automatically infer types of connected props in the most cases. The way to do that is documented in the React Redux documentation, and in this post we'll see the application on a concrete example. 

We'll be refactoring a sample App component, the implementation (but not the type) details of which are simplified for brevity. The component itself fetches a list of items on mount (via Redux action) and then renders the list, which it receives from the props. Additionally the component is using React router, where it receives the URL params as props from.

// types.tsx
export type Item = {
  id: number;
  text: string;
};

export type AppState = {
  loading: boolean;
  data: Item[];
};

// actions.ts
export function loadData(): ThunkAction<void, AppState, undefined, PayloadAction<any>> {
  // Load data from api
}

export function deleteItem(id: string): ThunkAction<void, AppState, undefined, PayloadAction<any>> {
  // Delete an item by id
}

export function addItem(item: Item): ThunkAction<void, AppState, undefined, PayloadAction<any>> {
  // Add a new item
}

// App.tsx
import React, { useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
import { loadData, deleteItem, addItem } from './actions';
import { Item, AppState } from './types';

interface OwnProps extends RouteComponentProps<{ id: string }> {}

interface ConnectedProps {
  loading: boolean;
  data: Item[];
}

interface DispatchProps {
  loadData: typeof loadData;
  deleteItem: typeof deleteItem;
  addItem: typeof addItem;
}

export type Props = OwnProps & ConnectedProps & DispatchProps;

export const App = ({ loading, data, loadData, ...props }: Props) => {
  useEffect(() => {
    loadData();
  }, [loadData]);

  if (loading) {
    return <p>Loading...</p>;
  }
  return (
    <div>
      <ul>
        {data.map((result) => (
          <li key={result.id}>{result.text}</li>
        ))}
      </ul>
    </div>
  );
};

const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps> = (state: AppState, props: OwnProps) => {
  return {
    loading: state.loading,
    data: state.data,
    id: props.match.params.id,
  };
};

const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
  loadData,
  deleteItem,
  addItem,
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

Note that we use typeof to infer the types of the actions and the types in mapStateToProps are basically a combination of AppState and OwnProps types. Looks like we're doing a lot of manual type declaration for the types we have already available elsewhere, so why not to use that type information and infer the component props automatically?

Another issue here is that the dispatched actions return a function of ThunkAction type, which in turn returns void (i.e. nothing). When connecting the component to Redux and running TypeScript in a strict mode, we get the following error:

 

Type 'Matching<ConnectedProps & { loadData: () => void; }, Props>' is not assignable to type 'DispatchProps'.           
  The types returned by 'loadData(...)' are incompatible between these types.   
     Type 'void' is not assignable to type 'ThunkAction<void, AppState, undefined, { payload: any; type: string; }>'.

The last part, Type 'void' is not assignable to type 'ThunkAction<void, AppState, undefined, { payload: any; type: string; }>'. is the most important here. Even though the type of the loadData is () => ThunkAction => void, due to the way how React-Redux resolves thunks, the actual inferred type will be () => void.

That's where ConnectedProps helper type becomes useful. It allows inferring connected types from mapStateToProps and mapDispatchToProps, plus it will correctly resolve the types for thunks. To start, let's move mapStateToProps and mapDispatchToProps to the top of the file and strip them from all the generic type declarations, as they won't be necessary anymore.

const mapStateToProps = (state: AppState, props: OwnProps) => {
  return {
    loading: state.loading,
    data: state.data,
    id: props.match.params.id,
  };
};

const mapDispatchToProps = {
  loadData,
  deleteItem,
  addItem,
};

Next we need to create a connector function by combining the props from Redux. We do it before declaring the component since we'll use this function when creating the Props type.

const connector = connect(mapStateToProps, mapDispatchToProps);

Now it's time to use ConnectedProps helper to extract the types of the connected props. Before that we'll also need to remove our ConnectedProps and DispatchProps interfaces.

 

import { ConnectedProps } from 'react-redux';

//...

type PropsFromRedux = ConnectedProps<typeof connector>;

And lastly, we combine these props with own props to create the Props type for the component.

 

interface OwnProps extends RouteComponentProps<{ id: string }> {}

type Props = PropsFromRedux & OwnProps;

export const App = ({ loading, data, loadData, ...props }: Props) => { //.. }

export default connector(App);

The final result will look like this.

import React, { useEffect } from 'react';
import { ConnectedProps, connect } from 'react-redux';
import { RouteComponentProps } from 'react-router-dom';
import { loadData, deleteItem, addItem } from './actions';
import { AppState } from './types';

const mapStateToProps = (state: AppState, props: OwnProps) => {
  return {
    loading: state.loading,
    data: state.data,
    id: props.match.params.id,
  };
};

const mapDispatchToProps = {
  loadData,
  deleteItem,
  addItem,
};

const connector = connect(mapStateToProps, mapDispatchToProps);

type PropsFromRedux = ConnectedProps<typeof connector>;

interface OwnProps extends RouteComponentProps<{ id: string }> {}

export type Props = PropsFromRedux & OwnProps;

export const App = ({ loading, data, loadData, ...props }: Props) => {
  useEffect(() => {
    loadData();
  }, [loadData]);

  if (loading) {
    return <p>Loading...</p>;
  }
  return (
    <div>
      <ul>
        {data.map((result) => (
          <li key={result.id}>{result}</li>
        ))}
      </ul>
    </div>
  );
};

export default connector(App);

We have simplified our component by getting rid of the manual declaration of the props received from Redux. They are now inferred automatically from the types we have for them in the state and actions. This greatly improves the maintainability of the app and also fixes the issue of incorrectly inferring Redux thunk action return types.

23