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 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 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 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.