LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
Vue.jsIntermediate~2 hours

Shopping Cart

Build a cart with shared state across components using a Pinia store.

PiniaComposablesProps & Emits

Overview

A shopping cart is the standard project for learning shared state because it has a real, unavoidable problem: a ProductList component needs to add items to the cart, and a completely separate CartSummary component needs to display and update those same items — with neither one a parent of the other. Passing the cart down through props from a common ancestor would mean threading it through components that don't actually use it themselves.

Instead, this project manages the cart with Pinia, Vue's official state-management library. A Pinia store defined with the "setup store" syntax looks just like a composable — ref for state, computed for derived values, plain functions for actions — but any component anywhere in the tree can call useCartStore() and get back the exact same shared instance, no prop drilling required.

What You'll Build
  • A useCartStore Pinia store holding items, with addItem/removeItem/updateQuantity actions.
  • itemCount and subtotal as computed getters on the store, always derived from items, never duplicated.
  • A ProductList component that calls cart.addItem(product) directly, with zero cart-related props passed in.
  • A CartItem component demonstrating classic props-in / emits-out — it receives one cart line and emits quantity/removal changes.
  • A useCartSummary composable that wraps the store to expose formatted, display-ready values.
  • Two sibling components sharing one source of truth with zero prop drilling between them.

Prerequisites

  • Vue template syntax and Single File Components — comfortable from the Todo App project above.
  • ref/computed fundamentals — a Pinia setup store is written with the exact same building blocks.
  • Props and defineEmits — used throughout CartItem below.
  • Array methods — find, findIndex, map, and reduce, used throughout the store and the cart totals.
  • Pinia, composables, and provide/inject are new here — all are introduced from the ground up in this tutorial.

Project Structure

The cart store lives in its own file so any component in the tree can import and call it. ProductList and CartSummary are true siblings — both rendered directly inside App, next to each other — which is exactly the layout that would otherwise force prop drilling.

src/
stores/
cart.js // defineStore('cart', ...) — items, actions, and computed getters
composables/
useCartSummary.js // Wraps the store with formatted, display-ready values
products.js // Static list of products available to add to the cart
ProductList.vue // Reads nothing from the cart, only calls store actions
CartItem.vue // One cart row — props in, events out
CartSummary.vue // Reads cart items via the store + renders a list of CartItem
App.vue // Renders ProductList and CartSummary side by side
main.js // Creates the app, installs Pinia, mounts it
style.css // Shared dark-theme styling
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.shop-app { width: 100%; max-width: 480px; background: #23262e; border-radius: 10px; padding: 24px; }
.shop-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.shop-app h2 { margin: 16px 0 10px; font-size: 1.1rem; }
.product-row, .cart-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #3a3f4b;
}
.product-row span:first-child, .cart-row span:first-child { flex: 1; }
.product-row button, .cart-row button {
padding: 6px 12px;
border: none;
border-radius: 6px;
background: #42b883;
color: white;
cursor: pointer;
font-size: 0.85rem;
}
.cart-row button { background: #5a3a3a; }
.cart-row input[type="number"] {
width: 48px;
padding: 4px;
border-radius: 4px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
text-align: center;
}
.cart-total { margin-top: 12px; font-weight: bold; text-align: right; font-size: 1.1rem; }
.cart-empty { color: #888; padding: 8px 0; }

Step 1: Define Product Data and the Cart State Shape

The product catalog is static data, not state — it never changes while the app runs, so it lives as a plain exported array rather than in a ref. The cart is designed as a flat array of { id, name, price, quantity } objects, so the store's actions can lean on ordinary array methods instead of juggling object-key bookkeeping.

// products.js
export const PRODUCTS = [
{ id: 1, name: 'Wireless Mouse', price: 24.99 },
{ id: 2, name: 'Mechanical Keyboard', price: 89.99 },
{ id: 3, name: 'USB-C Hub', price: 34.5 },
{ id: 4, name: 'Laptop Stand', price: 42.0 },
];
// Cart state shape, used throughout stores/cart.js:
// items: [{ id, name, price, quantity }]
//
// Deliberately a flat array (not an object keyed by id) so the store's
// actions below can lean on plain array methods (find/findIndex) instead of
// juggling object-key bookkeeping

Step 2: Create the Cart Store with Pinia

This is Pinia's "setup store" syntax — the function you pass to defineStore is written exactly like a composable. Whatever it returns becomes the store's public surface: items and the computed getters are readable state, and the plain functions are the store's actions. Any component that calls useCartStore() gets back this exact same reactive object, not a copy.

// stores/cart.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
const items = ref([]); // { id, name, price, quantity }[]
function addItem(product) {
const existing = items.value.find((item) => item.id === product.id);
if (existing) {
// Already in the cart — bump its quantity instead of adding a
// duplicate row. Pinia state is reactive the same way a component's
// own ref is, so mutating it directly is fine and expected
existing.quantity++;
} else {
// First time this product is added — spread its fields and start quantity at 1
items.value.push({ ...product, quantity: 1 });
}
}
function removeItem(id) {
const index = items.value.findIndex((item) => item.id === id);
if (index !== -1) {
items.value.splice(index, 1);
}
}
function updateQuantity(id, quantity) {
if (quantity < 1) {
// Treat "quantity 0 or less" as a removal rather than leaving a
// useless zero-quantity row in the cart
removeItem(id);
return;
}
const item = items.value.find((item) => item.id === id);
if (item) {
item.quantity = quantity;
}
}
// computed getters — always derived from items, never stored separately,
// so they can never drift out of sync with the actual cart contents
const itemCount = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0));
const subtotal = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0));
// Only what's returned here is part of the store's public API — every
// component using useCartStore() sees exactly this shape
return { items, addItem, removeItem, updateQuantity, itemCount, subtotal };
});
How State Flows Through the Store

