← Back to Blog

Notes for Modern Redux with Redux Toolkit (RTK) and TypeScript

These are the notes I wrote while following along with Modern Redux with Redux Toolkit (RTK) and TypeScript by Jamund Ferguson

Creating a RootState type and Typed Hooks for Type-Aware Redux Interactions

1export const store = configureStore({
2 reducer: {
3 cart: cartReducer,
4 products: productsReducer,
5 },
6})
7
8// ReturnType is a utility to help us type our reducers
9export type RootState = ReturnType<typeof store.getState>
10export type AppDispatch = typeof store.dispatch
11

Redux provides useSelector and useDispatch hooks, but in order to type them we need to wrap the selector and dispatch functions to allow them to accept our types

import {TypedUseSelectorHook, useSelector, useDispatch} from 'react-redux'
import type {RootState, AppDispatch} from './store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

Access Redux Data in a Component with the TypeScript-enabled useAppSelector hook

useAppSelector now works just like Redux's useSelector, but contains type information to help us autocomplete our selectors.

import {useAppSelector, useAppDispatch} from '../../app/hooks'
import {receivedProducts} from './productSlice'
export function Products() {
const products = useAppSelector((state) => state.products.products)
return (
<ul>
{Object.values(products).map((product) => (
<li key={product.id}> {product.name} </li>
))}
</ul>
)
}

Create a Reducer with Redux Toolkit and Dispatch its Action with the useAppDispatch hook

const initialState: ProductsState = {
products: {},
}
const productsSlice = createSlice({
initialState,
name: 'products',
reducers: {
receivedProducts(state, action: PayloadAction<Product[]>) {
const products = action.payload
products.forEach((product) => {
state.products[product.id] = product
})
},
},
})
// RTK automatically generates action creators for each of the reducers we define
export const {receivedProducts} = productsSlice.actions
export default productsSlice.reducer

Instead of using setState to set the products, use the dispatch hook to dispatch the action to the store.

import {useAppDispatch} from '../../app/hooks'
import {receivedProducts} from './productSlice'
export function Products() {
const dispatch = useAppDispatch()
React.useEffect(() => {
getProducts().then((products) => {
dispatch(receivedProducts(products))
})
})
}

Building a Reducer Method to add an Item to the Shopping Cart

1import {createSlice, PayloadAction} from '@reduxjs/toolkit'
2
3export interface CartState {
4 items: {
5 [id: string]: number
6 }
7}
8
9const initialState: CartState = {
10 items: {},
11}
12
13const cartSlice = createSlice({
14 name: 'cart',
15 initialState,
16 reducers: {
17 addToCart(state, action: PayloadAction<string>) {
18 const id = action.payload
19
20 if (state.items[id]) {
21 state.items[id]++
22 } else {
23 state.items[id] = 1
24 }
25 },
26 },
27})
28
29export const {addToCart} = cartSlice.actions
30export default cartSlice.reducer
31

I'm not sure I know what a Slice is yet

Now as we click on items in the list, the reducer will add them to the cart.

import {useAppSelector, useAppDispatch} from '../../app/hooks'
import {receivedProducts} from './productSlice'
import {addToCart} from './cartSlice'
export function Products() {
const products = useAppSelector((state) => state.products.products)
const dispatch = useAppDispatch()
return (
<ul>
{Object.values(products).map((product) => (
<li key={product.id}>
<button onClick={() => dispatch(addToCart(product.id))}>
Add {product.name} to cart
</button>
</li>
))}
</ul>
)
}

Create a Selector to Aggregate Data from our Redux Store

1import {RootState} from './store'
2
3export function getNumberOfItems(state: RootState) {
4 return Object.values(state.cart.items).reduce((sum, count) => sum + count, 0)
5}
6
1export function CartLink() {
2 const numberOfItems = useAppSelector(getNumberOfItems)
3
4 return (
5 <Link to="/cart">
6 <span>{numberOfItems ? numberOfItems : 'Cart'}</span>
7 </Link>
8 )
9}
10

Use createSelector from Redux Toolkit to build a Memoized Selector

Look at this line here

const numberOfItems = useAppSelector(getNumberOfItems)

This will run again every time the CartLink component is rendered. We can use createSelector to create a memoized selector that will only run once.

1export const getMemoizedNumberOfItems = createSelector(
2 (state: RootState) => state.cart.items,
3 (items) => Object.values(items).reduce((sum, count) => sum + count, 0),
4)
5

This will only re-run when state.cart.items changes. In this case, it's a premature optimization, but it's important to know the pattern for when more intensive selectors are being used.

1export function CartLink() {
2 const numberOfItems = useAppSelector(getMemoizedNumberOfItems)
3
4 return (
5 <Link to="/cart">
6 <span>{numberOfItems ? numberOfItems : 'Cart'}</span>
7 </Link>
8 )
9}
10

