State Management & RxJS
1️⃣ Where Should State Live? (Component vs Service)
๐ง Golden Rule
If more than one thing needs the data → it is state → move it out of the component
✅ Component State (Local, UI-only)
Dropdown open/close
Selected row
Loading spinner for one view
isOpen = false;
selectedId?: number;
✅ Service / Facade State (Shared / Long-lived)
API data
WebSocket data
Filters, selections shared across components
@Injectable()
export class ControlStateService {
private ordersSubject = new BehaviorSubject<Order[]>([]);
orders$ = this.ordersSubject.asObservable();
}
❌ Wrong
@Component()
export class ControlComponent {
orders: Order[] = [];
ngOnInit() {
this.http.get(...).subscribe(o => this.orders = o);
}
}
๐ This dies when:
Component is destroyed
Another component needs same data
2️⃣ Observable vs Subject vs BehaviorSubject (VERY IMPORTANT)
๐ข Observable
Read-only
No manual
.next()
// service private ordersSubject = new BehaviorSubject<Order[]>([]); orders$: Observable<Order[]> = this.ordersSubject.asObservable();
// component orders$ = this.orderService.orders$; // ✅ read-only
๐ Key idea
-
Component can read
-
Component cannot call
.next() -
Prevents accidental state mutation
๐ This is the preferred public API for state
๐ก Subject
Event stream
No stored value
Misses emissions if a late subscriber
// service or component
refresh$ = new Subject<void>();// emit event
this.refresh$.next();// listen to event
this.refresh$.subscribe(() => {
console.log('Refresh triggered');});๐ Key idea
No stored value
Late subscribers miss previous events
Perfect for clicks, refresh, submit, reload
๐ Use for actions, not state.
๐ต BehaviorSubject
Has current value
Emits immediately on subscribe
private ordersSubject = new BehaviorSubject<Order[]>([]);
// update state
this.ordersSubject.next([...this.ordersSubject.value, newOrder]);
// read current value (sync)
const current = this.ordersSubject.value;
๐ Key idea
Always has a value
New subscribers get latest value immediately
Ideal for application state
๐ Perfect for the state๐ง Rule (Memorize)
| Type | Purpose | Stores value? | Late subscriber |
|---|---|---|---|
| Observable | Read-only state | ❌ | ❌ |
| Subject | Event/action | ❌ | ❌ |
| BehaviorSubject | State | ✅ | ✅ |
3️⃣ Core Operators (MUST Master)
๐ switchMap — Cancel Previous Request
Use when:
Search
Filters
Route params
this.search$
.pipe(
switchMap(term => this.api.search(term))
)
.subscribe();
๐ Automatically cancels old requests.
๐ mergeMap — Run in Parallel
Use when:
Fire-and-forget
Multiple independent requests
mergeMap(order => this.api.save(order))
❌ Dangerous for UI-triggered actions.
⛔ takeUntil — Cleanup Subscriptions
Use in components only.
private destroy$ = new Subject<void>();
ngOnInit() {
this.facade.orders$
.pipe(takeUntil(this.destroy$))
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
๐ Prevents memory leaks.
4️⃣ Avoid Nested Subscriptions (CRITICAL)
❌ Worst Pattern (Messy Async)
this.api.getUser().subscribe(user => {
this.api.getOrders(user.id).subscribe(orders => {
this.api.getPayments(orders[0].id).subscribe(...);
});
});
❌ Unreadable
❌ Impossible to cancel
❌ Error handling nightmare
✅ Refactored (Clean & Confident)
this.api.getUser().pipe(
switchMap(user => this.api.getOrders(user.id)),
switchMap(orders => this.api.getPayments(orders[0].id))
).subscribe();
pipe() is where you describe how data should flow and change — without executing it yet.
- Get the user
- then switch to getting orders
- then switch to getting payments
- then subscribe onc
๐ One stream
๐ One error path
๐ One unsubscribe
5️⃣ Async Refactor Pattern
❌ Before
loadData() {
this.loading = true;
this.api.getA().subscribe(a => {
this.api.getB(a.id).subscribe(b => {
this.data = b;
this.loading = false;
});
});
}
✅ After
data$ = this.api.getA().pipe(
switchMap(a => this.api.getB(a.id)),
finalize(() => this.loading = false)
);
Template:
<div *ngIf="data$ | async as data">
๐ Component becomes declarative.
6️⃣ Service Pattern (State Done Right)
control-state.service.ts
@Injectable()
export class ControlStateService {
private ordersSubject = new BehaviorSubject<Order[]>([]);
orders$ = this.ordersSubject.asObservable();
loadOrders() {
this.api.getOrders().subscribe(o => this.ordersSubject.next(o));
}
}
control-page.component.ts
orders$ = this.controlStateService.orders$;
ngOnInit() {
this.facade.loadOrders();
}
๐ Components don’t care how data arrives.
7️⃣ Error Handling the Right Way
❌ Wrong
.subscribe(
data => {},
err => this.error = err
);
✅ Correct
pipe(
catchError(err => {
this.error = err;
return EMPTY;
})
)
๐ Keeps stream alive
๐ No broken UI
8️⃣ Async Pipe > Manual Subscribe
❌ Wrong
this.facade.orders$.subscribe(o => this.orders = o);
✅ Correct
orders$ = this.facade.orders$;
<tr *ngFor="let o of orders$ | async">
๐ Auto unsubscribe
๐ Cleaner code
9️⃣ Smell Tests (Memorize These)
| Smell | Meaning |
|---|---|
| subscribe inside subscribe | ❌ wrong |
| subject exposed publicly | ❌ wrong |
| state stored in the component | ❌ fragile |
| mergeMap for UI clicks | ❌ dangerous |
| manual unsubscribe everywhere | ❌ bad design |
Streams describe time.
State lives outside components.
Components only react.
If you can:
Read async code top → bottom
Delete a component without breaking state
Replace nested subscribes with operators
๐ State Lifetime Rules (Very Important)
Ask how long this data should live?
|
|---|
๐ If state survives navigation → component is wrong place
1️⃣2️⃣ Derived State Must NOT Be Stored
❌ Wrong
this.totalPrice = this.items.reduce(...)
✅ Correct (Derived)
totalPrice$ = this.items$.pipe(
map(items => items.reduce(...))
);
Or with signals:
totalPrice = computed(() =>
this.items().reduce(...)
);
๐ Only store source of truth
1️⃣3️⃣ Avoid Boolean Explosion
❌ Bad
isLoading;
hasError;
isEmpty;
✅ Better
state$ = of<'loading' | 'loaded' | 'error'>('loading');
Or signal:
state = signal<'loading' | 'loaded' | 'error'>('loading');
1️⃣4️⃣ Side Effects Must Be Isolated
Side effects:
API calls
Navigation
Toasts
Logging
❌ Wrong
map(data => {
this.router.navigate(...)
return data;
})
✅ Correct
tap(() => this.router.navigate(...))
๐ map = transform
๐ tap = side effects
๐ข SIGNALS — PRACTICAL & THEORY (Angular 16+)
1️⃣5️⃣ What Signals Are (Simple Truth)
Signals are synchronous, local, pull-based state
RxJS:
async
streams over time
Signals:
sync
value right now
1️⃣6️⃣ When Signals Are PERFECT
Use signals for:
UI state
Derived state
Local feature state
Change detection simplicity
count = signal(0);
increment() {
count.update(c => c + 1);
}
1️⃣7️⃣ computed() — Derived State Done Right
items = signal<Item[]>([]);
total = computed(() =>
this.items().reduce((a, b) => a + b.price, 0)
);
๐ No subscriptions
๐ Auto recalculation
๐ No memory leaks
1️⃣8️⃣ effect() — Controlled Side Effects
effect(() => {
if (this.total() > 1000) {
console.log('High value order');
}
});
⚠️ Rules:
❌ Don’t update signals inside effect unless intentional
❌ Don’t fetch APIs blindly
1️⃣9️⃣ Signals + RxJS Together (REAL WORLD)
You will NOT replace RxJS.
Example: API → signal
orders = signal<Order[]>([]);
loadOrders() {
this.api.getOrders().subscribe(o => this.orders.set(o));
}
Or RxJS → signal automatically
orders = toSignal(this.api.getOrders());
๐ RxJS = async source
๐ Signal = state holder
๐ฅ MESSY REAL CODE → LIVE RXJS REFACTOR
❌ Messy Real Code (Seen in Many Projects)
ngOnInit() {
this.route.params.subscribe(params => {
this.loading = true;
this.api.getUser(params['id']).subscribe(user => {
this.api.getOrders(user.id).subscribe(orders => {
this.api.getPayments(orders[0].id).subscribe(payments => {
this.payments = payments;
this.loading = false;
});
});
});
});
}
Problems:
4 nested subscriptions ❌
No cancellation ❌
Memory leaks ❌
Impossible to read ❌
✅ Step-by-Step Refactor (CONFIDENTLY)
Step 1: Flatten
this.route.params.pipe(
switchMap(p => this.api.getUser(p['id'])),
switchMap(user => this.api.getOrders(user.id)),
switchMap(orders => this.api.getPayments(orders[0].id))
)
Step 2: Move to service
payments$ = this.service.payments$;
Service:
payments$ = this.route.params.pipe(
switchMap(p => this.api.getUser(p['id'])),
switchMap(u => this.api.getOrders(u.id)),
switchMap(o => this.api.getPayments(o[0].id)),
shareReplay(1)
);
Step 3: Async pipe
<div *ngIf="payments$ | async as payments">
✔ No manual subscription
✔ Auto cleanup
✔ Readable
๐ฃ SIGNALS VERSION (Same Logic)
payments = toSignal(
this.route.params.pipe(
switchMap(p => this.api.getUser(p['id'])),
switchMap(u => this.api.getOrders(u.id)),
switchMap(o => this.api.getPayments(o[0].id))
),
{ initialValue: [] }
);
Template:
<div *ngFor="let p of payments()">
⚔️ SIGNALS vs RXJS — HONEST COMPARISON
| Topic | Signals | RxJS |
|---|---|---|
| Sync state | ✅ best | ❌ awkward |
| Async streams | ❌ no | ✅ best |
| UI derived state | ✅ perfect | ๐ |
| API orchestration | ❌ no | ✅ |
| Cancellation | ❌ | ✅ |
| Learning curve | Easy | Steep |
| Large-scale data flow | ❌ | ✅ |
๐ Golden Rule
RxJS for time.
Signals for state.
They are complements, not competitors.
๐จ Common Signal Mistakes (Avoid These)
❌ Using signals for HTTP polling
❌ Replacing all Observables
❌ Effects of doing business logic
❌ Storing backend DTOs directly
❌ Global signals everywhere
Lernings
Refactor nested subscribes without fear
Explain switchMap vs mergeMap in 1 sentence
Decide signal vs observable instantly
Keep components subscription-free
Use effects only for side effects