Advanced State Management in React with Redux Toolkit

Learn Redux Toolkit with a real-world project: build a shopping cart with tips and tricks + bonus.

By Gennaro Nucaro

  • React, Front-end

Introduction

Redux Toolkit is a powerful library for state management in React applications. In today's tutorial, we'll explore how to implement a shopping cart for an e-commerce site, illustrating step by step how to use this library effectively.

A Bit of Theory

Redux is a state management library for JavaScript applications, commonly used with React but also compatible with other frameworks. Redux Toolkit is a library specifically designed to simplify the configuration and usage of Redux in React.

It is a library designed for global state management in applications, which allows access to the state from any component in the application. From any component, actions can be dispatched to modify the state, even if they are on different pages.

The main components in a Redux app are:

  1. Store: Represents the container of the global state of the application.
  2. Actions: These are objects that send data from the application to the store.
  3. Reducers: These are pure functions that accept the current state and an action to return a new state.
  4. Dispatch Function: The dispatch function is the method used to send actions to the store.
  5. Selectors: Selectors are functions that extract and transform data from the Redux state.

Initial Setup

Development Environment Setup

We will start with a template that I have already prepared, complete with CSS, components, and libraries, focusing exclusively on Redux Toolkit, ready to use. The template is a classic application built in React: it is a small e-commerce with two pages, "Home" and "Cart." On the "Home" page, there is a list of items to add to the cart. On the "Cart" page, we have the actual shopping cart, where you can add, reduce, or remove items, update the cart, or clear its contents.

How to proceed: you can download the template and follow the development step by step with the guide, or you can visit the branch shopping-cart-redux-implementation and review the commits.

You can find the project on my GitHub profile.

To download the template:

git clone https://github.com/Gennaro-Nucaro/redux-toolkit-shopping-cart.git

Installing Required Dependencies: Redux Toolkit and React-Redux

How to install Redux Toolkit:

npm install @reduxjs/toolkit react-redux

Creating Redux Store

Create a folder called store inside the srcdirectory. Next, create the file index.ts inside this folder.

src/store/index.ts
1import { configureStore } from "@reduxjs/toolkit";
2
3export const store = configureStore({
4    reducer: {},
5})
6
7export type RootState = ReturnType<typeof store.getState>
8export type AppDispatch = typeof store.dispatch

Here we configure the store, where we will place our reducers. TheRootState type represents the structure of the state in our store, while AppDispatch is the type used for dispatches.

Connecting the Store to React

After configuring the store, let's connect it to our React application.

src/index.tsx
1import React from "react";
2import ReactDOM from "react-dom/client";
3import "./index.css";
4import App from "./App";
5import "bootstrap/dist/css/bootstrap.min.css";
6import "bootstrap-icons/font/bootstrap-icons.css";
7import { store } from "./store";
8import { Provider } from "react-redux";
9
10const root = ReactDOM.createRoot(
11  document.getElementById("root") as HTMLElement
12);
13root.render(
14  <React.StrictMode>
15    <Provider store={store}>
16      <App />
17    </Provider>
18  </React.StrictMode>
19);

Creating a State Slice with Redux Toolkit

After creating the store, let's move on to creating a slice. A slice in Redux Toolkit represents a section of the application's state, where our specific state will be stored. To create a slice, we use the createSlice function with the following properties:

  • Name: The name given to the piece of state, in this case 'cart'.
  • InitialState: An object that defines the initial state.
  • Reducers: A property that contains our reducers, the functions responsible for modifying the state.

Inside the store folder, create a subfolder called cart and inside it, the cartSlice.ts file.

src/store/cart/cartSlice.ts
1import { createSlice } from "@reduxjs/toolkit";
2
3export interface Item {
4  id: number;
5  name: string;
6  price: number;
7  qty: number;
8}
9
10export interface CartState {
11  items: Item[];
12  total: number;
13  amount: number;
14}
15
16const initialState: CartState = {
17  items: [],
18  total: 0,
19  amount: 0,
20};
21
22export const cartSlice = createSlice({
23  name: "cart",
24  initialState,
25  reducers: {},
26});
27
28// export const { } = cartSlice.actions
29
30export default cartSlice.reducer;

After creating the slice, export the reducer cartSlice.reducer and add it to the configureStore located in store/index.ts.

src/store/index.ts
1import { configureStore } from "@reduxjs/toolkit";
2import cartReducer from "./cart/cartSlice"
3
4export const store = configureStore({
5    reducer: {
6        cart: cartReducer,
7    },
8})
9
10export type RootState = ReturnType<typeof store.getState>
11export type AppDispatch = typeof store.dispatch

Implementing Features

Implementing Actions

After completing the setup, we can proceed with creating our CRUD logic. We'll start with actions. Actions are functions that receive data as a parameter (through action.payload) and are essential in reducers. Thanks to Redux Toolkit, we can define our actions directly within the slice, simplifying state management.

