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
No comments:
Post a Comment