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.
- A
CartService(providedIn: 'root') holding a privateitemssignal, withaddItem/removeItem/updateQuantitymethods. itemCountandsubtotalascomputed()signals on the service, always derived fromitems, never duplicated.- A
ProductListComponentthat callscart.addItem(product)directly, with zero cart-related@Input()s passed in. - A
CartItemComponentdemonstrating classic inputs-in / events-out — it receives one cart line through@Input()and emits quantity/removal changes through@Output(). - A
CartSummaryComponentthat injects the sameCartServiceand renders every line as aCartItemComponent. - Two sibling components sharing one source of truth with zero prop drilling between them.
Prerequisites
- Standalone components,
signal(), andcomputed()— comfortable from the Todo App project above. - The
@Componentdecorator and itsimportsarray — every component below only imports what its own template actually uses. @Input()/@Output()fundamentals — used throughoutCartItemComponent.- Array methods —
find,map, andfilter, 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 stylingmain.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.tsexport 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 bookkeepingStep 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.tsimport { 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)) ); }}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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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.tsexport 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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { bootstrapApplication } from '@angular/platform-browser';import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch((err) => console.error(err));Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a
checkout()method toCartServicethat clearsitemsSignaland shows a thank-you message. - Persist
itemstolocalStorageinsideCartServicewith aneffect(), the same pattern used in the Todo App project. - Add quantity stepper
+/-buttons toCartItemComponentinstead of a raw number input, emittingquantityChangewithitem.quantity + 1or- 1. - Add a navbar component that injects
CartServiceto show a liveitemCount()badge, proving the shared instance works from a completely different part of the tree. - Split validation (e.g. a max-quantity limit) out of
CartServiceand into its own small injectable helper class that both the service andCartItemComponent'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.