Click Run to see what this code prints.

Step 3: Build the Product List

ProductList calls useCartStore() directly — no props connect it to the cart, and no common ancestor passes anything down. That single line is what a real component needs to reach into fully shared, app-wide state.

<!-- ProductList.vue -->
<script setup>
import { PRODUCTS } from '../products';
import { useCartStore } from '../stores/cart';
const cart = useCartStore(); // same shared instance every other component that calls this also gets
</script>
<template>
<div class="product-list">
<h2>Products</h2>
<div class="product-row" v-for="product in PRODUCTS" :key="product.id">
<span>{{ product.name }}</span>
<span>{{ '$' + product.price.toFixed(2) }}</span>
<button type="button" @click="cart.addItem(product)">Add to Cart</button>
</div>
</div>
</template>

Step 4: Build a CartItem Component with Props and Emits

Rather than have CartSummary build every row's markup inline, CartItem is its own component — it receives one cart line as an item prop and emits update-quantity/remove events, the same props-in / events-out shape as TodoItem in the previous project. This keeps CartItem reusable and fully decoupled from Pinia: it doesn't know the store exists at all.

<!-- CartItem.vue -->
<script setup>
// CartItem deliberately knows nothing about the store — it only deals in
// props and emitted events, so it could just as easily be reused with data
// that came from somewhere other than Pinia
defineProps({ item: Object });
const emit = defineEmits(['update-quantity', 'remove']);
</script>
<template>
<div class="cart-row">
<span>{{ item.name }}</span>
<input
type="number"
min="1"
:value="item.quantity"
@input="emit('update-quantity', item.id, Number($event.target.value))"
/>
<span>{{ '$' + (item.price * item.quantity).toFixed(2) }}</span>
<button type="button" @click="emit('remove', item.id)">Remove</button>
</div>
</template>
<!-- CartSummary.vue -->
<script setup>
import { useCartStore } from '../stores/cart';
import CartItem from './CartItem.vue';
const cart = useCartStore(); // reads state straight from the store — no prop passed it that data from App
</script>
<template>
<div class="cart-summary">
<h2>Cart</h2>
<p v-if="cart.items.length === 0" class="cart-empty">Your cart is empty.</p>
<!-- CartItem's emitted events are wired straight to store actions —
Pinia actions are just functions, so they can be event handlers directly -->
<CartItem
v-for="item in cart.items"
:key="item.id"
:item="item"
@update-quantity="cart.updateQuantity"
@remove="cart.removeItem"
/>
<p v-if="cart.items.length > 0" class="cart-total">{{ 'Total: $' + cart.subtotal.toFixed(2) }}</p>
</div>
</template>

Step 5: Extract a useCartSummary Composable

A composable is just a plain function that packages up reactive logic for reuse — it is the exact same shape a Pinia setup store uses internally, only without defineStore wrapping it into a shared singleton. Here, useCartSummary wraps the cart store to expose formatted, display-ready values, so a component like a navbar badge doesn't need to know the store's raw shape or repeat formatting logic.

// composables/useCartSummary.js
import { computed } from 'vue';
import { useCartStore } from '../stores/cart';
// Wrapping a store call inside a composable is a common Vue pattern: the
// composable can add its own computed values on top of the store's raw
// state, without that formatting logic needing to live in every component
// that wants to show a "cart badge" or price summary
export function useCartSummary() {
const cart = useCartStore();
const formattedSubtotal = computed(() => '$' + cart.subtotal.toFixed(2));
const badgeLabel = computed(() => (cart.itemCount === 0 ? '' : String(cart.itemCount)));
return { formattedSubtotal, badgeLabel, itemCount: cart.itemCount };
}

Any component — a navbar, a checkout header, a footer summary — can now call useCartSummary() and get ready-to-render values, instead of importing the store directly and re-deriving the same formatting every time.

Step 6: Register Pinia and Wire It Together

Pinia itself has to be installed on the app instance before any component's useCartStore() call will work — app.use(createPinia()) is what makes that possible. Once that's done, App doesn't need to hold or forward the cart at all; it just renders ProductList and CartSummary side by side, and both automatically share the same store.

// main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia()); // registers Pinia so every component's useCartStore() call resolves to the same shared instance
app.mount('#app');
<!-- App.vue -->
<script setup>
import ProductList from './ProductList.vue';
import CartSummary from './CartSummary.vue';
</script>
<template>
<div class="shop-app">
<h1>Shop</h1>
<ProductList />
<CartSummary />
</div>
</template>