src/store/cart/cartSlice.ts
1import { createSlice } from "@reduxjs/toolkit";
2import type { PayloadAction } from "@reduxjs/toolkit";
3
4export interface Item {
5  id: number;
6  name: string;
7  price: number;
8  qty: number;
9}
10
11export interface CartState {
12  items: Item[];
13  total: number;
14  amount: number;
15}
16
17const initialState: CartState = {
18  items: [],
19  total: 0,
20  amount: 0,
21};
22
23export const cartSlice = createSlice({
24  name: "cart",
25  initialState,
26  reducers: {
27    addItem: (state, action: PayloadAction<Item>) => {
28      const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);
29
30      if (existingItemIndex !== -1) {
31        // If the item exists, only increment its quantity
32        state.items[existingItemIndex].qty += 1;
33      } else {
34        // Add the item
35        state.items.push(action.payload);
36      }
37
38      // Update the total and amount
39      state.total = state.items.reduce((total, item) => total + item.qty, 0);
40      state.amount = state.items.reduce((amount, item) => amount + (item.price * item.qty), 0);
41    },
42  },
43});
44
45export const { addItem } = cartSlice.actions;
46
47export default cartSlice.reducer;

To type the action parameter, we use the PayloadAction<Item> type from the Redux Toolkit library. After that, we export our action with export const { addItem } = cartSlice.actions;, which can be used anywhere in the app.

It's important to know that within reducers, the state we work with is immutable: we operate on a sort of copy of the original state. This happens because Redux Toolkit uses the library immer.js, which allows us to modify the state safely, avoiding undesirable side effects.

TIPS:
Since we use immer.js in reducers, if we try to do a console.log(state) inside reducers, we will get an object with an unusual structure. To correctly view the object, use the current function from Redux Toolkit like this: console.log(current(state));

Using State and Actions in Components

Now, we will export our state and action within our app. We'll use the useSelector hook to retrieve the state and the useDispatch hook to dispatch actions.

useSelector

Let's start by using the useSelector hook to spread the Redux state throughout the app.

The RootState type, which we defined in store/index.ts, is used in selectors.

Apply the following changes:

src/pages/Cart.tsx
1import CartItemListView from "../components/CartItemListView";
2import { useSelector } from "react-redux";
3import { RootState } from "../store";
4
5function Cart() {
6  const amount = useSelector((state: RootState) => state.cart.amount);
7  const total = useSelector((state: RootState) => state.cart.total);
8
9  return (
10    <div className="m-2">
11      <h1 className="text-center">Your Cart</h1>
12      <CartItemListView />
13      <div className="d-flex justify-content-center align-items-center gap-5 p-4">
14        <p className="fs-3 m-0">
15          Total ({total} items): €{amount.toFixed(2)}
16        </p>
17        <div className="d-flex align-items-center button-container gap-1">
18          <button type="button" className="btn btn-success flex-shrink-0 fs-5">
19            Save Cart
20          </button>
21          <button type="button" className="btn btn-danger flex-shrink-0 fs-5">
22            Delete all
23          </button>
24        </div>
25      </div>
26    </div>
27  );
28}
29
30export default Cart;

TIPS:
Be careful when using useSelector in this way.

const { name, password, email, phone, address } = useSelector((state: RootState) => state.user);

Updating even just one of these properties could cause a re-render of other components that use them, leading to performance issues. It is recommended to use a specific selector for each piece of state in order to minimize re-renders.

src/components/Navbar.tsx
1import { Link } from "react-router-dom";
2import { RootState } from "../store";
3import { useSelector } from "react-redux";
4
5const Navbar = () => {
6  const total = useSelector((state: RootState) => state.cart.total);
7
8  return (
9    <nav className="navbar bg-primary" data-bs-theme="dark">
10      <div className="container">
11        <Link className="navbar-brand fs-3" to="/">
12          Shopping Cart
13        </Link>
14        <Link
15          className="navbar-brand d-flex align-items-center gap-1"
16          to="/cart"
17        >
18          <i className="bi bi-cart large-icon" />
19          <span>{total ? total : ""}</span>
20        </Link>
21      </div>
22    </nav>
23  );
24};
25
26export default Navbar;
src/components/CartItemListView.tsx
1import { useSelector } from "react-redux";
2import { RootState } from "../store";
3
4const CartItemListView = () => {
5  const items = useSelector((state: RootState) => state.cart.items);
6
7  return (
8    <div className="d-flex flex-column flex-md-row gap-4 align-items-center justify-content-center">
9      <ul className="list-group cart-item-list-view">
10        {items.map((item) => {
11          return (
12            <li
13              key={item.id}
14              className="list-group-item item d-flex justify-content-between align-items-center fs-5"
15            >
16              <div>
17                <span>{item.name}</span>
18                <span className="text-secondary p-2 fs-6">{item.price}</span>
19              </div>
20              <div className="d-flex align-items-baseline button-container gap-1">
21                <span className="d-inline p-2">Qty. {item.qty}</span>
22                <button type="button" className="btn btn-warning fs-5">
23                  -
24                </button>
25                <button type="button" className="btn btn-success fs-5">
26                  +
27                </button>
28                <button type="button" className="btn btn-danger fs-5">
29                  remove
30                </button>
31              </div>
32            </li>
33          );
34        })}
35      </ul>
36    </div>
37  );
38};
39
40export default CartItemListView;

You can also create selectors this way: you create a separate function and then use it with useSelector.

1export const itemsSelector = ({ cart }: RootState) => cart.items;
2...
3const items = useSelector(itemsSelector);

In this way, you can apply logic before returning the state.

A powerful tool that we won't cover in our cart app tutorial is createSelector. It is a Redux Toolkit function capable of creating advanced selectors that are memoized.

