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.

Published:

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
import { configureStore } from "@reduxjs/toolkit";

export const store = configureStore({
    reducer: {},
})

export type RootState = ReturnType<typeof store.getState>
export 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
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import { store } from "./store";
import { Provider } from "react-redux";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

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
import { createSlice } from "@reduxjs/toolkit";

export interface Item {
  id: number;
  name: string;
  price: number;
  qty: number;
}

export interface CartState {
  items: Item[];
  total: number;
  amount: number;
}

const initialState: CartState = {
  items: [],
  total: 0,
  amount: 0,
};

export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {},
});

// export const { } = cartSlice.actions

export 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
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cart/cartSlice"

export const store = configureStore({
    reducer: {
        cart: cartReducer,
    },
})

export type RootState = ReturnType<typeof store.getState>
export 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
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";

export interface Item {
  id: number;
  name: string;
  price: number;
  qty: number;
}

export interface CartState {
  items: Item[];
  total: number;
  amount: number;
}

const initialState: CartState = {
  items: [],
  total: 0,
  amount: 0,
};

export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addItem: (state, action: PayloadAction<Item>) => {
      const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);

      if (existingItemIndex !== -1) {
        // If the item exists, only increment its quantity
        state.items[existingItemIndex].qty += 1;
      } else {
        // Add the item
        state.items.push(action.payload);
      }

      // Update the total and amount
      state.total = state.items.reduce((total, item) => total + item.qty, 0);
      state.amount = state.items.reduce((amount, item) => amount + (item.price * item.qty), 0);
    },
  },
});

export const { addItem } = cartSlice.actions;

export 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
import CartItemListView from "../components/CartItemListView";
import { useSelector } from "react-redux";
import { RootState } from "../store";

function Cart() {
  const amount = useSelector((state: RootState) => state.cart.amount);
  const total = useSelector((state: RootState) => state.cart.total);

  return (
    <div className="m-2">
      <h1 className="text-center">Your Cart</h1>
      <CartItemListView />
      <div className="d-flex justify-content-center align-items-center gap-5 p-4">
        <p className="fs-3 m-0">
          Total ({total} items): €{amount.toFixed(2)}
        </p>
        <div className="d-flex align-items-center button-container gap-1">
          <button type="button" className="btn btn-success flex-shrink-0 fs-5">
            Save Cart
          </button>
          <button type="button" className="btn btn-danger flex-shrink-0 fs-5">
            Delete all
          </button>
        </div>
      </div>
    </div>
  );
}

export 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
import { Link } from "react-router-dom";
import { RootState } from "../store";
import { useSelector } from "react-redux";

const Navbar = () => {
  const total = useSelector((state: RootState) => state.cart.total);

  return (
    <nav className="navbar bg-primary" data-bs-theme="dark">
      <div className="container">
        <Link className="navbar-brand fs-3" to="/">
          Shopping Cart
        </Link>
        <Link
          className="navbar-brand d-flex align-items-center gap-1"
          to="/cart"
        >
          <i className="bi bi-cart large-icon" />
          <span>{total ? total : ""}</span>
        </Link>
      </div>
    </nav>
  );
};

export default Navbar;
src/components/CartItemListView.tsx
import { useSelector } from "react-redux";
import { RootState } from "../store";

