Implementare un carrello di un e-commerce in Vue.js con Pinia
Gestire lo stato di un carrello è una delle sfide più comuni nello sviluppo di un e-commerce. Il carrello è un elemento trasversale: deve essere accessibile da qualsiasi pagina, deve reagire in tempo reale alle azioni dell'utente e deve mantenere la coerenza dei dati tra componenti distanti nell'albero dell'applicazione. Vue.js, abbinato a Pinia come store manager, offre una soluzione elegante, tipizzata e scalabile per questo problema.
In questo articolo costruiremo passo dopo passo un carrello completo: dallo store Pinia che ne modella la logica, ai componenti Vue che lo consumano, fino alla gestione di quantità, rimozione prodotti e calcolo dei totali.
Prerequisiti e setup del progetto
Per seguire l'articolo è necessario avere Node.js installato (versione 18 o superiore) e una conoscenza di base di Vue.js 3 con la Composition API. Creiamo un nuovo progetto con Vite e aggiungiamo Pinia:
# Creazione del progetto Vue con Vite
npm create vite@latest ecommerce-cart -- --template vue
cd ecommerce-cart
npm install
# Installazione di Pinia
npm install pinia
Il primo passaggio è registrare Pinia nell'applicazione Vue. Apriamo il file di ingresso e configuriamo lo store:
// main.js — Punto di ingresso dell'applicazione
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
// Registrazione di Pinia come plugin globale
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
Con queste poche righe, ogni componente dell'applicazione potrà accedere agli store Pinia senza alcuna configurazione aggiuntiva.
Definire il modello dei dati
Prima di scrivere lo store, è utile stabilire la forma dei dati con cui lavoreremo. In un e-commerce reale i prodotti hanno decine di proprietà, ma per il nostro scopo ne bastano poche, essenziali. Definiamo due interfacce concettuali: il prodotto così come arriva dal catalogo e l'elemento del carrello, che aggiunge la nozione di quantità.
Un prodotto del catalogo avrà un identificativo univoco, un nome, un prezzo e un'immagine. Un elemento del carrello conterrà lo stesso prodotto, arricchito dalla quantità selezionata dall'utente. Questa separazione è importante: il catalogo e il carrello sono due domini distinti, e mantenerli separati rende il codice più chiaro e manutenibile.
Lo store del carrello con Pinia
Pinia supporta due sintassi per definire uno store: la Option API (con state, getters e actions espliciti) e la Setup API, che ricalca la Composition API di Vue. Useremo la Option API perché rende immediatamente visibile la struttura dello store, ma entrambe le forme sono equivalenti in termini di funzionalità.
// stores/cart.js — Store Pinia per la gestione del carrello
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
// Lista degli elementi nel carrello
items: []
}),
getters: {
// Numero totale di pezzi nel carrello
totalItems(state) {
return state.items.reduce((sum, item) => sum + item.quantity, 0)
},
// Prezzo totale del carrello
totalPrice(state) {
return state.items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
)
},
// Verifica se il carrello è vuoto
isEmpty(state) {
return state.items.length === 0
},
// Cerca un elemento nel carrello tramite l'id del prodotto
getItemByProductId(state) {
return (productId) =>
state.items.find((item) => item.product.id === productId)
}
},
actions: {
// Aggiunge un prodotto al carrello o ne incrementa la quantità
addProduct(product, quantity = 1) {
const existingItem = this.getItemByProductId(product.id)
if (existingItem) {
existingItem.quantity += quantity
} else {
this.items.push({
product: { ...product },
quantity
})
}
},
// Rimuove completamente un prodotto dal carrello
removeProduct(productId) {
const index = this.items.findIndex(
(item) => item.product.id === productId
)
if (index !== -1) {
this.items.splice(index, 1)
}
},
// Aggiorna la quantità di un elemento specifico
updateQuantity(productId, newQuantity) {
const item = this.getItemByProductId(productId)
if (!item) return
// Se la quantità scende a zero o meno, rimuoviamo il prodotto
if (newQuantity <= 0) {
this.removeProduct(productId)
return
}
item.quantity = newQuantity
},
// Svuota completamente il carrello
clearCart() {
this.items = []
}
}
})
Analizziamo le scelte progettuali di questo store. Lo state contiene un unico array items: ogni elemento è un oggetto con una proprietà product (una copia del prodotto originale) e una proprietà quantity. La copia è intenzionale: se il catalogo aggiornasse un prezzo, il carrello manterrebbe il prezzo al momento dell'aggiunta, comportamento tipico di un e-commerce reale.
I getters derivano informazioni dallo stato senza duplicarlo. totalItems e totalPrice sono calcolati in modo reattivo: ogni volta che items cambia, Vue ricalcola automaticamente questi valori e aggiorna tutti i componenti che li utilizzano. Il getter getItemByProductId restituisce una funzione anziché un valore: questo pattern, chiamato getter parametrizzato, permette di cercare un elemento passando un argomento.
Le actions sono i metodi che modificano lo stato. addProduct controlla se il prodotto è già presente nel carrello: in caso affermativo ne incrementa la quantità, altrimenti lo aggiunge come nuovo elemento. updateQuantity gestisce anche il caso limite in cui la quantità scende a zero, delegando la rimozione a removeProduct.
Il componente scheda prodotto
Creiamo ora un componente che rappresenta un singolo prodotto del catalogo. La sua responsabilità è mostrare le informazioni del prodotto e offrire un pulsante per aggiungerlo al carrello.
<!-- components/ProductCard.vue — Scheda prodotto del catalogo -->
<template>
<li class="product-card">
<img
:src="product.image"
:alt="product.name"
class="product-card__image"
/>
<h3 class="product-card__name">{{ product.name }}</h3>
<p class="product-card__price">€ {{ formattedPrice }}</p>
<button
class="product-card__button"
@click="handleAddToCart"
>
Aggiungi al carrello
</button>
</li>
</template>
<script setup>
import { computed } from 'vue'
import { useCartStore } from '../stores/cart'
// Il prodotto viene ricevuto come prop dal componente genitore
const props = defineProps({
product: {
type: Object,
required: true
}
})
const cart = useCartStore()
// Formattazione del prezzo con due decimali
const formattedPrice = computed(() => {
return props.product.price.toFixed(2)
})
// Gestione del click sul pulsante di aggiunta
function handleAddToCart() {
cart.addProduct(props.product)
}
</script>
Il componente è volutamente semplice. Riceve il prodotto come prop, formatta il prezzo e, al click del pulsante, invoca l'action addProduct dello store. Non mantiene alcuno stato locale: tutta la logica del carrello è centralizzata nello store.
La lista prodotti
Per mostrare il catalogo, creiamo un componente che itera su un array di prodotti e renderizza una ProductCard per ciascuno. In un'applicazione reale, questi dati arriverebbero da un'API; qui li definiamo staticamente per concentrarci sulla logica del carrello.
<!-- components/ProductList.vue — Griglia dei prodotti del catalogo -->
<template>
<h2>Catalogo prodotti</h2>
<ul class="product-list">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</ul>
</template>
<script setup>
import { ref } from 'vue'
import ProductCard from './ProductCard.vue'
// Dati di esempio per il catalogo
const products = ref([
{
id: 1,
name: 'Sneaker in pelle bianca',
price: 89.99,
image: '/images/sneaker-white.jpg'
},
{
id: 2,
name: 'Zaino impermeabile',
price: 54.50,
image: '/images/backpack.jpg'
},
{
id: 3,
name: 'Orologio minimal',
price: 129.00,
image: '/images/watch.jpg'
},
{
id: 4,
name: 'Occhiali da sole polarizzati',
price: 42.00,
image: '/images/sunglasses.jpg'
}
])
</script>
Il componente riga del carrello
Ogni elemento nel carrello merita un componente dedicato. La riga del carrello mostra il prodotto, la quantità attuale, i controlli per modificarla e il subtotale. È il componente più interattivo dell'intero flusso.
<!-- components/CartItem.vue — Singola riga del carrello -->
<template>
<li class="cart-item">
<img
:src="item.product.image"
:alt="item.product.name"
class="cart-item__image"
/>
<h3 class="cart-item__name">{{ item.product.name }}</h3>
<p class="cart-item__unit-price">
€ {{ item.product.price.toFixed(2) }} cad.
</p>
<!-- Controlli per la quantità -->
<fieldset class="cart-item__quantity-controls">
<legend class="visually-hidden">Quantità</legend>
<button
@click="decrementQuantity"
:disabled="item.quantity <= 1"
aria-label="Diminuisci quantità"
>
−
</button>
<input
type="number"
:value="item.quantity"
min="1"
max="99"
@change="handleQuantityChange"
aria-label="Quantità"
/>
<button
@click="incrementQuantity"
aria-label="Aumenta quantità"
>
+
</button>
</fieldset>
<p class="cart-item__subtotal">
€ {{ subtotal }}
</p>
<button
class="cart-item__remove"
@click="handleRemove"
aria-label="Rimuovi dal carrello"
>
Rimuovi
</button>
</li>
</template>
<script setup>
import { computed } from 'vue'
import { useCartStore } from '../stores/cart'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const cart = useCartStore()
// Calcolo del subtotale per questa riga
const subtotal = computed(() => {
return (props.item.product.price * props.item.quantity).toFixed(2)
})
function incrementQuantity() {
cart.updateQuantity(
props.item.product.id,
props.item.quantity + 1
)
}
function decrementQuantity() {
cart.updateQuantity(
props.item.product.id,
props.item.quantity - 1
)
}
// Gestione dell'input manuale della quantità
function handleQuantityChange(event) {
const value = parseInt(event.target.value, 10)
if (isNaN(value) || value < 1) {
// Ripristino al valore precedente se l'input non è valido
event.target.value = props.item.quantity
return
}
cart.updateQuantity(props.item.product.id, value)
}
function handleRemove() {
cart.removeProduct(props.item.product.id)
}
</script>
Notiamo come il componente non modifichi mai direttamente lo stato dello store. Ogni interazione — incremento, decremento, modifica manuale, rimozione — passa attraverso le actions di Pinia. Questo principio, noto come flusso unidirezionale dei dati, rende il comportamento dell'applicazione prevedibile e facilmente debuggabile.
L'input numerico per la quantità merita attenzione: quando l'utente inserisce un valore manualmente, lo validiamo nell'handler handleQuantityChange. Se il valore non è un numero valido o è inferiore a 1, ripristiniamo il campo al valore corrente senza modificare lo store.
Il componente riepilogo del carrello
Il riepilogo unisce tutti i pezzi: mostra la lista degli elementi, il totale e i pulsanti per procedere al checkout o svuotare il carrello.
<!-- components/CartSummary.vue — Riepilogo completo del carrello -->
<template>
<h2>Il tuo carrello</h2>
<p v-if="cart.isEmpty">
Il carrello è vuoto. Esplora il catalogo per aggiungere prodotti.
</p>
<template v-else>
<p class="cart-summary__count">
{{ cart.totalItems }} articoli nel carrello
</p>
<ul class="cart-summary__list">
<CartItem
v-for="item in cart.items"
:key="item.product.id"
:item="item"
/>
</ul>
<p class="cart-summary__total">
<strong>Totale: € {{ formattedTotal }}</strong>
</p>
<button
class="cart-summary__checkout"
@click="handleCheckout"
>
Procedi al pagamento
</button>
<button
class="cart-summary__clear"
@click="cart.clearCart()"
>
Svuota carrello
</button>
</template>
</template>
<script setup>
import { computed } from 'vue'
import { useCartStore } from '../stores/cart'
import CartItem from './CartItem.vue'
const cart = useCartStore()
// Formattazione del totale con due decimali
const formattedTotal = computed(() => {
return cart.totalPrice.toFixed(2)
})
// Gestione del click su "Procedi al pagamento"
function handleCheckout() {
// In un'applicazione reale qui si navigherebbe alla pagina di checkout
console.log('Checkout avviato con', cart.items.length, 'prodotti')
console.log('Totale:', cart.totalPrice)
}
</script>
Il componente sfrutta il rendering condizionale di Vue con v-if e v-else per mostrare un messaggio quando il carrello è vuoto e il riepilogo completo in caso contrario. I getters isEmpty, totalItems e totalPrice dello store guidano tutta la presentazione.
Composizione nell'App principale
Assembliamo i componenti nel file radice dell'applicazione:
<!-- App.vue — Componente radice dell'applicazione -->
<template>
<h1>Il nostro negozio</h1>
<ProductList />
<CartSummary />
</template>
<script setup>
import ProductList from './components/ProductList.vue'
import CartSummary from './components/CartSummary.vue'
</script>
La semplicità di questo file dimostra uno dei vantaggi principali di Pinia: i componenti ProductList e CartSummary non hanno bisogno di comunicare tra loro tramite props o eventi. Entrambi accedono allo stesso store e reagiscono automaticamente ai cambiamenti di stato.
Persistenza del carrello con localStorage
In un e-commerce reale, l'utente si aspetta di ritrovare il proprio carrello anche dopo aver chiuso il browser. Pinia offre un meccanismo elegante per aggiungere questa funzionalità: i plugin. Un plugin Pinia è una funzione che viene eseguita per ogni store e può agganciare comportamenti aggiuntivi.
// plugins/cartPersistence.js — Plugin Pinia per la persistenza del carrello
export function cartPersistencePlugin({ store }) {
// Il plugin agisce solo sullo store del carrello
if (store.$id !== 'cart') return
const STORAGE_KEY = 'ecommerce-cart'
// Ripristino dello stato salvato al caricamento
const savedState = localStorage.getItem(STORAGE_KEY)
if (savedState) {
try {
const parsed = JSON.parse(savedState)
store.$patch({ items: parsed.items || [] })
} catch (error) {
// Se il dato salvato è corrotto, lo ignoriamo
console.warn('Stato del carrello non valido, ripristino ignorato.')
localStorage.removeItem(STORAGE_KEY)
}
}
// Salvataggio automatico ad ogni mutazione dello stato
store.$subscribe((_mutation, state) => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ items: state.items })
)
})
}
Registriamo il plugin nel file di ingresso:
// main.js — Aggiunta del plugin di persistenza
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { cartPersistencePlugin } from './plugins/cartPersistence'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// Registrazione del plugin prima del montaggio dell'app
pinia.use(cartPersistencePlugin)
app.use(pinia)
app.mount('#app')
Il plugin utilizza store.$subscribe, un metodo di Pinia che viene invocato ogni volta che lo stato dello store cambia. Ad ogni modifica, lo stato del carrello viene serializzato in JSON e salvato in localStorage. Al caricamento successivo, il plugin legge il dato salvato e lo ripristina con store.$patch. Il blocco try/catch protegge l'applicazione da dati corrotti o incompatibili.
Composable per la logica di utilità
Con la crescita dell'applicazione, è utile estrarre logica riutilizzabile in composable. Un composable è una funzione che incapsula logica reattiva e può essere importata in qualsiasi componente. Creiamone uno che fornisce metodi di utilità legati al carrello:
// composables/useCartHelpers.js — Composable con utilità per il carrello
import { computed } from 'vue'
import { useCartStore } from '../stores/cart'
export function useCartHelpers() {
const cart = useCartStore()
// Verifica se un prodotto specifico è già nel carrello
const isInCart = (productId) => {
return computed(() => !!cart.getItemByProductId(productId))
}
// Restituisce la quantità di un prodotto nel carrello (0 se assente)
const getQuantity = (productId) => {
return computed(() => {
const item = cart.getItemByProductId(productId)
return item ? item.quantity : 0
})
}
// Formatta un prezzo in euro con due decimali
const formatPrice = (amount) => {
return new Intl.NumberFormat('it-IT', {
style: 'currency',
currency: 'EUR'
}).format(amount)
}
return {
isInCart,
getQuantity,
formatPrice
}
}
Questo composable è particolarmente utile nella scheda prodotto: possiamo mostrare un'etichetta diversa sul pulsante se il prodotto è già nel carrello, oppure visualizzare direttamente la quantità attuale.
<!-- Esempio di utilizzo del composable in ProductCard.vue -->
<template>
<li class="product-card">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p>{{ formatPrice(product.price) }}</p>
<button
v-if="!alreadyInCart"
@click="cart.addProduct(product)"
>
Aggiungi al carrello
</button>
<!-- Se il prodotto è già nel carrello, mostriamo la quantità -->
<p v-else>
Nel carrello: {{ currentQuantity }}
<button @click="cart.addProduct(product)">+1</button>
</p>
</li>
</template>
<script setup>
import { useCartStore } from '../stores/cart'
import { useCartHelpers } from '../composables/useCartHelpers'
const props = defineProps({
product: {
type: Object,
required: true
}
})
const cart = useCartStore()
const { isInCart, getQuantity, formatPrice } = useCartHelpers()
// Valori reattivi calcolati per il prodotto corrente
const alreadyInCart = isInCart(props.product.id)
const currentQuantity = getQuantity(props.product.id)
</script>
Test dello store
Uno dei vantaggi di centralizzare la logica in uno store Pinia è la facilità con cui possiamo testarla in isolamento. Lo store è una funzione pura che opera su uno stato prevedibile: non richiede il rendering di componenti e non dipende dal DOM.
// stores/__tests__/cart.spec.js — Test unitari per lo store del carrello
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '../cart'
// Prodotto di esempio utilizzato nei test
const sampleProduct = {
id: 1,
name: 'Prodotto di test',
price: 25.00,
image: '/test.jpg'
}
describe('Cart Store', () => {
let cart
beforeEach(() => {
// Ogni test parte con uno store pulito
setActivePinia(createPinia())
cart = useCartStore()
})
it('parte con un carrello vuoto', () => {
expect(cart.items).toHaveLength(0)
expect(cart.isEmpty).toBe(true)
expect(cart.totalItems).toBe(0)
expect(cart.totalPrice).toBe(0)
})
it('aggiunge un prodotto al carrello', () => {
cart.addProduct(sampleProduct)
expect(cart.items).toHaveLength(1)
expect(cart.items[0].product.name).toBe('Prodotto di test')
expect(cart.items[0].quantity).toBe(1)
})
it('incrementa la quantità se il prodotto è già presente', () => {
cart.addProduct(sampleProduct)
cart.addProduct(sampleProduct)
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(2)
})
it('calcola correttamente il totale', () => {
cart.addProduct(sampleProduct, 3)
// 25.00 * 3 = 75.00
expect(cart.totalPrice).toBe(75.00)
expect(cart.totalItems).toBe(3)
})
it('rimuove un prodotto dal carrello', () => {
cart.addProduct(sampleProduct)
cart.removeProduct(sampleProduct.id)
expect(cart.items).toHaveLength(0)
expect(cart.isEmpty).toBe(true)
})
it('aggiorna la quantità di un prodotto', () => {
cart.addProduct(sampleProduct)
cart.updateQuantity(sampleProduct.id, 5)
expect(cart.items[0].quantity).toBe(5)
})
it('rimuove il prodotto se la quantità scende a zero', () => {
cart.addProduct(sampleProduct)
cart.updateQuantity(sampleProduct.id, 0)
expect(cart.items).toHaveLength(0)
})
it('svuota completamente il carrello', () => {
cart.addProduct(sampleProduct)
cart.addProduct({ ...sampleProduct, id: 2, name: 'Altro prodotto' })
cart.clearCart()
expect(cart.items).toHaveLength(0)
expect(cart.isEmpty).toBe(true)
})
})
I test coprono tutti i casi principali: aggiunta, incremento, rimozione, aggiornamento quantità, gestione del caso limite della quantità a zero e svuotamento completo. Con Vitest, eseguirli è immediato:
# Installazione di Vitest come dipendenza di sviluppo
npm install -D vitest
# Esecuzione dei test
npx vitest run
Struttura finale del progetto
Al termine dello sviluppo, la struttura delle cartelle sarà la seguente:
ecommerce-cart/
src/
components/
ProductCard.vue
ProductList.vue
CartItem.vue
CartSummary.vue
composables/
useCartHelpers.js
plugins/
cartPersistence.js
stores/
cart.js
__tests__/
cart.spec.js
App.vue
main.js
Ogni cartella ha una responsabilità chiara: stores per la logica di stato, components per la presentazione, composables per la logica riutilizzabile, plugins per le estensioni di Pinia. Questa organizzazione scala naturalmente: aggiungere uno store per gli ordini, un composable per i coupon o un plugin per la sincronizzazione con il server richiede semplicemente un nuovo file nella cartella appropriata.
Considerazioni per la produzione
Il carrello che abbiamo costruito copre la maggior parte dei casi d'uso comuni, ma un'applicazione destinata alla produzione richiede alcune attenzioni aggiuntive.
La validazione lato server è fondamentale. Il carrello nel browser è un'interfaccia utente, non la fonte di verità. Al momento del checkout, il server deve verificare la disponibilità dei prodotti, i prezzi correnti e applicare le regole di business (limiti di quantità, restrizioni geografiche, promozioni). Lo store Pinia gestisce lo stato locale; il server gestisce lo stato autorevole.
La gestione degli errori di rete diventa rilevante quando il carrello si sincronizza con un backend. Le actions di Pinia supportano nativamente le operazioni asincrone: basta dichiarare l'action come async e utilizzare await per le chiamate API. È buona pratica aggiungere allo state una proprietà loading e una error per permettere ai componenti di mostrare feedback appropriati.
Le prestazioni possono diventare un tema con carrelli molto grandi. Se un utente aggiunge decine di prodotti diversi, il calcolo dei totali ad ogni modifica resta comunque efficiente grazie alla reattività granulare di Vue. Tuttavia, se il componente CartItem diventa complesso, vale la pena considerare l'uso di v-memo o di componenti asincroni per ridurre il lavoro di rendering.
Infine, l'accessibilità non va trascurata. I controlli di quantità dovrebbero essere navigabili da tastiera, i pulsanti di rimozione dovrebbero avere etichette aria-label descrittive (come già fatto nel nostro codice), e le modifiche al carrello dovrebbero essere annunciate agli screen reader tramite regioni aria-live.
Conclusione
Abbiamo costruito un carrello e-commerce completo utilizzando Vue.js 3 e Pinia, partendo dalla definizione dello store fino alla persistenza dei dati e ai test unitari. L'architettura risultante è modulare, testabile e pronta per essere estesa con funzionalità aggiuntive come coupon, spedizioni multiple o integrazione con gateway di pagamento.
Pinia si è rivelato uno strumento ideale per questo tipo di logica: leggero, completamente tipizzabile, con un'API intuitiva che si integra naturalmente con la Composition API di Vue. La separazione tra store, componenti e composable mantiene il codice organizzato anche quando la complessità cresce, e la possibilità di testare lo store in isolamento garantisce che la logica di business resti affidabile nel tempo.