1import { createSelector } from '@reduxjs/toolkit';
2
3const selectProducts = state => state.products;
4const selectCartItems = state => state.cart.items;
5
6const selectTotal = createSelector(
7  [selectProducts, selectCartItems],
8  (products, cartItems) => {
9    return cartItems.reduce((total, itemId) => {
10      const product = products.find(product => product.id === itemId);
11      return total + product.price;
12    }, 0);
13  }
14);
15...
16const total = useSelector(selectTotal);

useDispatch

After using our selectors, let's move on to using the useDispatch hook to trigger the addItem action we created earlier.

src/components/ItemList.tsx
1import { useDispatch } from "react-redux";
2import { addItem } from "../store/cart/cartSlice";
3
4const data = {
5  items: [
6    {
7      id: 1,
8      name: "Google Pixel 9",
9      price: 899.99,
10    },
11    {
12      id: 2,
13      name: "Apple iPhone 15",
14      price: 769.99,
15    },
16    {
17      id: 3,
18      name: "Samsung Galaxy S23",
19      price: 699.99,
20    },
21    {
22      id: 4,
23      name: "Xiaomi 14",
24      price: 709.99,
25    },
26  ],
27};
28
29const Itemlist = () => {
30  const dispatch = useDispatch();
31
32  return (
33    <div className="d-flex flex-column flex-md-row p-4 gap-4 py-md-5 align-items-center justify-content-center">
34      <ul className="list-group">
35        {data.items.map((item) => {
36          return (
37            <li
38              key={item.id}
39              className="list-group-item item d-flex justify-content-between align-items-center fs-5"
40            >
41              <div>
42                {item.name}
43                <span className="text-secondary p-2 fs-6">{item.price}</span>
44              </div>
45
46              <button
47                onClick={() =>
48                  dispatch(
49                    addItem({
50                      ...item,
51                      qty: 1,
52                    })
53                  )
54                }
55                type="button"
56                className="btn btn-primary"
57              >
58                Add to cart
59              </button>
60            </li>
61          );
62        })}
63      </ul>
64    </div>
65  );
66};
67
68export default Itemlist;

As we have seen, we exported our state and actions throughout the app. Even at this point, we can already see the power of this library, and we have only just started. Later, we will explore other interesting features.

Creating All Other CRUD Actions

Let's create the actions. Afterward, we'll use these actions in the various components of the application.

src/store/cart/cartSlice.ts
1import { createSlice } from "@reduxjs/toolkit";
2import type { PayloadAction } from "@reduxjs/toolkit";
3
4export interface Item {
5  id: number;
6  name: string;
7  price: number;
8  qty: number;
9}
10
11export interface CartState {
12  items: Item[];
13  total: number;
14  amount: number;
15}
16
17const initialState: CartState = {
18  items: [],
19  total: 0,
20  amount: 0,
21};
22
23// Helper function to update total items and total amount
24function updateTotals(state: CartState) {
25  state.total = state.items.reduce((total, item) => total + item.qty, 0);
26  state.amount = state.items.reduce((amount, item) => amount + item.price * item.qty, 0);
27}
28
29export const cartSlice = createSlice({
30  name: "cart",
31  initialState,
32  reducers: {
33    // Adds an item to the cart or increments its quantity if it already exists
34    addItem: (state, action: PayloadAction<Item>) => {
35      const existingItemIndex = state.items.findIndex((item) => item.id === action.payload.id);
36
37      if (existingItemIndex !== -1) {
38        // If the item exists, only increment its quantity
39        state.items[existingItemIndex].qty += 1;
40      } else {
41        // Add the item
42        state.items.push(action.payload);
43      }
44
45      updateTotals(state);
46    },
47    // Removes one unit of an item or the item itself if quantity is 1
48    removeItem: (state, action: PayloadAction<number>) => {
49      const existingItemIndex = state.items.findIndex((item) => item.id === action.payload);
50      if (existingItemIndex !== -1) {
51        // Decrease or remove item based on quantity
52        if (state.items[existingItemIndex].qty > 1) {
53          state.items[existingItemIndex].qty -= 1;
54        } else {
55          state.items.splice(existingItemIndex, 1);
56        }
57      }
58      updateTotals(state);
59    },
60    // Removes all units of a specific item from the cart
61    removeAll: (state, action: PayloadAction<number>) => {
62      state.items = state.items.filter((item) => item.id !== action.payload);
63      updateTotals(state);
64    },
65    // Completely clears the cart
66    deleteCart: (state) => {
67      state.items = [];
68      state.total = 0;
69      state.amount = 0;
70    },
71  },
72});
73
74export const { addItem, removeItem, removeAll, deleteCart } = cartSlice.actions;
75export default cartSlice.reducer;
src/components/CartItemListView.tsx
1import { useSelector } from "react-redux";
2import { RootState } from "../store";
3import { useDispatch } from "react-redux";
4import { addItem, removeAll, removeItem } from "../store/cart/cartSlice";
5
6const CartItemListView = () => {
7  const items = useSelector((state: RootState) => state.cart.items);
8  const dispatch = useDispatch();
9
10  return (
11    <div className="d-flex flex-column flex-md-row gap-4 align-items-center justify-content-center">
12      <ul className="list-group cart-item-list-view">
13        {items.map((item) => {
14          return (
15            <li
16              key={item.id}
17              className="list-group-item item d-flex justify-content-between align-items-center fs-5"
18            >
19              <div>
20                <span>{item.name}</span>
21                <span className="text-secondary p-2 fs-6">{item.price}</span>
22              </div>
23              <div className="d-flex align-items-baseline button-container gap-1">
24                <span className="d-inline p-2">Qty. {item.qty}</span>
25                <button
26                  onClick={() => {
27                    dispatch(removeItem(item.id));
28                  }}
29                  type="button"
30                  className="btn btn-warning fs-5"
31                >
32                  -
33                </button>
34                <button
35                  onClick={() => {
36                    dispatch(addItem({ ...item, qty: 1 }));
37                  }}
38                  type="button"
39                  className="btn btn-success fs-5"
40                >
41                  +
42                </button>
43                <button
44                  onClick={() => {
45                    dispatch(removeAll(item.id));
46                  }}
47                  type="button"
48                  className="btn btn-danger fs-5"
49                >
50                  remove
51                </button>
52              </div>
53            </li>
54          );
55        })}
56      </ul>
57    </div>
58  );
59};
60
61export default CartItemListView;
src/pages/Cart.tsx
1import CartItemListView from "../components/CartItemListView";
2import { useSelector } from "react-redux";
3import { RootState } from "../store";
4import { useDispatch } from "react-redux";
5import { deleteCart } from "../store/cart/cartSlice";
6
7function Cart() {
8  const amount = useSelector((state: RootState) => state.cart.amount);
9  const total = useSelector((state: RootState) => state.cart.total);
10  const dispatch = useDispatch();
11
12  return (
13    <div className="m-2">
14      <h1 className="text-center">Your Cart</h1>
15      <CartItemListView />
16      <div className="d-flex justify-content-center align-items-center gap-5 p-4">
17        <p className="fs-3 m-0">
18          Total ({total} items): €{amount.toFixed(2)}
19        </p>
20        <div className="d-flex align-items-center button-container gap-1">
21          <button type="button" className="btn btn-success flex-shrink-0 fs-5">
22            Save Cart
23          </button>
24          <button
25            onClick={() => {
26              dispatch(deleteCart());
27            }}
28            type="button"
29            className="btn btn-danger flex-shrink-0 fs-5"
30          >
31            Delete all
32          </button>
33        </div>
34      </div>
35    </div>
36  );
37}
38
39export default Cart;