const CartItemListView = () => {
  const items = useSelector((state: RootState) => state.cart.items);

  return (
    <div className="d-flex flex-column flex-md-row gap-4 align-items-center justify-content-center">
      <ul className="list-group cart-item-list-view">
        {items.map((item) => {
          return (
            <li
              key={item.id}
              className="list-group-item item d-flex justify-content-between align-items-center fs-5"
            >
              <div>
                <span>{item.name}</span>
                <span className="text-secondary p-2 fs-6">{item.price} €</span>
              </div>
              <div className="d-flex align-items-baseline button-container gap-1">
                <span className="d-inline p-2">Qty. {item.qty}</span>
                <button type="button" className="btn btn-warning fs-5">
                  -
                </button>
                <button type="button" className="btn btn-success fs-5">
                  +
                </button>
                <button type="button" className="btn btn-danger fs-5">
                  remove
                </button>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default CartItemListView;

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

export const itemsSelector = ({ cart }: RootState) => cart.items;
...
const 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.

import { createSelector } from '@reduxjs/toolkit';

const selectProducts = state => state.products;
const selectCartItems = state => state.cart.items;

const selectTotal = createSelector(
  [selectProducts, selectCartItems],
  (products, cartItems) => {
    return cartItems.reduce((total, itemId) => {
      const product = products.find(product => product.id === itemId);
      return total + product.price;
    }, 0);
  }
);
...
const 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
import { useDispatch } from "react-redux";
import { addItem } from "../store/cart/cartSlice";

const data = {
  items: [
    {
      id: 1,
      name: "Google Pixel 9",
      price: 899.99,
    },
    {
      id: 2,
      name: "Apple iPhone 15",
      price: 769.99,
    },
    {
      id: 3,
      name: "Samsung Galaxy S23",
      price: 699.99,
    },
    {
      id: 4,
      name: "Xiaomi 14",
      price: 709.99,
    },
  ],
};

const Itemlist = () => {
  const dispatch = useDispatch();

  return (
    <div className="d-flex flex-column flex-md-row p-4 gap-4 py-md-5 align-items-center justify-content-center">
      <ul className="list-group">
        {data.items.map((item) => {
          return (
            <li
              key={item.id}
              className="list-group-item item d-flex justify-content-between align-items-center fs-5"
            >
              <div>
                {item.name}
                <span className="text-secondary p-2 fs-6">{item.price} €</span>
              </div>

              <button
                onClick={() =>
                  dispatch(
                    addItem({
                      ...item,
                      qty: 1,
                    })
                  )
                }
                type="button"
                className="btn btn-primary"
              >
                Add to cart
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export 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
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";

export interface Item {
  id: number;
  name: string;
  price: number;
  qty: number;
}

export interface CartState {
  items: Item[];
  total: number;
  amount: number;
}

const initialState: CartState = {
  items: [],
  total: 0,
  amount: 0,
};

// Helper function to update total items and total amount
function updateTotals(state: CartState) {
  state.total = state.items.reduce((total, item) => total + item.qty, 0);
  state.amount = state.items.reduce((amount, item) => amount + item.price * item.qty, 0);
}

export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    // Adds an item to the cart or increments its quantity if it already exists
    addItem: (state, action: PayloadAction<Item>) => {
      const existingItemIndex = state.items.findIndex((item) => item.id === action.payload.id);

      if (existingItemIndex !== -1) {
        // If the item exists, only increment its quantity
        state.items[existingItemIndex].qty += 1;
      } else {
        // Add the item
        state.items.push(action.payload);
      }

      updateTotals(state);
    },
    // Removes one unit of an item or the item itself if quantity is 1
    removeItem: (state, action: PayloadAction<number>) => {
      const existingItemIndex = state.items.findIndex((item) => item.id === action.payload);
      if (existingItemIndex !== -1) {
        // Decrease or remove item based on quantity
        if (state.items[existingItemIndex].qty > 1) {
          state.items[existingItemIndex].qty -= 1;
        } else {
          state.items.splice(existingItemIndex, 1);
        }
      }
      updateTotals(state);
    },
    // Removes all units of a specific item from the cart
    removeAll: (state, action: PayloadAction<number>) => {
      state.items = state.items.filter((item) => item.id !== action.payload);
      updateTotals(state);
    },
    // Completely clears the cart
    deleteCart: (state) => {
      state.items = [];
      state.total = 0;
      state.amount = 0;
    },
  },
});

export const { addItem, removeItem, removeAll, deleteCart } = cartSlice.actions;
export default cartSlice.reducer;
src/components/CartItemListView.tsx
import { useSelector } from "react-redux";
import { RootState } from "../store";
import { useDispatch } from "react-redux";
import { addItem, removeAll, removeItem } from "../store/cart/cartSlice";

const CartItemListView = () => {
  const items = useSelector((state: RootState) => state.cart.items);
  const dispatch = useDispatch();

  return (
    <div className="d-flex flex-column flex-md-row gap-4 align-items-center justify-content-center">
      <ul className="list-group cart-item-list-view">
        {items.map((item) => {
          return (
            <li
              key={item.id}
              className="list-group-item item d-flex justify-content-between align-items-center fs-5"
            >
              <div>
                <span>{item.name}</span>
                <span className="text-secondary p-2 fs-6">{item.price} €</span>
              </div>
              <div className="d-flex align-items-baseline button-container gap-1">
                <span className="d-inline p-2">Qty. {item.qty}</span>
                <button
                  onClick={() => {
                    dispatch(removeItem(item.id));
                  }}
                  type="button"
                  className="btn btn-warning fs-5"
                >
                  -
                </button>
                <button
                  onClick={() => {
                    dispatch(addItem({ ...item, qty: 1 }));
                  }}
                  type="button"
                  className="btn btn-success fs-5"
                >
                  +
                </button>
                <button
                  onClick={() => {
                    dispatch(removeAll(item.id));
                  }}
                  type="button"
                  className="btn btn-danger fs-5"
                >
                  remove
                </button>
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default CartItemListView;
src/pages/Cart.tsx
import CartItemListView from "../components/CartItemListView";
import { useSelector } from "react-redux";
import { RootState } from "../store";
import { useDispatch } from "react-redux";
import { deleteCart } from "../store/cart/cartSlice";

function Cart() {
  const amount = useSelector((state: RootState) => state.cart.amount);
  const total = useSelector((state: RootState) => state.cart.total);
  const dispatch = useDispatch();

  return (
    <div className="m-2">
      <h1 className="text-center">Your Cart</h1>
      <CartItemListView />
      <div className="d-flex justify-content-center align-items-center gap-5 p-4">
        <p className="fs-3 m-0">
          Total ({total} items): €{amount.toFixed(2)}
        </p>
        <div className="d-flex align-items-center button-container gap-1">
          <button type="button" className="btn btn-success flex-shrink-0 fs-5">
            Save Cart
          </button>
          <button
            onClick={() => {
              dispatch(deleteCart());
            }}
            type="button"
            className="btn btn-danger flex-shrink-0 fs-5"
          >
            Delete all
          </button>
        </div>
      </div>
    </div>
  );
}

export 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
import { createAction } from "@reduxjs/toolkit";
import { Item } from "./cartSlice";

export const addItem = createAction<Item>("cart/addItem");
export const removeItem = createAction<number>("cart/removeItem");
export const removeAll = createAction<number>("cart/removeAll");
export 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:

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

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
import { PayloadAction } from "@reduxjs/toolkit";
import { CartState, Item } from "./cartSlice";

const updateTotals = (state: CartState) => {
    state.total = state.items.reduce((total, item) => total + item.qty, 0);
    state.amount = state.items.reduce((amount, item) => amount + (item.price * item.qty), 0);
};

export const addItemReducer = (state: CartState, action: PayloadAction<Item>) => {
    const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);

    if (existingItemIndex !== -1) {
        // Se l'elemento esiste, incrementa solo la quantità
        state.items[existingItemIndex].qty += 1;
    } else {
        // Aggiunge l'elemento
        state.items.push(action.payload);
    }

    updateTotals(state);
};

export const removeItemReducer = (state: CartState, action: PayloadAction<number>) => {
    const existingItemIndex = state.items.findIndex(item => item.id === action.payload);
    if (existingItemIndex !== -1) {
        // Decrementa o rimuove l'elemento in base alla quantità
        if (state.items[existingItemIndex].qty > 1) {
            state.items[existingItemIndex].qty -= 1;
        } else {
            state.items.splice(existingItemIndex, 1);
        }
    }
    updateTotals(state);
};

export const removeAllReducer = (state: CartState, action: PayloadAction<number>) => {
    state.items = state.items.filter(item => item.id !== action.payload);
    updateTotals(state);
};

export const deleteCartReducer = (state: CartState) => {
    state.items = [];
    state.total = 0;
    state.amount = 0;
};

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
import { createSlice } from "@reduxjs/toolkit";
import { addItemReducer, deleteCartReducer, removeAllReducer, removeItemReducer } from "./reducers";
import { addItem, removeItem, removeAll, deleteCart } from "./actions";

export interface Item {
  id: number;
  name: string;
  price: number;
  qty: number;
}

export interface CartState {
  items: Item[];
  total: number;
  amount: number;
}

const initialState: CartState = {
  items: [],
  total: 0,
  amount: 0,
};


export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(addItem, addItemReducer)
      .addCase(removeItem, removeItemReducer)
      .addCase(removeAll, removeAllReducer)
      .addCase(deleteCart, deleteCartReducer)
  },
});

export 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
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { Item } from "./cartSlice";
import { RootState } from "..";

interface CartApiData {
  items: Item[];
  total: number;
  amount: number;
}

export const addItem = createAction<Item>("cart/addItem");
export const removeItem = createAction<number>("cart/removeItem");
export const removeAll = createAction<number>("cart/removeAll");
export const deleteCart = createAction("cart/deleteCart");

//remember to run the command npx json-server --watch db.json --port 5000
// Create an asyncThunk to fetch cart data from the server
export const fetchCartData = createAsyncThunk<CartApiData>(
  "cart/fetchCartData",
  async () => {
    const response = await fetch("http://localhost:5000/cart");
    const data: CartApiData = await response.json();
    return data;
  }
);

export const saveCart = createAsyncThunk(
  "cart/saveCart",
  async (action, { getState }) => {
    const state = getState() as RootState;
    const { isLoading, ...cart } = state.cart;

    const response = await fetch("http://localhost:5000/cart", {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(cart),
    });

    const data = await response.json();
    return data;
  }
);

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
import { createSlice } from "@reduxjs/toolkit";
import {
  addItemReducer,
  deleteCartReducer,
  removeAllReducer,
  removeItemReducer,
} from "./reducers";
import {
  addItem,
  removeItem,
  removeAll,
  deleteCart,
  fetchCartData,
  saveCart,
} from "./actions";

export interface Item {
  id: number;
  name: string;
  price: number;
  qty: number;
}

export interface CartState {
  items: Item[];
  total: number;
  amount: number;
  isLoading: boolean;
}

const initialState: CartState = {
  items: [],
  total: 0,
  amount: 0,
  isLoading: false,
};

export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(addItem, addItemReducer)
      .addCase(removeItem, removeItemReducer)
      .addCase(removeAll, removeAllReducer)
      .addCase(deleteCart, deleteCartReducer)
      //get cart
      .addCase(fetchCartData.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(fetchCartData.fulfilled, (state, action) => {
        // Handle the fulfilled state by setting the cart data
        state.items = action.payload.items;
        state.total = action.payload.total;
        state.amount = action.payload.amount;
        state.isLoading = false;
      })
      .addCase(fetchCartData.rejected, (state) => {
        state.isLoading = false;
      })
      //save cart
      .addCase(saveCart.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(saveCart.fulfilled, (state) => {
        // Handle the fulfilled state by setting the cart data
        state.isLoading = false;
        alert("cart saved !!!");
      })
      .addCase(saveCart.rejected, (state) => {
        state.isLoading = false;
      });
  },
});
export { addItem, removeItem, removeAll, deleteCart };

export 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
import CartItemListView from "../components/CartItemListView";
import { useSelector } from "react-redux";
import { AppDispatch, RootState } from "../store";
import { useDispatch } from "react-redux";
import { deleteCart, saveCart } from "../store/cart/actions";

function Cart() {
  const amount = useSelector((state: RootState) => state.cart.amount);
  const total = useSelector((state: RootState) => state.cart.total);
  const isLoading = useSelector((state: RootState) => state.cart.isLoading);
  const dispatch: AppDispatch = useDispatch();

  if (isLoading) {
    return <p>Loading...</p>;
  }

  return (
    <div className="m-2">
      <h1 className="text-center">Your Cart</h1>
      <CartItemListView />
      <div className="d-flex justify-content-center align-items-center gap-5 p-4">
        <p className="fs-3 m-0">
          Total ({total} items): €{amount.toFixed(2)}
        </p>
        <div className="d-flex align-items-center button-container gap-1">
          <button
            onClick={() => {
              dispatch(saveCart());
            }}
            type="button"
            className="btn btn-success flex-shrink-0 fs-5"
          >
            Update Cart
          </button>
          <button
            onClick={() => {
              dispatch(deleteCart());
            }}
            type="button"
            className="btn btn-danger flex-shrink-0 fs-5"
          >
            Delete all
          </button>
        </div>
      </div>
    </div>
  );
}

export default Cart;
shopping-cart/src/App.tsx
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Cart from "./pages/Cart";
import Navbar from "./components/Navbar";
// import Modal from "./components/Modal";
import { useDispatch } from "react-redux";
import { useEffect } from "react";
import { AppDispatch } from "./store";
import { fetchCartData } from "./store/cart/actions";


function App() {
  const dispatch: AppDispatch  = useDispatch();

  useEffect(() => {
    dispatch(fetchCartData());
}, [dispatch]);

  return (
    <Router>
      <Navbar />
      {/* <Modal /> */}
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="cart" element={<Cart />} />
      </Routes>
    </Router>
  );
}

export 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
import { useDispatch, useSelector } from "react-redux"
import { AppDispatch, RootState } from "./index"

export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export 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.

//const amount = useSelector((state: RootState) => state.cart.amount);
//const total = useSelector((state: RootState) => state.cart.total);
//const isLoading = useSelector((state: RootState) => state.cart.isLoading);
//const dispatch: AppDispatch = useDispatch();

const amount = useAppSelector((state) => state.cart.amount);
const total = useAppSelector((state) => state.cart.total);
const isLoading = useAppSelector((state) => state.cart.isLoading);
const 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.

const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
  devTools: process.env.NODE_ENV !== "production",
});

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
import { PayloadAction } from "@reduxjs/toolkit";
import { CartState, Item } from "./cartSlice";
import { CartApiData } from "./actions";

const updateTotals = (state: CartState) => {
    state.total = state.items.reduce((total, item) => total + item.qty, 0);
    state.amount = state.items.reduce((amount, item) => amount + (item.price * item.qty), 0);
};

export const addItemReducer = (state: CartState, action: PayloadAction<Item>) => {
    const existingItemIndex = state.items.findIndex(item => item.id === action.payload.id);

    if (existingItemIndex !== -1) {
        // Se l'elemento esiste, incrementa solo la quantità
        state.items[existingItemIndex].qty += 1;
    } else {
        // Aggiunge l'elemento
        state.items.push(action.payload);
    }

    updateTotals(state);
};

export const removeItemReducer = (state: CartState, action: PayloadAction<number>) => {
    const existingItemIndex = state.items.findIndex(item => item.id === action.payload);
    if (existingItemIndex !== -1) {
        // Decrementa o rimuove l'elemento in base alla quantità
        if (state.items[existingItemIndex].qty > 1) {
            state.items[existingItemIndex].qty -= 1;
        } else {
            state.items.splice(existingItemIndex, 1);
        }
    }
    updateTotals(state);
};

export const removeAllReducer = (state: CartState, action: PayloadAction<number>) => {
    state.items = state.items.filter(item => item.id !== action.payload);
    updateTotals(state);
};

export const deleteCartReducer = (state: CartState) => {
    state.items = [];
    state.total = 0;
    state.amount = 0;
};

export const startLoadingReducer = (state: CartState) => {
    state.isLoading = true;
};

export const stopLoadingReducer = (state: CartState) => {
    state.isLoading = false;
};

export const fetchCartDataFulfilledReducer = (
    state: CartState,
    action: PayloadAction<CartApiData>
) => {
    state.items = action.payload.items;
    state.total = action.payload.total;
    state.amount = action.payload.amount;
    state.isLoading = false;
};

export const saveCartFulfilledReducer = (state: CartState) => {
    state.isLoading = false;
    alert("Cart saved!");
};

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
import { createSlice, isPending, isRejected } from "@reduxjs/toolkit";
import {
  addItemReducer,
  deleteCartReducer,
  fetchCartDataFulfilledReducer,
  removeAllReducer,
  removeItemReducer,
  saveCartFulfilledReducer,
  startLoadingReducer,
  stopLoadingReducer,
} from "./reducers";
import {
  addItem,
  removeItem,
  removeAll,
  deleteCart,
  fetchCartData,
  saveCart,
} from "./actions";

export interface Item {
  id: number;
  name: string;
  price: number;
  qty: number;
}

export interface CartState {
  items: Item[];
  total: number;
  amount: number;
  isLoading: boolean;
}

const initialState: CartState = {
  items: [],
  total: 0,
  amount: 0,
  isLoading: false,
};

export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(addItem, addItemReducer)
      .addCase(removeItem, removeItemReducer)
      .addCase(removeAll, removeAllReducer)
      .addCase(deleteCart, deleteCartReducer)
      .addCase(fetchCartData.fulfilled, fetchCartDataFulfilledReducer)
      .addCase(saveCart.fulfilled, saveCartFulfilledReducer)
      .addMatcher(isPending, startLoadingReducer)
      .addMatcher(isRejected, stopLoadingReducer);
  },
});
export { addItem, removeItem, removeAll, deleteCart };

export 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
import { createSlice } from "@reduxjs/toolkit";

export interface ModalState {
    isOpen: boolean;
}

const initialState: ModalState = {
    isOpen: false,
};

export const modalSlice = createSlice({
    name: "modal",
    initialState,
    reducers: {
        openModal: (state) => {
            state.isOpen = true;
        },
        closeModal: (state) => {
            state.isOpen = false;
        },
    },
});

export const { openModal, closeModal } = modalSlice.actions;

export default modalSlice.reducer;

Now let's update the configureStore.

src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cart/cartSlice"
import modalReducer from "./modal/modalSlice"

export const store = configureStore({
    reducer: {
        cart: cartReducer,
        modal: modalReducer
    },
})

export type RootState = ReturnType<typeof store.getState>
export 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
import { closeModal } from "../store/modal/modalSlice";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { useEffect } from "react";

interface ModalProps {
  children: React.ReactNode;
  onConfirm: () => void;
}
const Modal: React.FC<ModalProps> = ({ children, onConfirm }) => {
  const isOpen = useAppSelector((state) => state.modal.isOpen);
  const dispatch = useAppDispatch();

  const confirm = () => {
    onConfirm();
    dispatch(closeModal());
  };

  useEffect(() => {
    return () => {
      dispatch(closeModal());
    };
  }, [dispatch]);

  if (!isOpen) return null;

  return (
    <div
      className="modal show d-block"
      style={{ backgroundColor: "#00000045" }}
      tabIndex={-1}
      role="dialog"
    >
      <div className="modal-dialog modal-dialog-centered" role="document">
        <div className="modal-content">
          <div className="d-flex justify-content-between modal-header">
            <h5 className="modal-title">Alert</h5>
            <span
              onClick={() => {
                dispatch(closeModal());
              }}
              aria-hidden="true"
            >
              <i className="bi bi-x-lg"></i>
            </span>
          </div>
          <div className="modal-body">{children}</div>
          <div className="modal-footer">
            <button
              type="button"
              className="btn btn-secondary"
              data-dismiss="modal"
              onClick={() => {
                dispatch(closeModal());
              }}
            >
              Close
            </button>
            <button
              onClick={() => {
                confirm();
              }}
              type="button"
              className="btn btn-primary"
            >
              Confirm
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Modal;
src/pages/Cart.tsx
import CartItemListView from "../components/CartItemListView";
import { deleteCart, saveCart } from "../store/cart/actions";
import { openModal } from "../store/modal/modalSlice";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import Modal from "../components/Modal";

function Cart() {
  const amount = useAppSelector((state) => state.cart.amount);
  const total = useAppSelector((state) => state.cart.total);
  const isLoading = useAppSelector((state) => state.cart.isLoading);
  const dispatch = useAppDispatch();

  const deleteChartItems = () => {
    dispatch(deleteCart());
  }

  if (isLoading) {
    return <p>Loading...</p>;
  }

  return (
    <>
      <Modal onConfirm={deleteChartItems}>
        <p>Do you want to delete all items from your cart?</p>
      </Modal>
      <div className="m-2">
        <h1 className="text-center">Your Cart</h1>
        <CartItemListView />
        <div className="d-flex justify-content-center align-items-center gap-5 p-4">
          <p className="fs-3 m-0">
            Total ({total} items): €{amount.toFixed(2)}
          </p>
          <div className="d-flex align-items-center button-container gap-1">
            <button
              onClick={() => {
                dispatch(saveCart());
              }}
              type="button"
              className="btn btn-success flex-shrink-0 fs-5"
            >
              Update Cart
            </button>
            <button
              onClick={() => {
                dispatch(openModal());
              }}
              type="button"
              className="btn btn-danger flex-shrink-0 fs-5"
              disabled={!amount}
            >
              Delete all
            </button>
          </div>
        </div>
      </div>
    </>
  );
}

export default Cart;