Skip to content
Angular ng state 4 min read

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.

ConcernSubjectBehaviorSubject
Initial valuenonerequired
Late subscribers get current valuenoyes
Read synchronouslynosubject.value
Suitable as a state storenoyes

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 async pipe per derived stream over subscribing in the class. If you must subscribe manually, use takeUntilDestroyed() 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 BehaviorSubject itself or its next method. 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 BehaviorSubject private readonly and expose only asObservable(); 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/combineLatest so calculation logic is centralised, not duplicated across components.
  • Bind with the async pipe to get automatic subscription cleanup; reserve manual subscribe() for side effects and pair it with takeUntilDestroyed().
  • Provide the store at the right scope: providedIn: 'root' for app-wide state, or a component providers array for state scoped to a feature subtree.
  • Use the synchronous .value snapshot sparingly — mainly in guards or one-off computations — and never as a substitute for reacting to the stream.
Last updated June 14, 2026
Was this helpful?