Observable Data Service Pattern
The observable data service is the canonical RxJS approach to shared state in Angular: a single injectable service holds the current value in a BehaviorSubject and exposes it as a read-only Observable stream. Components subscribe (usually through the async pipe) and re-render whenever the value changes, while mutations flow exclusively through intent-named methods on the service. It is the pattern most teams adopt before reaching for NgRx, and it remains the right tool whenever your state is naturally asynchronous or you need to compose it with other streams.
Why BehaviorSubject
A plain Subject only emits values to subscribers that were already listening, which is useless for state — a component created later would receive nothing until the next change. A BehaviorSubject fixes this by holding a current value: it requires an initial value, replays the latest value to every new subscriber immediately, and emits on every subsequent next(...). That replay behaviour is exactly what “shared state” needs.
| Concern | Subject | BehaviorSubject |
|---|---|---|
| Initial value | none | required |
| Late subscribers get current value | no | yes |
| Read synchronously | no | subject.value |
| Suitable as a state store | no | yes |
The key discipline is encapsulation: the BehaviorSubject stays private, and the service exposes only its asObservable() projection so consumers cannot call next() and corrupt the store from the outside.
A minimal store
Here is a complete cart store. The writable subject is private; the public surface is one read-only stream plus methods that describe intent.
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface CartItem {
id: number;
title: string;
price: number;
qty: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
// Private writable source of truth.
private readonly itemsSubject = new BehaviorSubject<CartItem[]>([]);
// Public read-only stream — consumers cannot call next() on this.
readonly items$: Observable<CartItem[]> = this.itemsSubject.asObservable();
/** Synchronous snapshot, handy in guards/effects. */
private get snapshot(): CartItem[] {
return this.itemsSubject.value;
}
add(item: Omit<CartItem, 'qty'>): void {
const existing = this.snapshot.find((i) => i.id === item.id);
const next = existing
? this.snapshot.map((i) =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i,
)
: [...this.snapshot, { ...item, qty: 1 }];
this.itemsSubject.next(next);
}
remove(id: number): void {
this.itemsSubject.next(this.snapshot.filter((i) => i.id !== id));
}
clear(): void {
this.itemsSubject.next([]);
}
}
Every mutation builds a new array rather than mutating in place. Immutable updates keep change detection predictable and make derived streams recompute correctly.
Derived (computed) streams
You rarely want to expose only raw state. Derive the values your templates actually bind to with RxJS operators, so the math lives in the service and stays consistent everywhere.
import { map } from 'rxjs';
// Inside CartStore:
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)),
);
Consuming it in a component
Inject the store with inject(), bind the streams through the async pipe, and use the new control-flow syntax. The async pipe subscribes and unsubscribes for you, so there is no manual cleanup.
import { Component, inject } from '@angular/core';
import { AsyncPipe, CurrencyPipe } from '@angular/common';
import { CartStore } from './cart.store';
@Component({
selector: 'app-cart',
standalone: true,
imports: [AsyncPipe, CurrencyPipe],
template: `
<h2>Cart ({{ store.count$ | async }})</h2>
@for (item of store.items$ | async; track item.id) {
<div class="row">
<span>{{ item.title }} ×{{ item.qty }}</span>
<button (click)="store.remove(item.id)">Remove</button>
</div>
} @empty {
<p>Your cart is empty.</p>
}
<strong>Total: {{ store.total$ | async | currency }}</strong>
`,
})
export class CartComponent {
protected readonly store = inject(CartStore);
}
Output:
Cart (3)
Headphones ×2 [Remove]
USB-C Cable ×1 [Remove]
Total: $84.97
Tip: prefer one
asyncpipe per derived stream over subscribing in the class. If you must subscribe manually, usetakeUntilDestroyed()so the subscription is torn down with the component.
Loading async data into the store
Because the store is built on observables, feeding it server data is natural — fetch with HttpClient and push the result through next().
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
// Inside a ProductStore with `private readonly listSubject`:
private readonly http = inject(HttpClient);
load(): void {
this.http
.get<CartItem[]>('/api/cart')
.subscribe((items) => this.listSubject.next(items));
}
Warning: never expose the
BehaviorSubjectitself or itsnextmethod. Returning the subject lets any component overwrite state from anywhere, which defeats the single-source-of-truth guarantee and makes bugs nearly impossible to trace.
Best Practices
- Keep the
BehaviorSubjectprivate readonlyand expose onlyasObservable(); mutate exclusively through intent-named methods (add,remove,clear). - Always update immutably — return new objects/arrays from
next(...)instead of mutating the current value in place. - Derive everything templates need with
map/combineLatestso calculation logic is centralised, not duplicated across components. - Bind with the
asyncpipe to get automatic subscription cleanup; reserve manualsubscribe()for side effects and pair it withtakeUntilDestroyed(). - Provide the store at the right scope:
providedIn: 'root'for app-wide state, or a componentprovidersarray for state scoped to a feature subtree. - Use the synchronous
.valuesnapshot sparingly — mainly in guards or one-off computations — and never as a substitute for reacting to the stream.