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