Combining Data from Two Redux Slices to Build our Shopping Cart

import {useAppSelector} from '../../app/hooks'
export function Cart() {
const products = useAppSelector((state) => state.products.products)
const items = useAppSelector((state) => state.cart.items)
return (
<ul>
{Object.entries(items).map(([id, quantity]) => (
<li key={products[id].name}>
<span>
There are {quantity} {products[id].name}s in your cart
</span>
<button>Remove {products[id].name} from cart</button>
</li>
))}
</ul>
)
}

Aggregate Price Information From Two Different Slices with createSelector

If you pass two arguments to createSelector, the first argument is a function that takes the state and returns the first slice.

The second argument is a function that takes the state and returns the second slice.

The third argument is a function that takes the first and second slices and returns a new slice.

1export const getTotalPrice = createSelector(
2 (state: RootState) => state.cart.items,
3 (state: RootState) => state.products.products,
4 (items, products) => {
5 return Object.values(items).reduce(
6 (sum, item) => sum + products[item.id].price * item.quantity,
7 0,
8 )
9 },
10)
11
1import {useAppSelector} from '../../app/hooks'
2import {getTotalPrice} from './cartSlice'
3
4export function Cart() {
5 const totalPrice = useAppSelector(getTotalPrice)
6
7 return (
8 <div>
9 <span> Total Price: ${totalPrice} </span>
10 </div>
11 )
12}
13

How to Apply Types to Redux Selectors

Even with just regular type inference, the IDE is already able to infer the types of getTotalPrice

But you can also apply types to selectors.

const getTotalPrice = createSelector<RootState, any, any, string>()

Adding a Button that Dispatches an Action To Redux to Remove an Item from the ShoppingCart

1const cartSlice = createSlice({
2 name: 'cart',
3 initialState,
4 reducers: {
5 addToCart(state, action: PayloadAction<string>) {
6 const id = action.payload
7
8 if (state.items[id]) {
9 state.items[id]++
10 } else {
11 state.items[id] = 1
12 }
13 },
14
15 removeAllFromCart(state, action: PayloadAction<string>) {
16 const id = action.payload
17
18 if (state.items[id]) {
19 delete state.items[id]
20 }
21 },
22 },
23})
24
25export const {addToCart, removeAllFromCart} = cartSlice.actions
26
import {useAppSelector, useAppDispatch} from '../../app/hooks'
import {removeAllFromCart} from './cartSlice'
export function Cart() {
const products = useAppSelector((state) => state.products.products)
const items = useAppSelector((state) => state.cart.items)
const dispatch = useAppDispatch()
return (
<ul>
{Object.entries(items).map(([id, quantity]) => (
<li key={products[id].name}>
<span>
There are {quantity} {products[id].name}s in your cart
</span>
<button onClick={() => dispatch(removeAllFromCart(id))}>
Remove {products[id].name} from cart
</button>
</li>
))}
</ul>
)
}

Dispatching Actions to Redux when an Input Field Fires its Blur Event with TypeScript

1const cartSlice = createSlice({
2 name: 'cart',
3 initialState,
4 reducers: {
5 addToCart,
6 removeAllFromCart,
7 updateQuantity(
8 state,
9 action: PayloadAction<{
10 id: string
11 quantity: number
12 }>,
13 ) {
14 const {id, quantity} = action.payload
15
16 state.items[id] = quantity
17 },
18 },
19})
20
21export const {addToCart, removeAllFromCart, updateQuantity} = cartSlice.actions
22
import {useAppSelector, useAppDispatch} from '../../app/hooks'
import {updateQuantity} from './cartSlice'
export function Cart() {
const products = useAppSelector((state) => state.products.products)
const items = useAppSelector((state) => state.cart.items)
const dispatch = useAppDispatch()
function onQuantityChanged(
e: React.FocusEvent<HTMLInputElement>,
id: string,
) {
const quantity = parseInt(e.target.value, 10)
dispatch(updateQuantity({id, quantity}))
}
return (
<ul>
{Object.entries(items).map(([id, quantity]) => (
<li key={products[id].name}>
<input
type="text"
defaultValue={quantity}
onBlur={(e) => onQuantityChanged(e, id)}
/>
</li>
))}
</ul>
)
}

Using TypeScript and Redux to Model the Different States of our Checkout Form

