LearnAI ToolsCareerPractice BuildsPlayContact
AngularIntermediate~2 hours

Shopping Cart

Build a cart with shared state across components using a signal-based store service.

Services & DISignals@Input/@Output

Overview

A shopping cart is the standard project for learning shared state because it has a real, unavoidable problem: a `ProductListComponent` needs to add items to the cart, and a completely separate `CartSummaryComponent` needs to display and update those same items — with neither one a parent of the other. Passing the cart down through `@Input()` from a common ancestor would mean threading it through a component that doesn't actually use it itself.

Instead, this project manages the cart with a `CartService` — an `@Injectable({ providedIn: 'root' })` class holding its state as signals. Angular's dependency injection system guarantees that any component anywhere in the tree that injects `CartService` gets back the exact same shared instance, no prop drilling required. This is Angular's answer to the same problem Vue solves with a Pinia store or React solves with Context: one owner of the state, reachable by name from anywhere.

What You'll Build
  • A `CartService` (`providedIn: 'root'`) holding a private `items` signal, with `addItem`/`removeItem`/`updateQuantity` methods.
  • `itemCount` and `subtotal` as `computed()` signals on the service, always derived from `items`, never duplicated.
  • A `ProductListComponent` that calls `cart.addItem(product)` directly, with zero cart-related `@Input()`s passed in.
  • A `CartItemComponent` demonstrating classic inputs-in / events-out — it receives one cart line through `@Input()` and emits quantity/removal changes through `@Output()`.
  • A `CartSummaryComponent` that injects the same `CartService` and renders every line as a `CartItemComponent`.
  • Two sibling components sharing one source of truth with zero prop drilling between them.

Prerequisites

  • Standalone components, `signal()`, and `computed()` — comfortable from the Todo App project above.
  • The `@Component` decorator and its `imports` array — every component below only imports what its own template actually uses.
  • `@Input()`/`@Output()` fundamentals — used throughout `CartItemComponent`.
  • Array methods — `find`, `map`, and `filter`, used throughout the service and the cart totals.
  • Dependency injection (`@Injectable`, `providedIn: 'root'`, `inject()`) is new here — it is introduced from the ground up in this tutorial.

Project Structure

The cart service lives in its own file so any component in the tree can inject it. `ProductListComponent` and `CartSummaryComponent` are true siblings — both rendered directly inside `AppComponent`, next to each other — which is exactly the layout that would otherwise force prop drilling.

