Implementing a Client-Side Shopping Cart with Angular 21
Angular 21 brings signals, standalone components, and the new control flow syntax into a mature, cohesive toolkit. A client-side shopping cart is the perfect playground to exercise all three: writable signals hold the cart state, computed signals derive totals and item counts, and the built-in @for / @if / @empty blocks replace the old structural directives with something far more readable. In this article we will build a fully functional cart from scratch, covering the data model, a signal-based service, presentational components, and localStorage persistence — all without pulling in any state management library.
Prerequisites
To follow along you need Node 20 or later, the Angular CLI at version 21, and a basic familiarity with TypeScript. Every component in this project is standalone, so there is no NgModule anywhere.
Create a fresh workspace:
ng new signal-cart --style=css --ssr=false
cd signal-cart
The --ssr=false flag keeps the setup simple. Server-side rendering can be added later without changing anything in the cart logic, since signals work identically on both platforms.
Defining the data model
A shopping cart needs two simple interfaces: one for a product in the catalogue, and one for an item already placed in the cart. Keeping them separate avoids coupling catalogue metadata (images, descriptions) with cart-specific state (quantity).
// src/app/models/product.model.ts
export interface Product {
id: string;
name: string;
price: number;
imageUrl: string;
}
export interface CartItem {
product: Product;
quantity: number;
}
The CartItem wraps a Product reference together with a quantity. This is the shape that the cart service will store internally and that every component will consume.
Building the cart service with signals
The heart of the application is a single injectable service. It exposes a writable signal holding the array of cart items and a pair of computed signals that derive the total price and the total number of items. Because signals are synchronous and glitch-free, every consumer sees a consistent snapshot of the state at all times.
// src/app/services/cart.service.ts
import {
Injectable,
signal,
computed,
effect,
} from '@angular/core';
import { Product, CartItem } from '../models/product.model';
@Injectable({ providedIn: 'root' })
export class CartService {
/** The single source of truth. */
private readonly _items = signal<CartItem[]>(this.loadFromStorage());
/** Public read-only view of the cart contents. */
readonly items = this._items.asReadonly();
/** Derived: total number of individual units in the cart. */
readonly totalQuantity = computed(() =>
this._items().reduce((sum, i) => sum + i.quantity, 0)
);
/** Derived: grand total price. */
readonly totalPrice = computed(() =>
this._items().reduce((sum, i) => sum + i.product.price * i.quantity, 0)
);
constructor() {
// Persist to localStorage whenever the cart changes.
effect(() => {
const serialized = JSON.stringify(this._items());
localStorage.setItem('cart', serialized);
});
}
addToCart(product: Product): void {
this._items.update(items => {
const existing = items.find(i => i.product.id === product.id);
if (existing) {
return items.map(i =>
i.product.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { product, quantity: 1 }];
});
}
removeFromCart(productId: string): void {
this._items.update(items =>
items.filter(i => i.product.id !== productId)
);
}
updateQuantity(productId: string, quantity: number): void {
if (quantity <= 0) {
this.removeFromCart(productId);
return;
}
this._items.update(items =>
items.map(i =>
i.product.id === productId ? { ...i, quantity } : i
)
);
}
clearCart(): void {
this._items.set([]);
}
private loadFromStorage(): CartItem[] {
try {
const raw = localStorage.getItem('cart');
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
}
Why signals instead of BehaviorSubject?
In earlier Angular versions the idiomatic approach was to wrap cart state in a BehaviorSubject, expose it as an Observable, and subscribe in the template with the async pipe. This worked, but it carried ceremony: you had to manage subscriptions, remember to call .next() instead of .set(), and mentally translate between the imperative update and the reactive stream. Signals remove that friction. A signal is read by calling it as a function, updated with .set() or .update(), and automatically tracked by Angular's change detection when read inside a template or a computed. There is no subscription to clean up and no pipe to import.
The role of computed
totalQuantity and totalPrice are computed signals. They re-evaluate lazily: Angular only runs the derivation function when one of the source signals has changed and a consumer reads the computed value. In a shopping cart scenario this means the grand total is never recalculated while the user is simply scrolling the product catalogue. The moment the template renders the cart icon badge, Angular reads totalQuantity(), walks the dependency graph, and produces a fresh number. If nothing changed, it returns the memoized result.
Persisting state with effect
The effect in the constructor is the recommended way to synchronize signal state with an external, non-reactive API like localStorage. Every time _items changes, the effect fires and serializes the new array. The Angular documentation explicitly recommends effects for this kind of side-effect — keeping data in sync with browser storage, analytics, or logging — while discouraging their use for deriving state (that is what computed is for).
A mock product catalogue
For demonstration purposes a static array of products is enough. In a real application this would come from an HTTP call wrapped in a resource or rxResource.
// src/app/data/products.ts
import { Product } from '../models/product.model';
export const PRODUCTS: Product[] = [
{
id: 'mech-kb-01',
name: 'Mechanical Keyboard',
price: 129.99,
imageUrl: 'assets/keyboard.webp',
},
{
id: 'usbc-hub-02',
name: 'USB-C Hub 7-in-1',
price: 49.99,
imageUrl: 'assets/hub.webp',
},
{
id: 'mon-4k-03',
name: '27" 4K Monitor',
price: 399.0,
imageUrl: 'assets/monitor.webp',
},
{
id: 'webcam-04',
name: '1080p Webcam',
price: 74.5,
imageUrl: 'assets/webcam.webp',
},
];
The product list component
This component displays the catalogue and lets the user add products to the cart. It is standalone, imports nothing beyond what Angular provides, and uses the new control flow syntax.
// src/app/components/product-list/product-list.component.ts
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartService } from '../../services/cart.service';
import { PRODUCTS } from '../../data/products';
import { Product } from '../../models/product.model';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CurrencyPipe],
template: `
<h2>Products</h2>
@for (product of products; track product.id) {
<p>
<strong>{{ product.name }}</strong> —
{{ product.price | currency }}
<button (click)="addToCart(product)">Add to cart</button>
</p>
} @empty {
<p>No products available.</p>
}
`,
})
export class ProductListComponent {
protected readonly products = PRODUCTS;
private readonly cart = inject(CartService);
addToCart(product: Product): void {
this.cart.addToCart(product);
}
}
Control flow: @for, track, and @empty
The @for block replaced *ngFor in Angular 17 and is now the standard way to iterate in templates. It requires a track expression, which Angular uses to associate DOM nodes with data items. Tracking by a unique identifier such as product.id ensures that adding or removing a product causes the minimum number of DOM mutations. The optional @empty block renders fallback content when the iterable is empty, eliminating the need for a separate @if guard.
The cart component
The cart component reads items from the service, displays them with quantity controls, and shows the grand total. It uses @if to conditionally render the empty-cart message, and @for to iterate over the items.
// src/app/components/cart/cart.component.ts
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartService } from '../../services/cart.service';
@Component({
selector: 'app-cart',
standalone: true,
imports: [CurrencyPipe],
template: `
<h2>Shopping Cart ({{ cart.totalQuantity() }} items)</h2>
@if (cart.items().length === 0) {
<p>Your cart is empty.</p>
} @else {
@for (item of cart.items(); track item.product.id) {
<p>
<strong>{{ item.product.name }}</strong>
— {{ item.product.price | currency }}
× {{ item.quantity }}
= {{ item.product.price * item.quantity | currency }}
</p>
<p>
<button (click)="decrement(item.product.id, item.quantity)">
−
</button>
<button (click)="increment(item.product.id, item.quantity)">
+
</button>
<button (click)="remove(item.product.id)">Remove</button>
</p>
}
<p>
<strong>Total: {{ cart.totalPrice() | currency }}</strong>
</p>
<p>
<button (click)="cart.clearCart()">Clear cart</button>
</p>
}
`,
})
export class CartComponent {
protected readonly cart = inject(CartService);
increment(productId: string, current: number): void {
this.cart.updateQuantity(productId, current + 1);
}
decrement(productId: string, current: number): void {
this.cart.updateQuantity(productId, current - 1);
}
remove(productId: string): void {
this.cart.removeFromCart(productId);
}
}
Notice that the template reads cart.totalQuantity() and cart.totalPrice() directly. Because these are computed signals, Angular tracks them as dependencies of the component's view. When the underlying _items signal changes, Angular knows that these computed values might have changed and schedules a re-render. If the computed value turns out to be the same (for example, two items were swapped but the total did not change), Angular skips the DOM update entirely.
Wiring everything together
The root component simply composes the two child components.
// src/app/app.component.ts
import { Component } from '@angular/core';
import { ProductListComponent } from './components/product-list/product-list.component';
import { CartComponent } from './components/cart/cart.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ProductListComponent, CartComponent],
template: `
<h1>Angular 21 Signal Cart</h1>
<app-product-list />
<app-cart />
`,
})
export class AppComponent {}
Because both components inject the same CartService (provided in root), they share the same signal instance. Adding a product in ProductListComponent immediately reflects in CartComponent without any manual event wiring or parent-child communication.
Using linkedSignal for quantity editing
Suppose you want to let the user type a quantity directly into an input field. The desired behavior is: the field should initialize to the current quantity from the cart, the user should be able to overwrite it freely, and if the cart item is removed and re-added the field should reset. This is exactly the use case that linkedSignal was designed for. A linkedSignal is a writable signal whose value is initialized and automatically reset by a linked computation, but can still be set manually.
// src/app/components/cart-item-editor/cart-item-editor.component.ts
import {
Component,
input,
inject,
linkedSignal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CartItem } from '../../models/product.model';
import { CartService } from '../../services/cart.service';
@Component({
selector: 'app-cart-item-editor',
standalone: true,
imports: [FormsModule],
template: `
<label>
{{ item().product.name }} — Qty:
<input
type="number"
[ngModel]="editableQuantity()"
(ngModelChange)="onQuantityChange($event)"
min="0"
/>
</label>
`,
})
export class CartItemEditorComponent {
/** The cart item passed in from a parent component. */
item = input.required<CartItem>();
private readonly cart = inject(CartService);
/**
* A writable signal linked to the item's quantity.
* It resets whenever the input signal changes,
* but the user can freely type a new value.
*/
editableQuantity = linkedSignal(() => this.item().quantity);
onQuantityChange(newQty: number): void {
this.editableQuantity.set(newQty);
this.cart.updateQuantity(this.item().product.id, newQty);
}
}
The linkedSignal call takes a computation that reads this.item().quantity. Whenever the parent passes a different CartItem (or the same item with a changed quantity coming from elsewhere), the linked signal recomputes and resets its value. But between resets the user can call .set() on it freely, which is exactly what happens when they type into the input. This pattern replaces the older approach of watching ngOnChanges and manually patching a form control.
Adding a discount code with a computed chain
A common extension is to support discount codes. This is a good opportunity to show how computed signals compose: you derive a discount multiplier from the code, then derive a discounted total from the multiplier and the raw total.
// Inside CartService, add:
readonly discountCode = signal<string>('');
readonly discountMultiplier = computed(() => {
switch (this.discountCode().toUpperCase()) {
case 'SAVE10':
return 0.9;
case 'HALF':
return 0.5;
default:
return 1;
}
});
readonly discountedTotal = computed(
() => this.totalPrice() * this.discountMultiplier()
);
applyDiscount(code: string): void {
this.discountCode.set(code);
}
The dependency chain is _items → totalPrice → discountedTotal and discountCode → discountMultiplier → discountedTotal. Angular resolves both paths lazily. If only the discount code changes, totalPrice is not recalculated because its source (_items) has not changed. Angular knows this thanks to the memoization built into every computed signal.
// src/app/components/discount/discount.component.ts
import { Component, inject, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CartService } from '../../services/cart.service';
@Component({
selector: 'app-discount',
standalone: true,
imports: [CurrencyPipe, FormsModule],
template: `
<label>
Discount code:
<input
[ngModel]="code()"
(ngModelChange)="onCodeChange($event)"
placeholder="e.g. SAVE10"
/>
</label>
@if (cart.discountMultiplier() < 1) {
<p>
Discount applied!
New total: {{ cart.discountedTotal() | currency }}
</p>
}
`,
})
export class DiscountComponent {
protected readonly cart = inject(CartService);
protected readonly code = signal('');
onCodeChange(value: string): void {
this.code.set(value);
this.cart.applyDiscount(value);
}
}
Testing the cart service
Angular 21 ships with Vitest as the default test runner. Signal-based services are straightforward to test because there are no asynchronous subscriptions to manage. You read the signal, assert the value, mutate the state, and read again.
// src/app/services/cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';
import { Product } from '../models/product.model';
describe('CartService', () => {
let service: CartService;
const keyboard: Product = {
id: 'kb-1',
name: 'Keyboard',
price: 100,
imageUrl: '',
};
const mouse: Product = {
id: 'ms-2',
name: 'Mouse',
price: 50,
imageUrl: '',
};
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
it('starts with an empty cart', () => {
expect(service.items()).toEqual([]);
expect(service.totalQuantity()).toBe(0);
expect(service.totalPrice()).toBe(0);
});
it('adds a product', () => {
service.addToCart(keyboard);
expect(service.items().length).toBe(1);
expect(service.items()[0].quantity).toBe(1);
});
it('increments quantity for duplicate products', () => {
service.addToCart(keyboard);
service.addToCart(keyboard);
expect(service.items().length).toBe(1);
expect(service.items()[0].quantity).toBe(2);
});
it('computes totals correctly', () => {
service.addToCart(keyboard);
service.addToCart(mouse);
service.addToCart(keyboard);
expect(service.totalQuantity()).toBe(3);
expect(service.totalPrice()).toBe(250);
});
it('removes a product', () => {
service.addToCart(keyboard);
service.removeFromCart('kb-1');
expect(service.items()).toEqual([]);
});
it('clears the cart', () => {
service.addToCart(keyboard);
service.addToCart(mouse);
service.clearCart();
expect(service.totalQuantity()).toBe(0);
});
});
There is no need for fakeAsync, tick, or done callbacks. Every assertion reads the signal synchronously and gets the current value. This is one of the most tangible developer-experience improvements that signals bring: tests become simpler and faster.
Performance considerations
Signals integrate with Angular's change detection at a granular level. When you read a signal in a template, Angular registers the component as a consumer of that signal. On the next change detection pass, Angular checks whether any of the signals consumed by the component have changed. If none have, the component is skipped entirely — even if it does not use OnPush. This makes signal-based components inherently efficient without requiring the developer to opt into a stricter change detection strategy.
For a shopping cart with a handful of items, performance is unlikely to be a concern. But the same architecture scales: if the cart held hundreds of line items (a wholesale scenario, for example), the computed signals would still re-evaluate only when the underlying array changes, and the @for block's track expression would minimize DOM churn.
Immutability matters
Throughout the service you may have noticed that every mutation returns a new array via .map(), .filter(), or the spread operator. This is intentional. Signals rely on reference equality by default to decide whether a value has changed. If you mutated the existing array in place and called .set() with the same reference, Angular would see the same object and skip the update. Producing a new array on every mutation guarantees that downstream consumers — computed signals, templates, effects — are notified correctly.
Scaling up: what comes next
A production shopping cart will eventually need routing (a dedicated cart page, a checkout flow), HTTP calls to a pricing API, authentication, and probably server-side rendering for SEO on the product pages. None of these additions conflict with the signal-based architecture outlined here. The CartService remains the single source of truth; you simply inject it wherever it is needed. For server communication, Angular 21's resource and rxResource APIs let you fetch data and expose it as a signal-compatible read, making it straightforward to blend remote data with local state.
If your state grows complex enough to warrant a dedicated store, libraries like NgRx SignalStore build directly on top of Angular signals, so the mental model stays the same. But for a feature-scoped slice of state like a shopping cart, a plain service with signals is often all you need.
Conclusion
Angular 21's signal primitives — signal, computed, linkedSignal, and effect — provide everything required to manage client-side cart state in a reactive, testable, and performant way. Combined with standalone components and the built-in control flow syntax, the result is a codebase that is shorter, easier to reason about, and free of the subscription management that used to pervade Angular applications. The shopping cart we built here is small, but the patterns are production-grade: immutable updates, derived state through computed chains, persistence through effects, and editable-yet-reactive fields through linked signals. These same patterns will serve you well as the feature set grows.