1type CheckoutState = 'LOADING' | 'READY' | 'ERROR'
2
3export interface CartState {
4 items: { [productId: string]: number }
5 checkoutState: CheckoutState
6}
7
8const intialState: CartState = {
9 items: {}
10 initialCheckoutState: 'READY',
11}
12
import {useAppSelector, useAppDispatch} from '../../app/hooks'
import {updateQuantity} from './cartSlice'
export function Cart() {
const products = useAppSelector((state) => state.products.products)
const items = useAppSelector((state) => state.cart.items)
const dispatch = useAppDispatch()
const checkoutState = useAppSelector((state) => state.cart.checkoutState)
if (checkoutState === 'LOADING') {
return (
<div>
<p> Loading... </p>
</div>
)
}
if (checkoutState === 'ERROR') {
return (
<div>
<p> Error! </p>
</div>
)
}
if (checkoutState === 'READY') {
return (
<ul>
{Object.entries(items).map(([id, quantity]) => (
<li key={products[id].name}>
<input type="text" defaultValue={quantity} />
</li>
))}
</ul>
)
}
throw new Error('Unknown checkout state: ' + checkoutState)
}

Using createAsyncThunk and the builder API to Generate Redux Actions for an API call

createAsyncThunk generates three actions

  • checkoutCart.pending
  • checkoutCart.fulfilled
  • checkoutCart.rejected

and these are automatically dispatched to Redux based on the lifecycle of the Promise in the second argument

export const checkoutCart = createAsyncThunk(
'cart/checkout', // the cart slice and the checkout action name
async (items: CartItems) => {
const response = await checkout(items)
return response
},
)
const cartSlice = createSlice({
name: 'cart',
initialState,
// These are defined inline, so we use `reducers`
reducers: {
addToCart,
removeAllFromCart,
updateQuantity,
},
// The checkoutCart thunk already exists, so we use `extraReducers` to imperatively attach it to this slice
extraReducers(builder) {
// The builder methods tell redux to update the state (argument 2) when an action occurs (argument 1)
builder.addCase(checkoutCart.pending, (state) => {
state.checkoutState = 'LOADING'
})
builder.addCase(checkoutCart.fulfilled, (state) => {
state.checkoutState = 'READY'
})
builder.addCase(checkoutCart.rejected, (state) => {
state.checkoutState = 'ERROR'
})
},
})
1import {useAppDispatch} from '../../app/hooks'
2import {checkoutCart} from './cartSlice'
3
4export function Cart() {
5 const dispatch = useAppDispatch()
6
7 function onCheckout(e: React.FormEvent<HTMLFormElement>) {
8 e.preventDefault()
9
10 dispatch(checkoutCart(items))
11 }
12
13 return (
14 <form onSubmit={onCheckout}>
15 <button type="submit"> Checkout </button>
16 </form>
17 )
18}
19

Handling Errors in Async Thunks with builder.addCase()

1export interface CartState {
2 items: { [productId: string]: number }
3 checkoutState: CheckoutState,
4 errorMessage: string
5}
6
7const intialState: CartState = {
8 items: {}
9 initialCheckoutState: 'READY',
10 errorMessage: ''
11}
12

The payload of the action for checkoutCart.rejected contains the error

builder.addCase(checkoutCart.rejected, (state, action) => {
state.checkoutState = 'ERROR'
state.errorMessage = action.error.message || ''
})
1import {useAppSelector} from '../../app/hooks'
2import {checkoutCart} from './cartSlice'
3
4export function Cart() {
5 const checkoutState = useAppSelector((state) => state.cart.checkoutState)
6 const errorMessage = useAppSelector((state) => state.cart.errorMessage)
7
8 return (
9 <form onSubmit={onCheckout}>
10 {checkoutState === 'ERROR' && errorMessage ? (
11 <p> {errorMessage} </p>
12 ) : null}
13 <button type="submit"> Checkout </button>
14 </form>
15 )
16}
17

Using the Response from an Async Thunk to Update the Redux State

The action of checkoutCart.fulfilled is dispatched when the Promise returned by checkoutCart is fulfilled. The payload of this action is the response from the API call.

1builder.addCase(
2 checkoutCart.fulfilled,
3 (state, action: PayloadAction<{success: boolean}>) => {
4 const {success} = action.payload
5 if (success) {
6 state.checkoutState = 'READY'
7 state.items = {}
8 } else {
9 state.checkoutState = 'ERROR'
10 }
11 },
12)
13

Accessing Global State inside of Async Thunks with TypeScript

So far we've been submitting onCheckout like dispatch(checkoutCart(items)) with items passed as an argument.

We can modify the checkoutCart thunk to use global state instead of this argument

The IDE was able to infer the type of items because it knew the type of the items we were passing in as an argument. If we're no longer passing that in as an argument, we'll have to manually tell the IDE to use as RootState

1export const checkoutCart = createAsyncThunk(
2 'cart/checkout',
3 async (_, thunkApi) => {
4 const state = thunkApi.getState() as RootState
5 const items = state.cart.items
6
7 const response = await checkout(items)
8
9 return response
10 },
11)
12
1function onCheckout(e: React.FormEvent<HTMLFormElement>) {
2 e.preventDefault()
3
4 dispatch(checkoutCart())
5}
6