TIPS: Refactoring the Slice with createAction and extraReducers

You might have noticed that there's a lot of code in our slice; this might be fine for now, but in a real application, we may need many more actions. This could become confusing, at least from my point of view, which is just a personal opinion. A solution I suggest is to create the actions and reducers separately and then import them into our slice. This way, we will have a clearer separation of logic and cleaner code.

createAction

createAction allows you to create an action with a specified type outside of the slice, preparing any payload to attach to the action. It returns an action object that includes a type field and optionally a payload field.

Let's create a file actions.ts in src/store/cart.

Inside actions.ts, let's create our actions using createAction like this:

src/store/cart/actions.ts
1import { createAction } from "@reduxjs/toolkit";
2import { Item } from "./cartSlice";
3
4export const addItem = createAction<Item>("cart/addItem");
5export const removeItem = createAction<number>("cart/removeItem");
6export const removeAll = createAction<number>("cart/removeAll");
7export const deleteCart = createAction("cart/deleteCart");

You can also create actions this way, which is particularly useful when you want to incorporate specific logic before dispatching the action:

1export const addItem = createAction("cart/addItem", (item: Item) => {
2  return {
3    payload: item,
4  };
5});

Let's create a reducers.ts file in src/store/cart. Here we will move our reducers. The reducers are already created; we just need to move them from the slice and place them in this file.

src/store/cart/reducers.ts
1import { PayloadAction } from "@reduxjs/toolkit";
2import { CartState, Item } from "./cartSlice";
3
4const updateTotals = (state: CartState) => {
5    state.total = state.items.reduce((total, item) => total + item.qty, 0);
6    state.amount = state.items.reduce((amount, item) => amount + (item.price * item.qty), 0);
7};
8
9export const addItemReducer = (state: CartState, action: PayloadAction<Item>) => {
10    const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);
11
12    if (existingItemIndex !== -1) {
13        state.items[existingItemIndex].qty += 1;
14    } else {
15        state.items.push(action.payload);
16    }
17
18    updateTotals(state);
19};
20
21export const removeItemReducer = (state: CartState, action: PayloadAction<number>) => {
22    const existingItemIndex = state.items.findIndex(item => item.id === action.payload);
23    if (existingItemIndex !== -1) {
24        if (state.items[existingItemIndex].qty > 1) {
25            state.items[existingItemIndex].qty -= 1;
26        } else {
27            state.items.splice(existingItemIndex, 1);
28        }
29    }
30    updateTotals(state);
31};
32
33export const removeAllReducer = (state: CartState, action: PayloadAction<number>) => {
34    state.items = state.items.filter(item => item.id !== action.payload);
35    updateTotals(state);
36};
37
38export const deleteCartReducer = (state: CartState) => {
39    state.items = [];
40    state.total = 0;
41    state.amount = 0;
42};

extraReducers

extraReducers in Redux Toolkit allows a slice to respond to actions that were not created directly within the slice itself. It is particularly useful for making the slice react to actions defined elsewhere in the application.

Now we just need to update the slice.

We will add our actions and reducers to the extraReducers section of createSlice, as this is external logic. Using the builder in extraReducers is very useful; for now, we'll only use addCase to add our actions. Later, we will explore other features that can be implemented with the builder.

