Simplifying Connected Props with Redux and TypeScript

Updated on · 4 min read
Simplifying Connected Props with Redux and TypeScript

If you're a web developer who works with TypeScript or JavaScript and React, you're probably familiar with Redux. While Redux is a great tool for managing state in large React applications, it can be challenging to use with TypeScript. In particular, dealing with connected components can involve a lot of manual type declarations, which can be tedious and error-prone. In this post, we'll explore how to simplify connected props with Redux and TypeScript using automatic type inference. We'll walk through a concrete example that demonstrates how to apply this technique to your own React Redux projects.

Getting started

When working with Redux-connected components in React, there can be up to three sources of props: props passed from the parent component (so-called own props), props returned from mapStateToProps, and props returned from mapDispatchToProps.

If you're using TypeScript, all of these props need to have types defined, including the state if it's a stateful class-based component. This can be a tedious and error-prone process that needs to be maintained in the future.

Fortunately, starting from version 7.1.2 of the @types/react-redux package, it's possible to automatically infer types for connected props in most cases. You can learn more about this in the React Redux documentation. In this post, we'll walk through a concrete example of how to apply this technique.

We'll be refactoring a sample App component, which fetches a list of items via a Redux action and renders the list. The component also receives URL parameters via React Router as props.

ts
// types.ts export type Item = { id: number; text: string; }; export type AppState = { loading: boolean; data: Item[]; };
ts
// types.ts export type Item = { id: number; text: string; }; export type AppState = { loading: boolean; data: Item[]; };
ts
// 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 }
ts
// 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 }
tsx
// 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
// 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 in the example above, we use typeof to infer the types of actions, and the types in mapStateToProps are a combination of AppState and OwnProps types. This can be a lot of manual type declaration for types that are already available elsewhere. Instead of doing all of this by hand, we can use the available type information to automatically infer the component props.

Simplifying Redux types with ConnectedProps

Another issue with the example is that the dispatched actions return a function of type ThunkAction, which in turn returns void. When connecting the component to Redux and running TypeScript in strict mode, you may encounter an error like this:

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

This is where the ConnectedProps helper type comes in handy. It allows us to infer connected types from mapStateToProps and mapDispatchToProps, and correctly resolves types for thunks. To use ConnectedProps, let's move mapStateToProps and mapDispatchToProps to the top of the file and strip them of all the generic type declarations. We also remove the ConnectedProps and DispatchProps interfaces we defined earlier, 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'll do this before declaring the component, as we'll use this function when creating its Props type.

tsx
const connector = connect(mapStateToProps, mapDispatchToProps);
tsx
const connector = connect(mapStateToProps, mapDispatchToProps);

Now, we can use the ConnectedProps helper to extract the types of connected props.

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 the component's own props to create the Props type for the component and export the component wrapped in the connector function, we created earlier.

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

By using the ConnectedProps helper, we've simplified our component by getting rid of the manual declaration of the props received from Redux. These props 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.

If you're interested in improving the types for your React components, you may find this article helpful: TypeScript: Typing React UseRef Hook.

Conclusion

In this post, we've explored how to simplify connected props with Redux and TypeScript by using the ConnectedProps helper type. By automatically inferring the types of connected props from mapStateToProps and mapDispatchToProps, we can avoid the tedious and error-prone process of the manual type declaration. This not only improves the maintainability of the app but also helps fix issues with incorrectly inferred Redux thunk action return types.

As a web developer working with TypeScript or JavaScript and React, mastering Redux can greatly enhance your ability to manage state in complex applications. Hopefully, this post has provided you with some useful tips and tricks for using Redux with TypeScript, and you'll be able to apply these techniques to your own projects.

References and resources