Gestione avanzata dello stato in React con Redux Toolkit
Impara Redux Toolkit con un progetto reale: costruisci uno shopping cart con tips e tricks + bonus.
Pubblicato:Introduzione
Redux Toolkit è una potente libreria per la gestione dello stato nelle applicazioni React. Nel tutorial di oggi, scopriremo come implementare un shopping cart per un e-commerce, illustrando passo dopo passo l'uso efficace di questa libreria.
Un po' di teoria
Redux è una libreria per la gestione dello stato in applicazioni JavaScript, comunemente usata con React ma compatibile anche con altri framework. Redux Toolkit è una libreria specificamente progettata per semplificare la configurazione e l'utilizzo di Redux in React.
È una libreria progettata per la gestione dello stato globale delle applicazioni, il che consente di accedere allo stato da ogni componente dell'applicazione. Da qualunque componente, è possibile lanciare azioni che modificano lo stato, anche se questi si trovano in pagine diverse.
Le componenti principali in app in redux sono:
- Store: Rappresenta il contenitore dello stato globale dell'applicazione.
- Actions: Sono oggetti che inviano dati dall'applicazione al store.
- Reducers: Sono funzioni pure che accettano lo stato attuale e un'azione per restituire un nuovo stato.
- Dispatch Function: La funzione dispatch è il metodo utilizzato per inviare azioni al store.
- Selectors: I selectors sono funzioni che estraggono e trasformano dati dallo stato di Redux.
Setup Iniziale
Configurazione dell'ambiente di sviluppo
Inizieremo con un template che ho già preparato, completo di CSS, componenti e librerie, focalizzandoci esclusivamente su Redux Toolkit, ready to use. Il template è una classica applicazione realizzata in React: si tratta di un piccolo e-commerce con due pagine, "Home" e "Cart". Nella pagina "Home", troviamo una lista di oggetti da aggiungere al carrello. Nella pagina "Cart", troviamo il carrello vero e proprio, dove è possibile aggiungere, ridurre o rimuovere articoli, aggiornare il carrello o eliminarne il contenuto.
Come procedere: puoi scaricare il template e seguire lo sviluppo passo passo con la guida, oppure puoi visitare il branch shopping-cart-redux-implementation e analizzare i commit.
Trovi il progetto sul mio profilo GitHub.
Per scaricare il template:
git clone https://github.com/Gennaro-Nucaro/redux-toolkit-shopping-cart.git
Installazione delle dipendenze necessarie, Redux-Toolkit e React-Redux
Come installare redux toolkit:
npm install @reduxjs/toolkit react-redux
Creazione redux store
Crea una cartella chiamata store
all'interno della directory src
. Successivamente, crea il file index.ts
all'interno di questa cartella.
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Qui configuriamo lo store, dove inseriremo i nostri reducers. Il tipo RootState
rappresenta la struttura dello stato nel nostro store, mentre AppDispatch
è il tipo utilizzato per i dispatch.
Colleghiamo lo store a React
Dopo aver configurato lo store, colleghiamolo alla nostra applicazione React.
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>
);
Creazione di uno Slice di Stato con Redux Toolkit
Dopo aver creato lo store, passiamo alla creazione di uno slice. Uno slice in Redux Toolkit rappresenta una sezione dello stato dell'applicazione, dove verrà conservato il nostro stato specifico. Per creare uno slice, utilizziamo la funzione createSlice
con le seguenti proprietà:
- Name: Il nome dato al pezzo di stato, in questo caso 'cart'.
- InitialState: Un oggetto che definisce lo stato iniziale.
- Reducers: Una proprietà che contiene i nostri reducers, le funzioni incaricate di modificare lo stato.
All'interno della cartella store
, creiamo una sottocartella denominata cart
e al suo interno il file 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;
Dopo aver creato lo slice, esportiamo il reducer cartSlice.reducer
e lo inseriamo nel configureStore
situato 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
Implementazione delle Funzionalità
Implementazione actions
Dopo aver completato la configurazione, possiamo procedere alla creazione della nostra logica CRUD. Inizieremo con le actions. Le actions sono funzioni che ricevono dati come parametro (tramite action.payload
) e sono fondamentali nei reducers. Grazie a Redux Toolkit, possiamo definire le nostre actions direttamente all'interno delle slice, semplificando così la gestione dello stato.
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;
Per tipizzare il parametro action
, usiamo il tipo PayloadAction<Item>
dalla libreria Redux Toolkit. Dopo di ciò, esportiamo la nostra azione con export const { addItem } = cartSlice.actions;
, che può essere usata ovunque nell'app.
È importante sapere che all'interno dei reducer, lo stato con cui lavoriamo è immutabile: operiamo su una specie di copia dello stato originale. Questo avviene perché Redux Toolkit utilizza la libreria immer.js, che ci permette di modificare lo stato in modo sicuro, evitando spiacevoli side effects.
TIPS:
Dato che utilizziamo immer.js nei reducer, se tentiamo di fare un console.log(state)
all'interno dei reducer, otterremo un oggetto con una struttura inusuale. Per visualizzare correttamente l'oggetto, usa la funzione current
di Redux Toolkit così: console.log(current(state));
Utilizzare lo Stato e le Azioni nei Componenti
Adesso, esporteremo il nostro stato e la nostra azione all'interno della nostra app. Useremo l'hook useSelector
per recuperare lo stato e l'hook useDispatch
per lanciare azioni.
useSelector
Iniziamo a utilizzare l'hook useSelector
per diffondere lo stato di Redux all'interno dell'app.
Il tipo RootState
, che abbiamo definito in store/index.ts
, viene utilizzato nei selector.
Applichiamo le seguenti modifiche:
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:
Attenzione all'uso di useSelector
fatto in questo modo.
const { name, password, email, phone, address } = useSelector((state: RootState) => state.user);
Aggiornare anche solo una di queste proprietà potrebbe causare il re-render di altri componenti che le utilizzano, portando a problemi di performance. È consigliabile utilizzare un selector specifico per ogni pezzo di stato al fine di minimizzare i re-render.
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;
Puoi anche creare selectors in questo modo: si crea una funzione a parte e poi si utilizza con useSelector
.
export const itemsSelector = ({ cart }: RootState) => cart.items;
...
const items = useSelector(itemsSelector);
In questo modo, puoi applicare delle logiche prima di restituire lo stato.
Un potente strumento che non vedremo nel nostro tutorial sull'app cart è createSelector
. È una funzione di Redux Toolkit capace di creare selector avanzati che sono memoizzati.
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
Dopo aver utilizzato i nostri selector, ora passiamo all'uso dell'hook useDispatch
per attivare l'azione addItem
creata precedentemente.
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;
Come abbiamo visto, abbiamo esportato il nostro stato e le nostre azioni in tutta l'app. Già da ora possiamo intuire la potenza di questa libreria, e abbiamo solo iniziato. dopo vedremo altre feature interessanti.
Creiamo tutte le altre actions CRUD
Creiamo le azioni. Successivamente, utilizziamo queste azioni nei vari componenti dell'applicazione.
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: Refactorizziamo lo slice con createAction
e extraReducers
Forse avrai notato che nel nostro slice c'è molto codice; questo potrebbe andare bene ora, ma in un'applicazione reale potremmo avere bisogno di molte altre actions. Questo potrebbe creare confusione, almeno dal mio punto di vista, che rimane un'opinione personale. Una soluzione che propongo è quella di creare separatamente le actions e i reducers, per poi importarli nel nostro slice. In questo modo, avremo una separazione più chiara della logica e un codice più pulito.
createAction
createAction
permette di creare un'azione con un tipo specificato al di fuori dello slice, preparando eventuali payload da allegare all'azione. Restituisce un oggetto action
che include un campo type
e facoltativamente un campo payload
.
Creiamo un file actions.ts
in src/store/cart
.
All'interno di actions.ts
, creiamo le nostre azioni usando createAction
in questo modo:
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");
Puoi creare le actions anche in questo modo, particolarmente utile quando vuoi incorporare logiche specifiche prima di inviare l'azione:
export const addItem = createAction("cart/addItem", (item: Item) => {
return {
payload: item,
};
});
Creiamo un file reducers.ts
in src/store/cart
. Qui trasferiremo i nostri reducers. I reducers sono già stati creati; sarà sufficiente spostarli dallo slice e inserirli in questo 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 consente a uno slice di rispondere a azioni che non sono state create direttamente all'interno dello slice stesso. È particolarmente utile per far reagire lo slice ad azioni definite altrove nell'applicazione.
Ora non ci resta che aggiornare lo slice.
Inseriremo le nostre actions e reducers nella sezione extraReducers
di createSlice
, poiché si tratta di logica esterna. L'uso del builder
in extraReducers
è molto utile; per ora, utilizziamo solamente addCase
per aggiungere le nostre azioni. Successivamente, esploreremo altre funzionalità che si possono implementare con il 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;
Ora è importante aggiornare gli import delle actions nei componenti, dato che abbiamo spostato le actions e non si trovano più in cartSlice.ts
.
Se sei pigro puoi usare questo in cartSlice.ts
senza aggiornare gli import nei componenti.
export { addItem, removeItem, removeAll, deleteCart };
Come creare azioni asincrone con createAsyncThunk
In un progetto reale che utilizza Redux Toolkit, ci possono essere azioni asincrone, ovvero azioni che modificano lo stato in maniera asincrona. Ad esempio, in un e-commerce, quando aggiungiamo un telefono dalla home, potrebbe essere necessario effettuare una chiamata API al backend, gestire lo stato di loading, aggiornare lo store slice del carrello se la chiamata ha successo e gestire eventuali errori. Per mantenere semplice questo tutorial di base, non creeremo azioni asincrone per ogni azione della nostra app ma ci limiteremo due azioni basiche come il caricamento dello shopping cart all'avvio dell'app e una per aggiornare il carrello dalla pagina del carrello.
Se desideri approfondire, ti consiglio di consultare la documentazione di createAsyncThunk
.
createAsyncThunk
createAsyncThunk
è una funzione di Redux Toolkit che semplifica la gestione delle azioni asincrone. Essa automatizza il processo di invio delle azioni per i vari stati di una richiesta asincrona: pending, fulfilled, e rejected.
Quando invochi createAsyncThunk
, devi fornire un tipo di azione come stringa e una funzione payload creator che ritorna una promise. Redux Toolkit gestisce automaticamente il ciclo di vita della promise, inviando azioni corrispondenti ai suoi stati di risoluzione o rifiuto. Questo elimina la necessità di scrivere manualmente i diversi case handlers nel reducer per gestire lo stato della richiesta.
Inoltre, createAsyncThunk
fornisce un parametro thunkAPI
che contiene funzioni e dati utili come dispatch
, getState
, e rejectWithValue
, offrendo una gestione flessibile degli scenari asincroni.
Prima di procedere, devi avviare il server mockato. Per farlo, usiamo la libreria json-server
:
npx json-server --watch db.json --port 5000
Se non riesci ad avviare il server, dai un'occhiata alla documentazione. Dovrebbe partire al primo tentativo. Lo scrivo perché la libreria json-server
si sta aggiornando frequentemente in questo periodo.
Nel file src/store/cart/actions.ts
creiamo le nostre azioni. Potremmo considerare la creazione di un file separato, ma per sole due azioni non conviene.
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;
}
);
Ora aggiorniamo lo slice.
Come nuova proprietà nel cartState
, abbiamo aggiunto isLoading
, utile per gestire uno stato di caricamento nella pagina. Le azioni create con createAsyncThunk
vanno nell'extraReducers
, dove in modo automatizzato vengono gestiti gli stati pending
, fulfilled
e rejected
.
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;
Ora non ci resta che utilizzare le nostre nuove azioni con l'hook useDispatch
. Questa volta, però, tipizziamo il dispatch
con il tipo AppDispatch
, che è necessario per la tipizzazione. In seguito, vedremo un piccolo trick per evitare di dover tipizzare ogni volta i selector e i dispatch.
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: Customs Hooks useAppDispatch e useAppSelector
Per evitare di tipizzare ogni volta useSelector
e useDispatch
, possiamo creare dei nostri hook personalizzati, evitando così la ripetizione.
Creiamo un file hooks.ts
all'interno della cartella src/store
.
import { useDispatch, useSelector } from "react-redux"
import { AppDispatch, RootState } from "./index"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
Una volta creati gli hook, sarà sufficiente sostituirli con useSelector
e useDispatch
nei componenti. Per non allungare troppo il tutorial, inserisco solo un frammento di codice per mostrare come effettuare la sostituzione.
//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
Un potente strumento che contraddistingue Redux è il Redux DevTools, disponibile come estensione del browser. Questo strumento è essenziale per il debug: registra tutte le azioni eseguite con i relativi payload, visualizza lo stato corrente e le differenze con lo stato precedente, offre controllo delle performance, facilita il testing e molto altro.
Il tool viene abilitato di default nel configureStore
tramite la proprietà devTools
. Questa proprietà accetta un valore booleano per abilitare o disabilitare gli strumenti, oppure un oggetto per configurare ulteriormente il tool.
const store = configureStore({
reducer: {
cart: cartReducer,
},
devTools: process.env.NODE_ENV !== "production",
});
Builder Methods e Matching Utilities
Come forse avrai notato, c'è un po' di codice boilerplate negli slice, dove le azioni pending e rejected gestiscono lo stato del loading nello stesso modo. Fortunatamente, Redux Toolkit offre strumenti per ridurre la ripetizione del codice.
Nel callback del builder nell'extraReducers nello slice, l'oggetto builder mette a disposizione, oltre a addCase
, anche addMatcher
e addDefaultCase
.
addMatcher
consente di abbinare le azioni basandosi sul tipo di azione: se corrisponde, attiva il reducer specifico.
Esempio pratico:
In questo esempio di addMatcher
, controlliamo se tutte le azioni terminano con "/pending" (ricorda che createAsyncThunk
genera automaticamente i nomi delle action). Se la condizione è soddisfatta, attiva il reducer corrispondente.
export const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addMatcher(
(action) => action.type.endsWith("/pending"),
(state, action) => {
state.isLoading = true;
}
);
},
});
addDefaultCase
serve per attivare un reducer che funge da caso predefinito, eseguito dopo tutti gli altri case specificati nel builder. Questo permette di gestire eventuali azioni non corrispondenti ad altri matcher specifici definiti nell'extraReducers
.
Matching Utilities
Ancora una volta, Redux Toolkit offre delle scorciatoie per evitare la ripetizione del codice. Possiamo usare le Matching Utilities: invece di (action) => action.type.endsWith("/pending")
, possiamo utilizzare isPending
di Redux Toolkit che automaticamente rileva se un'azione è in stato di attesa. Esiste anche isRejected
per riconoscere le azioni rifiutate. Esistono molte altre utility simili; ti consiglio di consultare la documentazione per maggiori dettagli.
import { createSlice, isPending } from "@reduxjs/toolkit";
export const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addMatcher(isPending, (state, action) => {
state.isLoading = true;
});
},
});
Aggiorniamo lo slice
Dopo aver esplorato queste utility, procediamo con l'aggiornamento del nostro codice.
Iniziamo spostando i nostri reducer dallo slice al file 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!");
};
Dopo aver copiato il codice ricordati di mettere l'export all'interface CartApiData in actions.ts
.
Ora aggiorniamo lo 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;
Conclusione
Se sei arrivato fin qui, ti faccio i miei complimenti! Ti ringrazio per aver letto il mio tutorial. Oggi abbiamo esplorato come utilizzare Redux Toolkit, una potente libreria per la gestione dello stato in React. Grazie a questo tutorial, ora potrai comprendere meglio come implementare, lavorare e applicare le best practices di Redux Toolkit nei tuoi progetti. Come ricompensa, ti lascio una modale globale realizzata con Redux Toolkit. Grazie e ciao.
Bonus: Creazione Modale Globale con redux toolkit
Dopo aver completato il nostro cart, come bonus a questo tutorial, creeremo una modale globale utilizzando Redux Toolkit.
Iniziamo creando uno slice, all'interno della cartella store creaiamo la cartella modal con all'interno il 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;
Ora aggiorniamo il 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
Successivamente, aggiorneremo il componente Modal
e la pagina Cart
, dove utilizzeremo la nostra modale, elimina poi ogni riferimento a 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
- Introduzione
- - Un po' di teoria
- Setup Iniziale
- - Configurazione dell'ambiente di sviluppo
- - Installazione delle dipendenze necessarie, Redux-Toolkit e React-Redux
- - Creazione redux store
- - Colleghiamo lo store a React
- - Creazione di uno Slice di Stato con Redux Toolkit
- Implementazione delle Funzionalità
- - Implementazione actions
- - Utilizzare lo Stato e le Azioni nei Componenti
- - useSelector
- - useDispatch
- - Creiamo tutte le altre actions CRUD
- TIPS: Refactorizziamo lo slice con createAction e extraReducers
- - createAction
- - extraReducers
- Come creare azioni asincrone con createAsyncThunk
- - createAsyncThunk
- TIPS: Customs Hooks useAppDispatch e useAppSelector
- Redux DevTools
- Builder Methods e Matching Utilities
- - addMatcher
- - addDefaultCase
- - Matching Utilities
- - Aggiorniamo lo slice
- Conclusione
- Bonus: Creazione Modale Globale con redux toolkit