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.