src/store/cart/cartSlice.ts
1import { createSlice } from "@reduxjs/toolkit";
2import { addItemReducer, deleteCartReducer, removeAllReducer, removeItemReducer } from "./reducers";
3import { addItem, removeItem, removeAll, deleteCart } from "./actions";
4
5export interface Item {
6  id: number;
7  name: string;
8  price: number;
9  qty: number;
10}
11
12export interface CartState {
13  items: Item[];
14  total: number;
15  amount: number;
16}
17
18const initialState: CartState = {
19  items: [],
20  total: 0,
21  amount: 0,
22};
23
24export const cartSlice = createSlice({
25  name: "cart",
26  initialState,
27  reducers: {},
28  extraReducers: (builder) => {
29    builder
30      .addCase(addItem, addItemReducer)
31      .addCase(removeItem, removeItemReducer)
32      .addCase(removeAll, removeAllReducer)
33      .addCase(deleteCart, deleteCartReducer)
34  },
35});
36
37export default cartSlice.reducer;

It is now important to update the imports of the actions in the components, since we have moved the actions and they are no longer in cartSlice.ts.

If you're feeling lazy, you can use this in cartSlice.ts without updating the imports in the components.

export { addItem, removeItem, removeAll, deleteCart };

How to Create Async Actions with createAsyncThunk

In a real project using Redux Toolkit, there may be asynchronous actions, which are actions that modify the state asynchronously. For example, in an e-commerce app, when adding a phone from the home page, it might be necessary to make an API call to the backend, manage the loading state, update the cart slice in the store if the call succeeds, and handle any errors. To keep this basic tutorial simple, we won't create asynchronous actions for every action in our app but will limit it to two basic actions, such as loading the shopping cart when the app starts and one to update the cart from the cart page.

If you want to dive deeper, I recommend checking out the documentation for createAsyncThunk.

createAsyncThunk

createAsyncThunk is a Redux Toolkit function that simplifies the handling of asynchronous actions. It automates the process of dispatching actions for the various states of an asynchronous request: pending, fulfilled, and rejected.

When invoking createAsyncThunk, you need to provide an action type as a string and a payload creator function that returns a promise. Redux Toolkit automatically manages the promise's lifecycle, dispatching actions corresponding to its resolution or rejection states. This eliminates the need to manually write separate case handlers in the reducer to handle the request's state.

Additionally, createAsyncThunk provides a thunkAPI parameter that contains useful functions and data such as dispatch, getState, and rejectWithValue, offering flexible management for asynchronous scenarios.

Before proceeding, you need to start the mock server. To do this, we use the json-server library:

npx json-server --watch db.json --port 5000

If you're unable to start the server, take a look at the documentation. It should start on the first try. I'm mentioning this because the json-server library is being frequently updated these days.

In the src/store/cart/actions.ts file, we will create our actions. We could consider creating a separate file, but for just two actions, it's not worth it.

src/store/cart/actions.ts
1import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
2import { Item } from "./cartSlice";
3import { RootState } from "..";
4
5interface CartApiData {
6  items: Item[];
7  total: number;
8  amount: number;
9}
10
11export const addItem = createAction<Item>("cart/addItem");
12export const removeItem = createAction<number>("cart/removeItem");
13export const removeAll = createAction<number>("cart/removeAll");
14export const deleteCart = createAction("cart/deleteCart");
15
16//remember to run the command npx json-server --watch db.json --port 5000
17// Create an asyncThunk to fetch cart data from the server
18export const fetchCartData = createAsyncThunk<CartApiData>(
19  "cart/fetchCartData",
20  async () => {
21    const response = await fetch("http://localhost:5000/cart");
22    const data: CartApiData = await response.json();
23    return data;
24  }
25);
26
27export const saveCart = createAsyncThunk(
28  "cart/saveCart",
29  async (action, { getState }) => {
30    const state = getState() as RootState;
31    const { isLoading, ...cart } = state.cart;
32
33    const response = await fetch("http://localhost:5000/cart", {
34      method: "PUT",
35      headers: {
36        "Content-Type": "application/json",
37      },
38      body: JSON.stringify(cart),
39    });
40
41    const data = await response.json();
42    return data;
43  }
44);

Now let's update the slice.

As a new property in cartState, we have added isLoading, which is useful for handling a loading state on the page. Actions created with createAsyncThunk go into extraReducers, where the pending, fulfilled, and rejected states are automatically managed.

src/store/cart/cartSlice.ts
1import { createSlice } from "@reduxjs/toolkit";
2import {
3  addItemReducer,
4  deleteCartReducer,
5  removeAllReducer,
6  removeItemReducer,
7} from "./reducers";
8import {
9  addItem,
10  removeItem,
11  removeAll,
12  deleteCart,
13  fetchCartData,
14  saveCart,
15} from "./actions";
16
17export interface Item {
18  id: number;
19  name: string;
20  price: number;
21  qty: number;
22}
23
24export interface CartState {
25  items: Item[];
26  total: number;
27  amount: number;
28  isLoading: boolean;
29}
30
31const initialState: CartState = {
32  items: [],
33  total: 0,
34  amount: 0,
35  isLoading: false,
36};
37
38export const cartSlice = createSlice({
39  name: "cart",
40  initialState,
41  reducers: {},
42  extraReducers: (builder) => {
43    builder
44      .addCase(addItem, addItemReducer)
45      .addCase(removeItem, removeItemReducer)
46      .addCase(removeAll, removeAllReducer)
47      .addCase(deleteCart, deleteCartReducer)
48      //get cart
49      .addCase(fetchCartData.pending, (state) => {
50        state.isLoading = true;
51      })
52      .addCase(fetchCartData.fulfilled, (state, action) => {
53        // Handle the fulfilled state by setting the cart data
54        state.items = action.payload.items;
55        state.total = action.payload.total;
56        state.amount = action.payload.amount;
57        state.isLoading = false;
58      })
59      .addCase(fetchCartData.rejected, (state) => {
60        state.isLoading = false;
61      })
62      //save cart
63      .addCase(saveCart.pending, (state) => {
64        state.isLoading = true;
65      })
66      .addCase(saveCart.fulfilled, (state) => {
67        // Handle the fulfilled state by setting the cart data
68        state.isLoading = false;
69        alert("cart saved !!!");
70      })
71      .addCase(saveCart.rejected, (state) => {
72        state.isLoading = false;
73      });
74  },
75});
76export { addItem, removeItem, removeAll, deleteCart };
77
78export default cartSlice.reducer;