src/app/
cart.service.ts // @Injectable({ providedIn: 'root' }) — items, methods, and computed getters
products.ts // Static list of products available to add to the cart
product-list/
product-list.component.ts // Reads nothing from the cart as an @Input; only calls service methods
cart-item/
cart-item.component.ts // One cart row — @Input() in, @Output() out
cart-summary/
cart-summary.component.ts // Reads cart items via the service + renders a list of CartItemComponent
app.component.ts // Renders ProductListComponent and CartSummaryComponent side by side
app.component.css // Shared dark-theme styling
main.ts // Bootstraps <AppComponent>
/* app.component.css */
* { 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: #dd0031;
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 signal. The cart is designed as a flat array of `CartLine` objects (a `Product` plus a `quantity`), so the service's methods can lean on ordinary array methods instead of juggling object-key bookkeeping.

// products.ts
export interface Product {
id: number;
name: string;
price: number;
}
export const PRODUCTS: Product[] = [
{ 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 line shape, used throughout cart.service.ts:
// CartLine = Product & { quantity: number }
//
// Deliberately a flat array (not an object keyed by id) so the service's
// methods below can lean on plain array methods (find/map/filter) instead of
// juggling object-key bookkeeping

Step 2: Create the CartService with Signals

`providedIn: 'root'` registers exactly one shared instance for the whole app, created lazily the first time any component injects it. The writable signal is kept `private`, and only a read-only view of it (`.asReadonly()`) is exposed publicly — the same encapsulation a private field plus a public getter would give you in plain TypeScript, and it is what forces every mutation to flow through the methods below instead of a component reaching in and calling `.set()` on the cart itself.

// cart.service.ts
import { Injectable, computed, signal } from '@angular/core';
import { Product } from './products';
export interface CartLine extends Product {
quantity: number;
}
@Injectable({
// providedIn: 'root' registers ONE shared instance app-wide — this is what
// makes CartService reachable from two totally unrelated sibling
// components without either one being the other's parent or passing
// anything through a common ancestor
providedIn: 'root',
})
export class CartService {
// Private writable signal — nothing outside this class can call
// .set()/.update() on it directly, which is what keeps every cart
// mutation funneled through the methods below
private readonly itemsSignal = signal<CartLine[]>([]);
// Exposed read-only — components can read items() but cannot reassign it
readonly items = this.itemsSignal.asReadonly();
// computed() getters — always derived from itemsSignal, never stored
// separately, so they can never drift out of sync with the actual cart
readonly itemCount = computed(() =>
this.itemsSignal().reduce((sum, item) => sum + item.quantity, 0)
);
readonly subtotal = computed(() =>
this.itemsSignal().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
addItem(product: Product): void {
const existing = this.itemsSignal().find((item) => item.id === product.id);
if (existing) {
// Already in the cart — bump its quantity instead of adding a
// duplicate row
this.updateQuantity(product.id, existing.quantity + 1);
} else {
// First time this product is added — spread its fields and start
// quantity at 1; a brand-new array is built since signals compare by
// reference, same rule as the Todo App's tasks signal
this.itemsSignal.update((current) => [...current, { ...product, quantity: 1 }]);
}
}
removeItem(id: number): void {
this.itemsSignal.update((current) => current.filter((item) => item.id !== id));
}
updateQuantity(id: number, quantity: number): void {
if (quantity < 1) {
// Treat "quantity 0 or less" as a removal rather than leaving a
// useless zero-quantity row in the cart
this.removeItem(id);
return;
}
this.itemsSignal.update((current) =>
current.map((item) => (item.id === id ? { ...item, quantity } : item))
);
}
}
How State Flows Through the Service

Click Run to see what this code prints.

Step 3: Build the Product List

`ProductListComponent` calls `inject(CartService)` directly — no `@Input()` connects 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.

// product-list/product-list.component.ts
import { Component, inject } from '@angular/core';
import { PRODUCTS } from '../products';
import { CartService } from '../cart.service';
@Component({
selector: 'app-product-list',
standalone: true,
templateUrl: './product-list.component.html',
})
export class ProductListComponent {
// inject() reaches into Angular's dependency injection tree and returns
// the exact same CartService singleton every other component that
// injects it also gets — "protected" so the template can read it directly
protected readonly cart = inject(CartService);
protected readonly products = PRODUCTS;
}
<!-- product-list/product-list.component.html -->
<div class="product-list">
<h2>Products</h2>
@for (product of products; track product.id) {
<div class="product-row">
<span>{{ product.name }}</span>
<span>{{ '$' + product.price.toFixed(2) }}</span>
<button type="button" (click)="cart.addItem(product)">Add to Cart</button>
</div>
}
</div>

Step 4: Build a CartItem Component with @Input/@Output

Rather than have `CartSummaryComponent` build every row's markup inline, `CartItemComponent` is its own component — it receives one cart line as an `@Input()` and emits `quantityChange`/`remove` through `@Output()`, the same inputs-in / events-out shape as `TodoItemComponent` in the previous project. This keeps `CartItemComponent` reusable and fully decoupled from `CartService`: it doesn't know the service exists at all.

// cart-item/cart-item.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CartLine } from '../cart.service';
@Component({
selector: 'app-cart-item',
standalone: true,
templateUrl: './cart-item.component.html',
})
export class CartItemComponent {
// CartItemComponent deliberately knows nothing about CartService — it
// only deals in @Input()/@Output(), so it could just as easily be reused
// with cart line data that came from somewhere other than this service
@Input({ required: true }) item!: CartLine;
@Output() quantityChange = new EventEmitter<number>();
@Output() remove = new EventEmitter<number>();
}
<!-- cart-item/cart-item.component.html -->
<div class="cart-row">
<span>{{ item.name }}</span>
<!-- $any(...) casts event.target past strict template type-checking so
.value can be read directly — a common, narrowly-scoped escape hatch
for native DOM event targets Angular's compiler can't infer on its own -->
<input
type="number"
min="1"
[value]="item.quantity"
(input)="quantityChange.emit(+$any($event.target).value)"
/>
<span>{{ '$' + (item.price * item.quantity).toFixed(2) }}</span>
<button type="button" (click)="remove.emit(item.id)">Remove</button>
</div>

Step 5: Build the CartSummary Component

`CartSummaryComponent` injects the exact same `CartService` instance `ProductListComponent` injected in Step 3 — proof that two unrelated siblings can share state through DI with zero props passed between them. `CartItemComponent`'s emitted events are wired straight to service methods, since a service method is just a function and can be used as an event handler directly.

// cart-summary/cart-summary.component.ts
import { Component, inject } from '@angular/core';
import { CartService } from '../cart.service';
import { CartItemComponent } from '../cart-item/cart-item.component';
@Component({
selector: 'app-cart-summary',
standalone: true,
imports: [CartItemComponent],
templateUrl: './cart-summary.component.html',
})
export class CartSummaryComponent {
// Reads state straight from the shared service — nothing passed it this
// data from AppComponent, because CartService is DI-resolved
// independently in every component that injects it
protected readonly cart = inject(CartService);
}
<!-- cart-summary/cart-summary.component.html -->
<div class="cart-summary">
<h2>Cart</h2>
@if (cart.items().length === 0) {
<p class="cart-empty">Your cart is empty.</p>
}
@for (item of cart.items(); track item.id) {
<app-cart-item
[item]="item"
(quantityChange)="cart.updateQuantity(item.id, $event)"
(remove)="cart.removeItem(item.id)"
/>
}
@if (cart.items().length > 0) {
<p class="cart-total">{{ 'Total: $' + cart.subtotal().toFixed(2) }}</p>
}
</div>

Step 6: Wire It All Together in AppComponent

`AppComponent` never injects `CartService` at all — it does not need to. It just renders `ProductListComponent` and `CartSummaryComponent` side by side; both independently resolve the same singleton the first time they inject it, which is what "shared through DI" really means: no data flows through `AppComponent`, only components.

// app.component.ts
import { Component } from '@angular/core';
import { ProductListComponent } from './product-list/product-list.component';
import { CartSummaryComponent } from './cart-summary/cart-summary.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ProductListComponent, CartSummaryComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {}
<!-- app.component.html -->
<div class="shop-app">
<h1>Shop</h1>
<app-product-list />
<app-cart-summary />
</div>

Complete Code

Here is the complete project — every file, fully assembled from the steps above, in the order they would sit in a real Angular CLI-generated project.

// products.ts
export interface Product {
id: number;
name: string;
price: number;
}
export const PRODUCTS: Product[] = [
{ 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.service.ts
import { Injectable, computed, signal } from '@angular/core';
import { Product } from './products';
export interface CartLine extends Product {
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly itemsSignal = signal<CartLine[]>([]);
readonly items = this.itemsSignal.asReadonly();
readonly itemCount = computed(() => this.itemsSignal().reduce((sum, item) => sum + item.quantity, 0));
readonly subtotal = computed(() => this.itemsSignal().reduce((sum, item) => sum + item.price * item.quantity, 0));
addItem(product: Product): void {
const existing = this.itemsSignal().find((item) => item.id === product.id);
if (existing) {
this.updateQuantity(product.id, existing.quantity + 1);
} else {
this.itemsSignal.update((current) => [...current, { ...product, quantity: 1 }]);
}
}
removeItem(id: number): void {
this.itemsSignal.update((current) => current.filter((item) => item.id !== id));
}
updateQuantity(id: number, quantity: number): void {
if (quantity < 1) {
this.removeItem(id);
return;
}
this.itemsSignal.update((current) => current.map((item) => (item.id === id ? { ...item, quantity } : item)));
}
}
// product-list/product-list.component.ts
import { Component, inject } from '@angular/core';
import { PRODUCTS } from '../products';
import { CartService } from '../cart.service';
@Component({
selector: 'app-product-list',
standalone: true,
templateUrl: './product-list.component.html',
})
export class ProductListComponent {
protected readonly cart = inject(CartService);
protected readonly products = PRODUCTS;
}
<!-- product-list/product-list.component.html -->
<div class="product-list">
<h2>Products</h2>
@for (product of products; track product.id) {
<div class="product-row">
<span>{{ product.name }}</span>
<span>{{ '$' + product.price.toFixed(2) }}</span>
<button type="button" (click)="cart.addItem(product)">Add to Cart</button>
</div>
}
</div>
// cart-item/cart-item.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CartLine } from '../cart.service';
@Component({
selector: 'app-cart-item',
standalone: true,
templateUrl: './cart-item.component.html',
})
export class CartItemComponent {
@Input({ required: true }) item!: CartLine;
@Output() quantityChange = new EventEmitter<number>();
@Output() remove = new EventEmitter<number>();
}
<!-- cart-item/cart-item.component.html -->
<div class="cart-row">
<span>{{ item.name }}</span>
<input type="number" min="1" [value]="item.quantity" (input)="quantityChange.emit(+$any($event.target).value)" />
<span>{{ '$' + (item.price * item.quantity).toFixed(2) }}</span>
<button type="button" (click)="remove.emit(item.id)">Remove</button>
</div>
// cart-summary/cart-summary.component.ts
import { Component, inject } from '@angular/core';
import { CartService } from '../cart.service';
import { CartItemComponent } from '../cart-item/cart-item.component';
@Component({
selector: 'app-cart-summary',
standalone: true,
imports: [CartItemComponent],
templateUrl: './cart-summary.component.html',
})
export class CartSummaryComponent {
protected readonly cart = inject(CartService);
}
<!-- cart-summary/cart-summary.component.html -->
<div class="cart-summary">
<h2>Cart</h2>
@if (cart.items().length === 0) {
<p class="cart-empty">Your cart is empty.</p>
}
@for (item of cart.items(); track item.id) {
<app-cart-item [item]="item" (quantityChange)="cart.updateQuantity(item.id, $event)" (remove)="cart.removeItem(item.id)" />
}
@if (cart.items().length > 0) {
<p class="cart-total">{{ 'Total: $' + cart.subtotal().toFixed(2) }}</p>
}
</div>
// app.component.ts
import { Component } from '@angular/core';
import { ProductListComponent } from './product-list/product-list.component';
import { CartSummaryComponent } from './cart-summary/cart-summary.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ProductListComponent, CartSummaryComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {}
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch((err) => console.error(err));

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a `checkout()` method to `CartService` that clears `itemsSignal` and shows a thank-you message.
  • Persist `items` to `localStorage` inside `CartService` with an `effect()`, the same pattern used in the Todo App project.
  • Add quantity stepper `+`/`-` buttons to `CartItemComponent` instead of a raw number input, emitting `quantityChange` with `item.quantity + 1` or `- 1`.
  • Add a navbar component that injects `CartService` to show a live `itemCount()` badge, proving the shared instance works from a completely different part of the tree.
  • Split validation (e.g. a max-quantity limit) out of `CartService` and into its own small injectable helper class that both the service and `CartItemComponent`'s parent can use.

Summary

You built a shopping cart where two sibling components — `ProductListComponent` and `CartSummaryComponent` — share one piece of state through a signal-based `CartService`, with `CartItemComponent` demonstrating clean `@Input()`-in / `@Output()`-out composition. This combination — a `providedIn: 'root'` service for "who can reach the state," and `@Input()`/`@Output()` for "how components without shared state cooperate" — is the standard shape of a small-to-medium Angular application.