Observable Data Service Pattern
The observable data service pattern turns an ordinary injectable service into a small reactive store: it owns a piece of shared state internally and exposes it as a read-only observable stream that any component can subscribe to. Instead of components passing data through chains of @Input() and @Output() bindings, they read from and write to a single source of truth. This decouples producers from consumers, guarantees every subscriber sees a consistent view, and scales far better than prop-drilling as an application grows.
Why centralize state in a service
Angular services are singletons when provided in root, which makes them the natural home for state that outlives any single component. The key idea is encapsulation: the service holds a private Subject (usually a BehaviorSubject so late subscribers immediately receive the current value), mutates it through well-defined methods, and publishes a public observable that callers can only read. Components never touch the subject directly, so the service stays the only place state can change.
This is the reactive cousin of the singleton service pattern. The difference is that the data is pushed to subscribers over time rather than fetched once.
A minimal reactive store
Here is a typical cart service. The private BehaviorSubject holds the array of items; the public items$ exposes it as a plain Observable, and derived streams like count$ and total$ are computed with operators.
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, map } from 'rxjs';
export interface CartItem {
id: string;
name: string;
price: number;
qty: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly itemsSubject = new BehaviorSubject<CartItem[]>([]);
// Public read-only streams — subscribers cannot push to these.
readonly items$: Observable<CartItem[]> = this.itemsSubject.asObservable();
readonly count$ = this.items$.pipe(
map(items => items.reduce((n, i) => n + i.qty, 0)),
);
readonly total$ = this.items$.pipe(
map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0)),
);
add(item: Omit<CartItem, 'qty'>): void {
const items = this.itemsSubject.value;
const existing = items.find(i => i.id === item.id);
const next = existing
? items.map(i => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i))
: [...items, { ...item, qty: 1 }];
this.itemsSubject.next(next);
}
remove(id: string): void {
this.itemsSubject.next(this.itemsSubject.value.filter(i => i.id !== id));
}
clear(): void {
this.itemsSubject.next([]);
}
}
Always expose
asObservable()(or amap-derived stream), never the subject itself. Handing out theBehaviorSubjectlets any component call.next()and silently corrupt your single source of truth.
Consuming the streams
Components inject the service and subscribe in the template with the async pipe, which subscribes and unsubscribes automatically. With the new control flow you can branch on the emitted value cleanly.
import { Component, inject } from '@angular/core';
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { CartService } from './cart.service';
@Component({
selector: 'app-cart',
standalone: true,
imports: [AsyncPipe, CurrencyPipe],
template: `
<h2>Cart ({{ cart.count$ | async }})</h2>
@if (cart.items$ | async; as items) {
@for (item of items; track item.id) {
<div class="row">
<span>{{ item.name }} × {{ item.qty }}</span>
<button (click)="cart.remove(item.id)">Remove</button>
</div>
} @empty {
<p>Your cart is empty.</p>
}
<strong>Total: {{ cart.total$ | async | currency }}</strong>
}
`,
})
export class CartComponent {
readonly cart = inject(CartService);
}
Output:
Cart (3)
Pro Keyboard × 1 [Remove]
USB-C Cable × 2 [Remove]
Total: $79.97
Bridging observables and signals
Modern Angular favors signals for template state. You can keep the observable service as the canonical store and expose a signal view with toSignal, getting the ergonomics of signals without giving up RxJS for async composition.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CartService } from './cart.service';
@Component({
selector: 'app-cart-badge',
standalone: true,
template: `<span class="badge">{{ count() }}</span>`,
})
export class CartBadgeComponent {
private readonly cart = inject(CartService);
readonly count = toSignal(this.cart.count$, { initialValue: 0 });
}
toSignal subscribes on creation and unsubscribes when the component is destroyed, so you avoid manual subscription management entirely.
Subject choices
The right multicasting primitive depends on what late subscribers should receive.
| Subject | Initial value | Replays to late subscribers | Typical use |
|---|---|---|---|
BehaviorSubject | Required | Latest value | App state, current user, cart |
ReplaySubject | None | Last N values | Recent events, notifications |
Subject | None | Nothing | Fire-and-forget events, action triggers |
AsyncSubject | None | Final value on complete | One-shot completion results |
Best Practices
- Keep state mutations inside the service through intention-revealing methods (
add,remove); never let components callnext()on a shared subject. - Expose only
Observables publicly viaasObservable()or derivedpipe(...)streams, keeping theSubjectprivate readonly. - Treat state as immutable — emit new arrays and objects with spreads so change detection and memoized pipes work correctly.
- Prefer the
asyncpipe ortoSignalover manualsubscribe()to eliminate subscription leaks; if you must subscribe imperatively, usetakeUntilDestroyed(). - Derive computed streams (
count$,total$) with operators instead of storing redundant state that can drift out of sync. - Choose
BehaviorSubjectwhen subscribers need the current value immediately, andSubjectfor transient events that late subscribers should not replay. - For large feature areas, wrap several of these services behind a facade so components depend on one cohesive API.