Now all that's left is to use our new actions with the useDispatch hook. This time, however, we will type the dispatch with the AppDispatch type, which is necessary for proper typing. Later, we'll see a small trick to avoid having to type the selectors and dispatches every time.

src/pages/Cart.tsx
1import CartItemListView from "../components/CartItemListView";
2import { useSelector } from "react-redux";
3import { AppDispatch, RootState } from "../store";
4import { useDispatch } from "react-redux";
5import { deleteCart, saveCart } from "../store/cart/actions";
6
7function Cart() {
8  const amount = useSelector((state: RootState) => state.cart.amount);
9  const total = useSelector((state: RootState) => state.cart.total);
10  const isLoading = useSelector((state: RootState) => state.cart.isLoading);
11  const dispatch: AppDispatch = useDispatch();
12
13  if (isLoading) {
14    return <p>Loading...</p>;
15  }
16
17  return (
18    <div className="m-2">
19      <h1 className="text-center">Your Cart</h1>
20      <CartItemListView />
21      <div className="d-flex justify-content-center align-items-center gap-5 p-4">
22        <p className="fs-3 m-0">
23          Total ({total} items): €{amount.toFixed(2)}
24        </p>
25        <div className="d-flex align-items-center button-container gap-1">
26          <button
27            onClick={() => {
28              dispatch(saveCart());
29            }}
30            type="button"
31            className="btn btn-success flex-shrink-0 fs-5"
32          >
33            Update Cart
34          </button>
35          <button
36            onClick={() => {
37              dispatch(deleteCart());
38            }}
39            type="button"
40            className="btn btn-danger flex-shrink-0 fs-5"
41          >
42            Delete all
43          </button>
44        </div>
45      </div>
46    </div>
47  );
48}
49
50export default Cart;
shopping-cart/src/App.tsx
1import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
2import Home from "./pages/Home";
3import Cart from "./pages/Cart";
4import Navbar from "./components/Navbar";
5// import Modal from "./components/Modal";
6import { useDispatch } from "react-redux";
7import { useEffect } from "react";
8import { AppDispatch } from "./store";
9import { fetchCartData } from "./store/cart/actions";
10
11function App() {
12  const dispatch: AppDispatch  = useDispatch();
13
14  useEffect(() => {
15    dispatch(fetchCartData());
16}, [dispatch]);
17
18  return (
19    <Router>
20      <Navbar />
21      {/* <Modal /> */}
22      <Routes>
23        <Route path="/" element={<Home />} />
24        <Route path="cart" element={<Cart />} />
25      </Routes>
26    </Router>
27  );
28}
29
30export default App;

TIPS: Custom Hooks useAppDispatch and useAppSelector

To avoid having to type useSelector and useDispatch every time, we can create our own custom hooks, thus avoiding repetition.

Let's create a hooks.ts file inside the src/store folder.

src/store/hooks.ts
1import { useDispatch, useSelector } from "react-redux"
2import { AppDispatch, RootState } from "./index"
3
4export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
5export const useAppSelector = useSelector.withTypes<RootState>()

Once the hooks are created, you will only need to replace useSelector and useDispatch in the components. To avoid making the tutorial too long, I'll just include a code snippet to show how to make the replacement.

1//const amount = useSelector((state: RootState) => state.cart.amount);
2//const total = useSelector((state: RootState) => state.cart.total);
3//const isLoading = useSelector((state: RootState) => state.cart.isLoading);
4//const dispatch: AppDispatch = useDispatch();
5
6const amount = useAppSelector((state) => state.cart.amount);
7const total = useAppSelector((state) => state.cart.total);
8const isLoading = useAppSelector((state) => state.cart.isLoading);
9const dispatch = useAppDispatch();

Redux DevTools

A powerful tool that sets Redux apart is the Redux DevTools, available as a browser extension. This tool is essential for debugging: it records all the actions performed along with their payloads, displays the current state and the differences with the previous state, provides performance control, facilitates testing, and much more.

Redux console

The tool is enabled by default in configureStore via the devTools property. This property accepts a boolean value to enable or disable the tools, or an object for further configuring the tool.

1const store = configureStore({
2  reducer: {
3    cart: cartReducer,
4  },
5  devTools: process.env.NODE_ENV !== "production",
6});

Builder Methods and Matching Utilities

As you may have noticed, there's a bit of boilerplate code in the slices, where the pending and rejected actions handle the loading state in the same way. Fortunately, Redux Toolkit provides tools to reduce code repetition.

