Time to combine what you have learned. In this lesson you build a small shopping cart using signals, computed values and the control flow blocks. The goal is to see how little code is needed when state lives in signals.
What you will build
- A list of cart items with name, price and quantity
- Buttons to change the quantity and to remove an item
- A total that always matches the cart
- An empty state when nothing is left
The data
Each item has an id, a name, a price and a quantity.
// src/app/cart/cart-item.ts
export interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
The component
The cart's items live in one signal. Everything else is calculated from it.
// src/app/cart/cart.ts
import { CurrencyPipe } from '@angular/common';
import { Component, computed, signal } from '@angular/core';
import { CartItem } from './cart-item';
@Component({
selector: 'app-cart',
imports: [CurrencyPipe],
template: `
<h2>Your cart</h2>
@if (items().length === 0) {
<p>Your cart is empty.</p>
<button type="button" (click)="reset()">Restore sample items</button>
} @else {
<ul>
@for (item of items(); track item.id) {
<li>
<strong>{{ item.name }}</strong>
({{ item.price | currency }} each)
<button type="button" (click)="change(item.id, -1)" [attr.aria-label]="'One less ' + item.name">-</button>
{{ item.quantity }}
<button type="button" (click)="change(item.id, 1)" [attr.aria-label]="'One more ' + item.name">+</button>
= {{ item.price * item.quantity | currency }}
<button type="button" (click)="remove(item.id)">Remove</button>
</li>
}
</ul>
<p>Items: {{ itemCount() }}</p>
<p><strong>Total: {{ total() | currency }}</strong></p>
}
`,
})
export class Cart {
protected readonly items = signal<CartItem[]>(this.sample());
protected readonly itemCount = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0),
);
protected readonly total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0),
);
protected change(id: number, amount: number): void {
this.items.update((list) =>
list.map((item) =>
item.id === id ? { ...item, quantity: Math.max(1, item.quantity + amount) } : item,
),
);
}
protected remove(id: number): void {
this.items.update((list) => list.filter((item) => item.id !== id));
}
protected reset(): void {
this.items.set(this.sample());
}
private sample(): CartItem[] {
return [
{ id: 1, name: 'Notebook', price: 4.5, quantity: 2 },
{ id: 2, name: 'Pen set', price: 7.25, quantity: 1 },
{ id: 3, name: 'Backpack', price: 39.99, quantity: 1 },
];
}
}
// src/app/app.ts
import { Component } from '@angular/core';
import { Cart } from './cart/cart';
@Component({
imports: [Cart],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Shopping cart</h1>
<app-cart />
</main>
How it works
- One source of truth. The
itemssignal holds the data.itemCountandtotalare computed from it, so they cannot drift out of sync. - Immutable updates.
changeusesmapto build a new array where only the matching item is replaced with a modified copy.removeusesfilter. Neither one changes the old array. - Control flow.
@ifswitches between the empty state and the list, and@fordraws the rows. Each row is tracked byid. - A built-in pipe.
| currencyformats a number as money. Pipes are covered in their own lesson. TheCurrencyPipecomes from@angular/common, and it has to be listed in the component'simports. - Accessible buttons. The
+and-buttons havearia-labeltext, so a screen reader announces what they do, not only a symbol.
Common mistakes
- Changing an item in place:
item.quantity++changes the object but the signal never learns of it. Always produce new objects and arrays. - Duplicating state: do not store the total in its own signal. Calculate it with
computed. - Letting a quantity reach zero:
Math.max(1, ...)keeps it valid here. Removing is a separate action.
Try it yourself
- Add a "Clear cart" button that sets the list to an empty array.
- Add a free-shipping message when the total is above 50.
- Add a signal
couponAppliedand a computeddiscountedTotalthat takes 10% off.
Quick check
- Which values in this cart are computed, and which are stored?
- Why does
changecreate a new item object? - What does the
track item.iddo?
Next module: split a screen into reusable pieces with component inputs.