Subjects & BehaviorSubject
A plain observable is unicast: every subscriber triggers its producer function from scratch and gets its own private execution. A Subject breaks that rule — it is both an observable and an observer, so you can push values into it imperatively with next() while many subscribers share a single stream. That dual nature makes the Subject family the backbone of event buses, component-to-component messaging, and the classic observable data service pattern in Angular.
Subject: the multicast primitive
A Subject keeps a list of subscribers and forwards every next, error, and complete to all of them at once. Crucially, it does not replay anything — a subscriber only sees values emitted after it subscribes.
import { Subject } from 'rxjs';
const events$ = new Subject<string>();
events$.subscribe((v) => console.log('A:', v));
events$.next('first');
events$.subscribe((v) => console.log('B:', v));
events$.next('second');
Output:
A: first
A: second
B: second
Subscriber B missed 'first' because it joined late. This is the defining behaviour of a bare Subject.
BehaviorSubject: always has a current value
BehaviorSubject requires an initial value and remembers the latest value it emitted. Any new subscriber immediately receives that current value, then continues to get subsequent emissions. You can also read the value synchronously via .value. This makes it ideal for state — a piece of data that always has a “now.”
import { BehaviorSubject } from 'rxjs';
const count$ = new BehaviorSubject<number>(0);
count$.subscribe((v) => console.log('A:', v)); // gets 0 right away
count$.next(1);
count$.subscribe((v) => console.log('B:', v)); // gets current value 1
count$.next(2);
console.log('current =', count$.value);
Output:
A: 0
A: 1
B: 1
A: 2
B: 2
current = 2
ReplaySubject and AsyncSubject
ReplaySubject buffers a configurable number of past values (optionally within a time window) and replays them to every new subscriber. AsyncSubject emits only the last value, and only when the source complete()s — useful for one-shot results.
import { ReplaySubject, AsyncSubject } from 'rxjs';
const replay$ = new ReplaySubject<number>(2); // keep last 2
replay$.next(1);
replay$.next(2);
replay$.next(3);
replay$.subscribe((v) => console.log('replay:', v)); // gets 2, 3
const async$ = new AsyncSubject<number>();
async$.next(10);
async$.next(20);
async$.subscribe((v) => console.log('async:', v));
async$.complete(); // emits only 20
Output:
replay: 2
replay: 3
async: 20
Choosing the right variant
| Type | Initial value | Replays to late subscribers | Typical use |
|---|---|---|---|
Subject | No | Nothing | Event bus, notifications, action streams |
BehaviorSubject | Required | Latest value | Shared state, current selection, auth status |
ReplaySubject | No | Last n (or time window) | Caching recent events, audit logs |
AsyncSubject | No | Final value on complete | One-shot async result |
The observable data service pattern
The most common Angular use is to keep a BehaviorSubject private inside a service and expose only its read-only observable. Consumers subscribe to the public stream and mutate state exclusively through methods — they can never call next() directly.
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface CartItem {
id: string;
name: string;
qty: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = new BehaviorSubject<CartItem[]>([]);
/** Public read-only stream — components subscribe to this. */
readonly items$ = this.items.asObservable();
add(item: CartItem): void {
this.items.next([...this.items.value, item]);
}
clear(): void {
this.items.next([]);
}
}
In a standalone component you can render the stream with the async pipe, or bridge it to a signal with toSignal for ergonomic template access:
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CartService } from './cart.service';
@Component({
selector: 'app-cart',
standalone: true,
template: `
@if (items().length) {
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }} ×{{ item.qty }}</li>
}
</ul>
} @else {
<p>Your cart is empty.</p>
}
`,
})
export class CartComponent {
private readonly cart = inject(CartService);
readonly items = toSignal(this.cart.items$, { initialValue: [] });
}
Tip:
.asObservable()hides thenext/error/completemethods at the type level, enforcing one-way data flow. Exposing the rawSubjectlets any consumer push values and quietly destroys your single source of truth.
Warning: A
Subjectis hot and stateful. Once you callerror()orcomplete()on it, it is dead — every future subscriber instantly receives that terminal notification and can never get new values. For long-lived app state, never complete the subject early.
Best practices
- Default to
BehaviorSubjectfor shared state so every subscriber always gets a meaningful current value, and reach for plainSubjectonly for fire-and-forget events. - Keep the subject
privateand exposeasObservable()(or atoSignalview); mutate state only through service methods. - Treat each
next()as an immutable update — emit new arrays/objects ([...prev, item]) rather than mutating in place so change detection and equality checks work. - Prefer the
asyncpipe ortoSignalin templates over manual.subscribe()so subscriptions clean up automatically. - Use
ReplaySubject(1)instead ofBehaviorSubjectwhen you genuinely have no sensible initial value but still want late subscribers to receive the most recent emission. - Avoid calling
complete()on app-wide state subjects; let services live for the app’s lifetime and reset state by emitting a new value.