In the builder callback within the extraReducers of the slice, the builder object offers, in addition to addCase, addMatcher, and addDefaultCase.

addMatcher

Allows matching actions based on the action type: if it matches, the specific reducer is triggered.

Practical example:
In this example of addMatcher, we check if all actions end with "/pending" (remember that createAsyncThunk automatically generates action names). If the condition is met, it triggers the corresponding reducer.

export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addMatcher(
      (action) => action.type.endsWith("/pending"),
      (state, action) => {
        state.isLoading = true;
      }
    );
  },
});

addDefaultCase

It is used to trigger a reducer that acts as a default case, executed after all the other specific cases defined in the builder. This allows handling any actions that do not match other specific matchers defined in extraReducers.

Matching Utilities

Once again, Redux Toolkit offers shortcuts to avoid code repetition. We can use Matching Utilities: instead of (action) => action.type.endsWith("/pending"), we can use Redux Toolkit's isPending, which automatically detects if an action is in a pending state. There's also isRejected to recognize rejected actions. There are many other similar utilities; I recommend checking the documentation for more details.

import { createSlice, isPending } from "@reduxjs/toolkit";
          
export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addMatcher(isPending, (state, action) => {
      state.isLoading = true;
    });
  },
});

Updating the Slice

After exploring these utilities, let's proceed with updating our code.
We'll start by moving our reducers from the slice to the reducers.ts file.

src/store/cart/reducers.ts
1import { PayloadAction } from "@reduxjs/toolkit";
2import { CartState, Item } from "./cartSlice";
3import { CartApiData } from "./actions";
4
5const updateTotals = (state: CartState) => {
6    state.total = state.items.reduce((total, item) => total + item.qty, 0);
7    state.amount = state.items.reduce((amount, item) => amount + (item.price * item.qty), 0);
8};
9
10export const addItemReducer = (state: CartState, action: PayloadAction<Item>) => {
11    const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);
12
13    if (existingItemIndex !== -1) {
14        state.items[existingItemIndex].qty += 1;
15    } else {
16        state.items.push(action.payload);
17    }
18
19    updateTotals(state);
20};
21
22export const removeItemReducer = (state: CartState, action: PayloadAction<number>) => {
23    const existingItemIndex = state.items.findIndex(item => item.id === action.payload);
24    if (existingItemIndex !== -1) {
25        if (state.items[existingItemIndex].qty > 1) {
26            state.items[existingItemIndex].qty -= 1;
27        } else {
28            state.items.splice(existingItemIndex, 1);
29        }
30    }
31    updateTotals(state);
32};
33
34export const removeAllReducer = (state: CartState, action: PayloadAction<number>) => {
35    state.items = state.items.filter(item => item.id !== action.payload);
36    updateTotals(state);
37};
38
39export const deleteCartReducer = (state: CartState) => {
40    state.items = [];
41    state.total = 0;
42    state.amount = 0;
43};
44
45export const startLoadingReducer = (state: CartState) => {
46    state.isLoading = true;
47};
48
49export const stopLoadingReducer = (state: CartState) => {
50    state.isLoading = false;
51};
52
53export const fetchCartDataFulfilledReducer = (
54    state: CartState,
55    action: PayloadAction<CartApiData>
56) => {
57    state.items = action.payload.items;
58    state.total = action.payload.total;
59    state.amount = action.payload.amount;
60    state.isLoading = false;
61};
62
63export const saveCartFulfilledReducer = (state: CartState) => {
64    state.isLoading = false;
65    alert("Cart saved!");
66};

After copying the code, remember to add the export to the CartApiData interface in actions.ts.

Now let's update the slice.

src/store/cart/cartSlice.ts
1import { createSlice, isPending, isRejected } from "@reduxjs/toolkit";
2import {
3  addItemReducer,
4  deleteCartReducer,
5  fetchCartDataFulfilledReducer,
6  removeAllReducer,
7  removeItemReducer,
8  saveCartFulfilledReducer,
9  startLoadingReducer,
10  stopLoadingReducer,
11} from "./reducers";
12import {
13  addItem,
14  removeItem,
15  removeAll,
16  deleteCart,
17  fetchCartData,
18  saveCart,
19} from "./actions";
20
21export interface Item {
22  id: number;
23  name: string;
24  price: number;
25  qty: number;
26}
27
28export interface CartState {
29  items: Item[];
30  total: number;
31  amount: number;
32  isLoading: boolean;
33}
34
35const initialState: CartState = {
36  items: [],
37  total: 0,
38  amount: 0,
39  isLoading: false,
40};
41
42export const cartSlice = createSlice({
43  name: "cart",
44  initialState,
45  reducers: {},
46  extraReducers: (builder) => {
47    builder
48      .addCase(addItem, addItemReducer)
49      .addCase(removeItem, removeItemReducer)
50      .addCase(removeAll, removeAllReducer)
51      .addCase(deleteCart, deleteCartReducer)
52      .addCase(fetchCartData.fulfilled, fetchCartDataFulfilledReducer)
53      .addCase(saveCart.fulfilled, saveCartFulfilledReducer)
54      .addMatcher(isPending, startLoadingReducer)
55      .addMatcher(isRejected, stopLoadingReducer);
56  },
57});
58export { addItem, removeItem, removeAll, deleteCart };
59
60export default cartSlice.reducer;

Conclusion

