Simplifying Connected Props with Redux and TypeScript

Updated on · 3 min read
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.

tsx
// 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);
tsx
// 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:

tsx
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; }>'.
tsx
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.

tsx
const mapStateToProps = (state: AppState, props: OwnProps) => { return { loading: state.loading, data: state.data, id: props.match.params.id, }; }; const mapDispatchToProps = { loadData, deleteItem, addItem, };
tsx
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.

tsx
const connector = connect(mapStateToProps, mapDispatchToProps);
tsx
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.

tsx
import { ConnectedProps } from 'react-redux'; //... type PropsFromRedux = ConnectedProps<typeof connector>;
tsx
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.

tsx
interface OwnProps extends RouteComponentProps<{ id: string }> {} type Props = PropsFromRedux & OwnProps; export const App = ({ loading, data, loadData, ...props }: Props) => { //.. } export default connector(App);
tsx
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.

tsx
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);
tsx
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.

P.S.: Are you looking for a reliable and user-friendly hosting solution for your website or blog? Cloudways is a managed cloud platform that offers hassle-free and high-performance hosting experience. With 24/7 support and a range of features, Cloudways is a great choice for any hosting needs.