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:
- Store: Represents the container of the global state of the application.
- Actions: These are objects that send data from the application to the store.
- Reducers: These are pure functions that accept the current state and an action to return a new state.
- Dispatch Function: The dispatch function is the method used to send actions to the store.
- 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 src
directory. Next, create the file index.ts
inside this folder.
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.
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.
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
.
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.
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:
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.
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;
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.
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.
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;
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;
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:
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.
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.
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.
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.
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.
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;
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.
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.
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.
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.
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
.
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
.
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
.
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;
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;
Table of Contents
- Introduction
- - A Bit of Theory
- Initial Setup
- - Development Environment Setup
- - Installing Required Dependencies: Redux Toolkit and React-Redux
- - Creating Redux Store
- - Connecting the Store to React
- - Creating a State Slice with Redux Toolkit
- Implementing Features
- - Implementing Actions
- - Using State and Actions in Components
- - useSelector
- - useDispatch
- - Creating All Other CRUD Actions
- TIPS: Refactoring the Slice with createAction and extraReducers
- - createAction
- - extraReducers
- How to Create Async Actions with createAsyncThunk
- - createAsyncThunk
- TIPS: Custom Hooks useAppDispatch and useAppSelector
- Redux DevTools
- Builder Methods and Matching Utilities
- - addMatcher
- - addDefaultCase
- - Matching Utilities
- - Updating the Slice
- Conclusion
- Bonus: Creating a Global Modal with Redux Toolkit