Signals in Practice: Build a Shopping Cart

Angular Tutorial: Beginner to Advanced · 5. Signals and reactive state · 4 min read · Updated

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 items signal holds the data. itemCount and total are computed from it, so they cannot drift out of sync.
  • Immutable updates. change uses map to build a new array where only the matching item is replaced with a modified copy. remove uses filter. Neither one changes the old array.
  • Control flow. @if switches between the empty state and the list, and @for draws the rows. Each row is tracked by id.
  • A built-in pipe. | currency formats a number as money. Pipes are covered in their own lesson. The CurrencyPipe comes from @angular/common, and it has to be listed in the component's imports.
  • Accessible buttons. The + and - buttons have aria-label text, 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

  1. Add a "Clear cart" button that sets the list to an empty array.
  2. Add a free-shipping message when the total is above 50.
  3. Add a signal couponApplied and a computed discountedTotal that takes 10% off.

Quick check

  1. Which values in this cart are computed, and which are stored?
  2. Why does change create a new item object?
  3. What does the track item.id do?

Next module: split a screen into reusable pieces with component inputs.

Frequently asked questions

Why not store the total in a signal too?

Because then you would have to remember to update it every time the items change. A computed value updates by itself, so it is always correct.

How would I save the cart between visits?

Use an effect that writes the items to localStorage when they change, and read them when the component starts. The effects lesson shows the pattern.