Complete Code

Here is the complete project — every file, fully assembled from the steps above, in the order they would sit in a real Vite-scaffolded Vue project.

// products.js
export const PRODUCTS = [
{ id: 1, name: 'Wireless Mouse', price: 24.99 },
{ id: 2, name: 'Mechanical Keyboard', price: 89.99 },
{ id: 3, name: 'USB-C Hub', price: 34.5 },
{ id: 4, name: 'Laptop Stand', price: 42.0 },
];
// stores/cart.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
function addItem(product) {
const existing = items.value.find((item) => item.id === product.id);
if (existing) {
existing.quantity++;
} else {
items.value.push({ ...product, quantity: 1 });
}
}
function removeItem(id) {
const index = items.value.findIndex((item) => item.id === id);
if (index !== -1) items.value.splice(index, 1);
}
function updateQuantity(id, quantity) {
if (quantity < 1) {
removeItem(id);
return;
}
const item = items.value.find((item) => item.id === id);
if (item) item.quantity = quantity;
}
const itemCount = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0));
const subtotal = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0));
return { items, addItem, removeItem, updateQuantity, itemCount, subtotal };
});
// composables/useCartSummary.js
import { computed } from 'vue';
import { useCartStore } from '../stores/cart';
export function useCartSummary() {
const cart = useCartStore();
const formattedSubtotal = computed(() => '$' + cart.subtotal.toFixed(2));
const badgeLabel = computed(() => (cart.itemCount === 0 ? '' : String(cart.itemCount)));
return { formattedSubtotal, badgeLabel, itemCount: cart.itemCount };
}
<!-- ProductList.vue -->
<script setup>
import { PRODUCTS } from '../products';
import { useCartStore } from '../stores/cart';
const cart = useCartStore();
</script>
<template>
<div class="product-list">
<h2>Products</h2>
<div class="product-row" v-for="product in PRODUCTS" :key="product.id">
<span>{{ product.name }}</span>
<span>{{ '$' + product.price.toFixed(2) }}</span>
<button type="button" @click="cart.addItem(product)">Add to Cart</button>
</div>
</div>
</template>
<!-- CartItem.vue -->
<script setup>
defineProps({ item: Object });
const emit = defineEmits(['update-quantity', 'remove']);
</script>
<template>
<div class="cart-row">
<span>{{ item.name }}</span>
<input type="number" min="1" :value="item.quantity" @input="emit('update-quantity', item.id, Number($event.target.value))" />
<span>{{ '$' + (item.price * item.quantity).toFixed(2) }}</span>
<button type="button" @click="emit('remove', item.id)">Remove</button>
</div>
</template>
<!-- CartSummary.vue -->
<script setup>
import { useCartStore } from '../stores/cart';
import CartItem from './CartItem.vue';
const cart = useCartStore();
</script>
<template>
<div class="cart-summary">
<h2>Cart</h2>
<p v-if="cart.items.length === 0" class="cart-empty">Your cart is empty.</p>
<CartItem
v-for="item in cart.items"
:key="item.id"
:item="item"
@update-quantity="cart.updateQuantity"
@remove="cart.removeItem"
/>
<p v-if="cart.items.length > 0" class="cart-total">{{ 'Total: $' + cart.subtotal.toFixed(2) }}</p>
</div>
</template>
<!-- App.vue -->
<script setup>
import ProductList from './ProductList.vue';
import CartSummary from './CartSummary.vue';
</script>
<template>
<div class="shop-app">
<h1>Shop</h1>
<ProductList />
<CartSummary />
</div>
</template>

The live preview below teaches the same store-based sharing concept but swaps real Pinia for a hand-written equivalent built from reactive() plus provide/inject. Loading Pinia's own package from a CDN alongside Vue requires an extra dependency (vue-demi) with a finicky script-load order that isn't reliable inside a sandboxed preview iframe, so the demo below stands in for it: provide makes one shared reactive cart object reachable by name from App, and any descendant calls inject() to reach the exact same object — the same "one instance, reachable from anywhere" property Pinia gives you, built from Vue's own lower-level primitives instead of the Pinia package. Every component's template and logic below is otherwise identical to the taught code above.

Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a "Checkout" action to the store that clears items and shows a thank-you message.
  • Persist items to localStorage inside the store with a watch, the same pattern used in the Todo App project.
  • Add quantity stepper +/- buttons to CartItem instead of a raw number input, emitting update-quantity with item.quantity + 1 or - 1.
  • Add a navbar component that calls useCartSummary() to show a live item-count badge, proving the composable works from a completely different part of the tree.
  • Split validation (e.g. a max-quantity limit) out of the store and into its own composable that both the store and CartItem can share.

Summary

You built a shopping cart where two sibling components — ProductList and CartSummary — share one piece of state through a Pinia store, with a CartItem component demonstrating clean props-in / events-out composition, and a useCartSummary composable showing how to package reactive logic for reuse. This combination — a store for "who can reach the state," composables for "how logic gets reused," and props/emits for "how components without shared state cooperate" — is the standard shape of a small-to-medium Vue application.