LearnAI ToolsCareerPractice BuildsPlayContact
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.