If you've made it this far, congratulations! Thank you for reading my tutorial. Today we explored how to use Redux Toolkit, a powerful library for state management in React. Thanks to this tutorial, you should now have a better understanding of how to implement, work with, and apply Redux Toolkit best practices in your projects. As a reward, I leave you with a global modal created using Redux Toolkit. Thanks, and goodbye.

Bonus: Creating a Global Modal with Redux Toolkit

After completing our cart, as a bonus for this tutorial, we'll create a global modal using Redux Toolkit.

Let's start by creating a slice. Inside the store folder, create a new folder named modal and within it, the file modalSlice.ts.

src/store/modal/modalSlice.ts
1import { createSlice } from "@reduxjs/toolkit";
2
3export interface ModalState {
4    isOpen: boolean;
5}
6
7const initialState: ModalState = {
8    isOpen: false,
9};
10
11export const modalSlice = createSlice({
12    name: "modal",
13    initialState,
14    reducers: {
15        openModal: (state) => {
16            state.isOpen = true;
17        },
18        closeModal: (state) => {
19            state.isOpen = false;
20        },
21    },
22});
23
24export const { openModal, closeModal } = modalSlice.actions;
25
26export default modalSlice.reducer;

Now let's update the configureStore.

src/store/index.ts
1import { configureStore } from "@reduxjs/toolkit";
2import cartReducer from "./cart/cartSlice"
3import modalReducer from "./modal/modalSlice"
4
5export const store = configureStore({
6    reducer: {
7        cart: cartReducer,
8        modal: modalReducer
9    },
10})
11
12export type RootState = ReturnType<typeof store.getState>
13export type AppDispatch = typeof store.dispatch

Next, we will update the Modal component and the Cart page, where we will use our modal. Then, remove any references to the modal in App.tsx.

src/components/Modal.tsx
1import { closeModal } from "../store/modal/modalSlice";
2import { useAppDispatch, useAppSelector } from "../store/hooks";
3import { useEffect } from "react";
4
5interface ModalProps {
6  children: React.ReactNode;
7  onConfirm: () => void;
8}
9const Modal: React.FC<ModalProps> = ({ children, onConfirm }) => {
10  const isOpen = useAppSelector((state) => state.modal.isOpen);
11  const dispatch = useAppDispatch();
12
13  const confirm = () => {
14    onConfirm();
15    dispatch(closeModal());
16  };
17
18  useEffect(() => {
19    return () => {
20      dispatch(closeModal());
21    };
22  }, [dispatch]);
23
24  if (!isOpen) return null;
25
26  return (
27    <div
28      className="modal show d-block"
29      style={{ backgroundColor: "#00000045" }}
30      tabIndex={-1}
31      role="dialog"
32    >
33      <div className="modal-dialog modal-dialog-centered" role="document">
34        <div className="modal-content">
35          <div className="d-flex justify-content-between modal-header">
36            <h5 className="modal-title">Alert</h5>
37            <span
38              onClick={() => {
39                dispatch(closeModal());
40              }}
41              aria-hidden="true"
42            >
43              <i className="bi bi-x-lg"></i>
44            </span>
45          </div>
46          <div className="modal-body">{children}</div>
47          <div className="modal-footer">
48            <button
49              type="button"
50              className="btn btn-secondary"
51              data-dismiss="modal"
52              onClick={() => {
53                dispatch(closeModal());
54              }}
55            >
56              Close
57            </button>
58            <button
59              onClick={() => {
60                confirm();
61              }}
62              type="button"
63              className="btn btn-primary"
64            >
65              Confirm
66            </button>
67          </div>
68        </div>
69      </div>
70    </div>
71  );
72};
73
74export default Modal;
src/pages/Cart.tsx
1import CartItemListView from "../components/CartItemListView";
2import { deleteCart, saveCart } from "../store/cart/actions";
3import { openModal } from "../store/modal/modalSlice";
4import { useAppDispatch, useAppSelector } from "../store/hooks";
5import Modal from "../components/Modal";
6
7function Cart() {
8  const amount = useAppSelector((state) => state.cart.amount);
9  const total = useAppSelector((state) => state.cart.total);
10  const isLoading = useAppSelector((state) => state.cart.isLoading);
11  const dispatch = useAppDispatch();
12
13  const deleteChartItems = () => {
14    dispatch(deleteCart());
15  }
16
17  if (isLoading) {
18    return <p>Loading...</p>;
19  }
20
21  return (
22    <>
23      <Modal onConfirm={deleteChartItems}>
24        <p>Do you want to delete all items from your cart?</p>
25      </Modal>
26      <div className="m-2">
27        <h1 className="text-center">Your Cart</h1>
28        <CartItemListView />
29        <div className="d-flex justify-content-center align-items-center gap-5 p-4">
30          <p className="fs-3 m-0">
31            Total ({total} items): €{amount.toFixed(2)}
32          </p>
33          <div className="d-flex align-items-center button-container gap-1">
34            <button
35              onClick={() => {
36                dispatch(saveCart());
37              }}
38              type="button"
39              className="btn btn-success flex-shrink-0 fs-5"
40            >
41              Update Cart
42            </button>
43            <button
44              onClick={() => {
45                dispatch(openModal());
46              }}
47              type="button"
48              className="btn btn-danger flex-shrink-0 fs-5"
49              disabled={!amount}
50            >
51              Delete all
52            </button>
53          </div>
54        </div>
55      </div>
56    </>
57  );
58}
59
60export default Cart;