From 60aa7aeca962bd14a45192c2fcb57efb61a04a56 Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:51:17 +0530 Subject: [PATCH 1/8] Fix zoneless correctness: signal writes in subscribe examples --- docs/angular/anti-patterns.md | 14 ++++-- docs/angular/async-pipe.md | 86 +++++++++------------------------- docs/angular/reactive-forms.md | 4 ++ 3 files changed, 36 insertions(+), 68 deletions(-) diff --git a/docs/angular/anti-patterns.md b/docs/angular/anti-patterns.md index bfdf366..0a0b647 100644 --- a/docs/angular/anti-patterns.md +++ b/docs/angular/anti-patterns.md @@ -23,15 +23,19 @@ No cancellation (stale responses race), no unified error handling, leak-prone in ```typescript // FIX: flatten with a higher-order operator +protected readonly user = signal(null); + this.route.paramMap .pipe( map((params) => params.get("id")!), switchMap((id) => this.api.getUser(id)), takeUntilDestroyed(), ) - .subscribe((user) => (this.user = user)); + .subscribe((user) => this.user.set(user)); ``` +Note the subscribe callback writes to a **signal**, not a plain field. Under Angular's default zoneless change detection, a signal read in the template is what schedules a render; `this.user = user` on a plain field would update the value and never the view. + The choice of [`switchMap`](../operators/transformation/switchMap.md)/[`concatMap`](../operators/transformation/concatMap.md)/[`exhaustMap`](../operators/transformation/exhaustMap.md) is the follow-up question; have a reason ready. ## 2. Fat Subscribe Callbacks @@ -48,7 +52,9 @@ stream$.subscribe((data) => { Untestable, unreusable, invisible to operators downstream. ```typescript -// FIX: declare the transformation in the pipe; subscribe only assigns +// FIX: declare the transformation in the pipe; subscribe only writes the signal +protected readonly names = signal([]); + stream$ .pipe( map((data) => @@ -56,7 +62,7 @@ stream$ ), filter((names) => names.length > 0), ) - .subscribe((names) => (this.names = names)); + .subscribe((names) => this.names.set(names)); ``` ## 3. Exposing Raw Subjects @@ -90,7 +96,7 @@ protected readonly items = toSignal(this.api.getItems(), { initialValue: [] }); // or in the template: @if (items$ | async; as items) { ... } ``` -Manual subscriptions are for side effects; display data belongs to [`toSignal`](signals-interop.md) or the [async pipe](async-pipe.md). +Manual subscriptions are for side effects; display data belongs to [`toSignal`](signals-interop.md) or the [async pipe](async-pipe.md). The smell has a second failure mode on top of the boilerplate: the plain-field assignment is not a change-detection notification source, so under Angular's default zoneless mode the view never updates from it at all. ## 5. `shareReplay` With Default Config on Infinite Sources diff --git a/docs/angular/async-pipe.md b/docs/angular/async-pipe.md index f51a224..0a4114c 100644 --- a/docs/angular/async-pipe.md +++ b/docs/angular/async-pipe.md @@ -10,7 +10,7 @@ Think of the `async` pipe as a smart assistant for handling asynchronous data ri 1. **Subscribes:** When the component loads, the `async` pipe automatically subscribes to the Observable (or Promise) you provide it. 2. **Unwraps Values:** When the Observable emits a new value, the `async` pipe "unwraps" that value and makes it available for binding in your template. -3. **Triggers Change Detection:** It automatically tells Angular to check the component for changes whenever a new value arrives, ensuring your view updates. +3. **Triggers Change Detection:** It calls `markForCheck()` on every emission. Under Angular's default zoneless change detection this is not a convenience, it is the reason the binding updates at all: nothing else notifies Angular that a new value arrived. 4. **Unsubscribes Automatically:** This is a huge benefit! When the component is destroyed, the `async` pipe automatically unsubscribes from the Observable, preventing potential memory leaks. You don't need manual unsubscription logic (like `takeUntilDestroyed` or `.unsubscribe()`) _for the subscription managed by the pipe itself_. 5. **Handles Null/Undefined Initially:** Before the Observable emits its first value, the `async` pipe typically returns `null`, which you can handle gracefully in your template (usually with an `@if`/`@else` block). @@ -81,24 +81,18 @@ export class UserService { **2. User Display Component** ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - Input, - OnInit, -} from "@angular/core"; +import { Component, inject, input } from "@angular/core"; import { AsyncPipe } from "@angular/common"; // the async pipe itself +import { toObservable } from "@angular/core/rxjs-interop"; +import { Observable, switchMap } from "rxjs"; import { UserService, UserProfile } from "./user.service"; // Adjust path -import { Observable, EMPTY } from "rxjs"; // Import Observable and EMPTY @Component({ selector: "app-user-display", imports: [AsyncPipe], template: `
-

User Profile (ID: {{ userId }})

+

User Profile (ID: {{ userId() }})

@if (user$ | async; as user) { @@ -108,11 +102,6 @@ import { Observable, EMPTY } from "rxjs"; // Import Observable and EMPTY

Username: {{ user.username }}

Email: {{ user.email }}

- - } @else {

Loading user data...

@@ -120,51 +109,17 @@ import { Observable, EMPTY } from "rxjs"; // Import Observable and EMPTY `, - // No 'styles' section - changeDetection: ChangeDetectionStrategy.OnPush, // Good practice with async pipe/observables }) -export class UserDisplayComponent implements OnInit { - private userService = inject(UserService); +export class UserDisplayComponent { + private readonly userService = inject(UserService); - @Input({ required: true }) userId!: number; // Get user ID from parent + readonly userId = input.required(); // signal input from the parent - // Expose the Observable directly to the template - user$: Observable = EMPTY; // Initialize with EMPTY or handle null later - - ngOnInit() { - // Assign the observable in ngOnInit (or wherever appropriate) - // NO .subscribe() here for the template binding! - this.user$ = this.userService.getUser(this.userId); - console.log( - `UserDisplayComponent (ID: ${this.userId}): Assigned observable to user$`, - ); - } - - // --- Compare with manual subscription (for illustration) --- - // // Manual Approach (requires more code + manual unsubscription handling): - // private destroyRef = inject(DestroyRef); - // userSignal = signal(null); - // loading = signal(false); - - // ngOnInitManual() { - // this.loading.set(true); - // this.userService.getUser(this.userId) - // .pipe( - // takeUntilDestroyed(this.destroyRef) // Need manual unsubscribe handling - // ) - // .subscribe({ - // next: (user) => { - // this.userSignal.set(user); // Store in component state - // this.loading.set(false); - // }, - // error: (err) => { - // console.error(err); - // this.loading.set(false); - // // Handle error state... - // } - // }); - // } - // // Then in template you'd bind to userSignal() and loading() + // Derive the stream from the input signal. + // NO .subscribe() here: the template's async pipe is the only consumer. + protected readonly user$: Observable = toObservable( + this.userId, + ).pipe(switchMap((id) => this.userService.getUser(id))); } ``` @@ -193,18 +148,17 @@ export class AppComponent {} **Explanation:** 1. `UserService` provides a `getUser(id)` method that returns an `Observable`. It includes caching and `shareReplay` for efficiency. -2. `UserDisplayComponent` gets a `userId` via `@Input`. -3. In `ngOnInit`, it calls `userService.getUser(this.userId)` and assigns the **returned Observable directly** to the public component property `user$`. **Crucially, there is no `.subscribe()` call here.** -4. In the template: +2. `UserDisplayComponent` declares a required `userId` **input signal** and derives `user$` from it in a field initializer: `toObservable(this.userId)` turns the input into a stream, and `switchMap` fetches the matching user, cancelling a stale request if the parent changes the id. **Crucially, there is no `.subscribe()` call anywhere in the class.** +3. In the template: - `@if (user$ | async; as user)`: This is the core line. - `user$ | async`: The `async` pipe subscribes to the `user$` observable. Initially, it returns `null`. - `as user`: If/when the `user$` observable emits a value, that value (the `UserProfile` object) is assigned to a local template variable named `user`. - The `@if` block only renders its content when `user$ | async` produces a "truthy" value (i.e., after the user profile has been emitted). - Inside the `@if` block, we can directly access properties of the resolved `user` object (e.g., `user.name`, `user.email`). - The `@else` block handles the initial state, showing "Loading user data..." until the `async` pipe receives the first emission. -5. When the `UserDisplayComponent` is destroyed (e.g., navigated away from), the `async` pipe automatically cleans up its subscription to `user$`. +4. When the `UserDisplayComponent` is destroyed (e.g., navigated away from), the `async` pipe automatically cleans up its subscription to `user$`. -Compare this component's TypeScript code to the commented-out `ngOnInitManual` example. The `async` pipe version is much cleaner and less error-prone for simply displaying the data. +No `changeDetection` configuration is needed: `OnPush` is Angular's default strategy, and the async pipe's `markForCheck()` call is exactly the notification `OnPush` waits for. (Opting a component out to `ChangeDetectionStrategy.Eager` is reserved for legacy code that mutates plain fields without notifying Angular.) For the manual-subscription alternative and when to prefer it, see "Async Pipe or toSignal?" below. ## Error Handling @@ -223,7 +177,11 @@ In signals-first codebases `toSignal` is becoming the default; the `async` pipe ??? question "What does the async pipe actually do under the hood?" - It subscribes on first evaluation, stores the latest value, calls `markForCheck()` on each emission so OnPush components re-render, and unsubscribes in its `ngOnDestroy`. That last part is why it is the standard answer to "how do you avoid subscription leaks in templates?" + It subscribes on first evaluation, stores the latest value, calls `markForCheck()` on each emission so the component (OnPush by default) re-renders, and unsubscribes in its `ngOnDestroy`. That last part is why it is the standard answer to "how do you avoid subscription leaks in templates?" + +??? question "Why does the async pipe still work without Zone.js?" + + Because `markForCheck()` is one of the notification sources zoneless change detection listens to (alongside signal writes read by templates, `ComponentRef.setInput`, and bound listeners). The pipe never depended on Zone.js patching; it always told Angular explicitly. That is also why a manual `subscribe` that assigns a plain field breaks under zoneless while the same stream through the async pipe keeps rendering. ??? question "Why can multiple async pipes on the same Observable be a problem?" diff --git a/docs/angular/reactive-forms.md b/docs/angular/reactive-forms.md index 39d21e3..b970631 100644 --- a/docs/angular/reactive-forms.md +++ b/docs/angular/reactive-forms.md @@ -15,6 +15,10 @@ Reactive forms are stream factories: every control exposes `valueChanges` and `s - **Top gotcha:** neither stream emits the **initial** value; combine with `startWith(control.value)` when the pipeline needs it - **Second gotcha:** `setValue`/`patchValue` also trigger `valueChanges`; use `{ emitEvent: false }` to avoid feedback loops +!!! warning "Zoneless change detection and form state" + + Programmatic model updates (`setValue`, `patchValue`, `FormArray` mutations) update form state and emit through `valueChanges`/`statusChanges`, but they do **not** schedule change detection under Angular's default zoneless mode. Templates that display form-derived state must consume it through a notification source: pipe the form streams into signals with `toSignal`, or bind with the async pipe, as the patterns on this page do. + ## Pattern 1: Debounced Search The canonical pipeline, covered in depth on [debounceTime](../operators/filtering/debounceTime.md) and [switchMap](../operators/transformation/switchMap.md): From 3afec5faf28fd5b27006a345b626426bf85a8217 Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:56:43 +0530 Subject: [PATCH 2/8] Correct eight verified defects from the accuracy audit --- docs/angular/router.md | 2 +- docs/learn/hot-observables.md | 2 +- docs/learn/promise-vs-observable.md | 4 ++-- docs/learn/unicast-vs-multicast.md | 10 ++++++---- docs/operators/combination/withLatestFrom.md | 3 +-- docs/operators/creation/empty-never.md | 6 +++--- docs/operators/filtering/take.md | 6 +++++- docs/operators/utility/delay.md | 7 ++++--- docs/operators/utility/timeout.md | 8 ++++---- 9 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/angular/router.md b/docs/angular/router.md index 32c6c75..146a4af 100644 --- a/docs/angular/router.md +++ b/docs/angular/router.md @@ -116,7 +116,7 @@ export class LoadingBarComponent { ??? question "Do ActivatedRoute subscriptions need manual cleanup?" - The route-scoped streams complete when their route is destroyed, so bare subscriptions to them are mostly self-cleaning. Streams you derive from them that hop to other sources (HTTP, timers), and anything on `Router.events`, follow the normal cleanup rules. + Bare subscriptions to the route-scoped streams (`paramMap`, `data`) are mostly harmless, but for a precise reason: the `ActivatedRoute` and the component are destroyed together, so nothing outlives anything else. The streams do **not** complete; no terminal notification is delivered. Streams you derive from them that hop to other sources (HTTP, timers), and anything on `Router.events`, follow the normal cleanup rules. ??? question "How would you drive a global progress bar from the router?" diff --git a/docs/learn/hot-observables.md b/docs/learn/hot-observables.md index 3a7a247..6d240b4 100644 --- a/docs/learn/hot-observables.md +++ b/docs/learn/hot-observables.md @@ -37,7 +37,7 @@ Remember, Cold Observables only start when you subscribe, and each subscription ## Examples in Angular/RxJS -1. **DOM Events using `fromEvent`:** Observables created from browser events are inherently hot. +1. **DOM Events using `fromEvent`:** Observables created from browser events are inherently hot: the events happen whether or not anyone subscribed, and late subscribers miss earlier clicks. (One precision worth knowing: each `fromEvent` subscriber installs its **own** event listener, so the subscription itself is unicast; see [Unicast vs Multicast](unicast-vs-multicast.md).) ```typescript import { diff --git a/docs/learn/promise-vs-observable.md b/docs/learn/promise-vs-observable.md index 6bcc044..9ae5a71 100644 --- a/docs/learn/promise-vs-observable.md +++ b/docs/learn/promise-vs-observable.md @@ -61,7 +61,7 @@ In modern Angular, while you _can_ use Promises, Observables are generally prefe ## Converting Between Them - **Promise to Observable:** [`from(promise)`](../operators/creation/from.md). Remember the promise is still eager and non-cancellable underneath. -- **Observable to Promise:** `firstValueFrom(obs$)` resolves with the first emission; `lastValueFrom(obs$)` waits for completion and resolves with the final value. Both reject if the source errors, or completes without a value. The old `.toPromise()` method is deprecated and removed in current RxJS; mentioning its replacements is an easy interview win. +- **Observable to Promise:** `firstValueFrom(obs$)` resolves with the first emission; `lastValueFrom(obs$)` waits for completion and resolves with the final value. Both reject if the source errors, or completes without a value. The old `.toPromise()` method is deprecated and slated for removal in the next major version; it still exists in current RxJS, but knowing its replacements is an easy interview win. ## Interview Q&A @@ -71,7 +71,7 @@ In modern Angular, while you _can_ use Promises, Observables are generally prefe ??? question "How do you convert an Observable to a Promise in current RxJS, and what happened to toPromise()?" - `firstValueFrom` and `lastValueFrom`. `toPromise()` was deprecated and removed, partly because its behavior on empty completion (resolving with `undefined`) was ambiguous; `lastValueFrom` rejects with `EmptyError` instead, making the empty case explicit. + `firstValueFrom` and `lastValueFrom`. `toPromise()` is deprecated and slated for removal in the next major version (it still works today), partly because its behavior on empty completion (resolving with `undefined`) was ambiguous; `lastValueFrom` rejects with `EmptyError` instead, making the empty case explicit. ??? question "Can a Promise be cancelled?" diff --git a/docs/learn/unicast-vs-multicast.md b/docs/learn/unicast-vs-multicast.md index b7d188a..8e50532 100644 --- a/docs/learn/unicast-vs-multicast.md +++ b/docs/learn/unicast-vs-multicast.md @@ -36,10 +36,12 @@ setTimeout(() => multicast$.subscribe((v) => console.log(`M-B: ${v}`)), 1500); ## The Mapping That Answers Most Interview Questions -| Concept | Producer | Late subscribers | Examples | -| ------------------- | --------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- | -| **Unicast / cold** | Created per subscriber, inside the Observable | Get a fresh execution from the start | `HttpClient`, `of`, `from`, `interval`, `defer` | -| **Multicast / hot** | One shared execution, outside or wrapped | Join live; missed values are gone (unless replayed) | `Subject` family, `fromEvent`, `share`, `shareReplay` | +| Concept | Producer | Late subscribers | Examples | +| ------------------- | --------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| **Unicast / cold** | Created per subscriber, inside the Observable | Get a fresh execution from the start | `HttpClient`, `of`, `from`, `interval`, `defer`, `fromEvent` (hot producer, see below) | +| **Multicast / hot** | One shared execution, outside or wrapped | Join live; missed values are gone (unless replayed) | `Subject` family, `share`, `shareReplay` | + +One entry deserves precision, because interviewers probe it: **`fromEvent` is a unicast subscription over a hot producer.** The event source (the DOM) is live and shared, so late subscribers miss earlier events, which is why the [hot observables](hot-observables.md) page uses it as an example. But each subscriber installs its **own** event listener: three subscriptions to `fromEvent(button, "click")` mean three `addEventListener` calls. One shared listener requires explicit multicasting: `fromEvent(...).pipe(share())`. Two practical consequences: diff --git a/docs/operators/combination/withLatestFrom.md b/docs/operators/combination/withLatestFrom.md index 0d87f0e..12eef59 100644 --- a/docs/operators/combination/withLatestFrom.md +++ b/docs/operators/combination/withLatestFrom.md @@ -145,8 +145,7 @@ export class ProductSearchComponent implements OnInit { 2. **`searchTerm$`:** We get an Observable of the search term's changes using `valueChanges`. We apply: - `debounceTime(400)`: To wait until the user stops typing for 400ms before considering the term stable. - `distinctUntilChanged()`: To avoid triggering searches if the debounced term is the same as the last one. - - `startWith()`: To ensure the stream has an initial value so `withLatestFrom` can emit right away if the category also has a value. This makes the initial state work correctly. -3. **`categoryFilter$`:** We get an Observable of the category changes using `valueChanges`. We also use `startWith()` here for the initial value. +3. **`categoryFilter$`:** We get an Observable of the category changes using `valueChanges`, plus `startWith(control.value)` so the stream has a value before the user touches the dropdown. This seed is essential, and it belongs on the **secondary** stream: `withLatestFrom` silently drops source emissions until every secondary has produced at least one value, so without it, early searches would vanish. 4. **`withLatestFrom(categoryFilter$)`:** We pipe the `searchTerm$` (our source). When `searchTerm$` emits a value (after debouncing), `withLatestFrom` looks at `categoryFilter$` and gets its _most recently emitted value_. 5. **`subscribe(([term, category]) => ...)`:** The result is an array `[sourceValue, latestOtherValue]`. We destructure this into `term` and `category`. This callback function is executed _only_ when the debounced search term changes. Inside, we have exactly what we need: the current search term and the _latest_ selected category at that moment. 6. **`takeUntilDestroyed(this.destroyRef)`:** This is the modern Angular way to handle unsubscriptions. When the `ProductSearchComponent` is destroyed, this operator automatically completes the Observable stream, preventing memory leaks without manual cleanup. diff --git a/docs/operators/creation/empty-never.md b/docs/operators/creation/empty-never.md index b529994..2dcacba 100644 --- a/docs/operators/creation/empty-never.md +++ b/docs/operators/creation/empty-never.md @@ -21,13 +21,13 @@ Think of it like this: Both represent a stream that will _never give you any dat - **What it does:** Represents an Observable that emits **zero** items. - **Key Behavior:** It **never** sends a `complete` notification and **never** sends an `error` notification. It remains silent indefinitely after subscription. - **Analogy:** It's like a process that hangs forever without producing output or terminating, or a phone line that just keeps ringing and ringing without ever being answered or going to an error state. It signals "I have nothing for you right now, and I might _never_ have anything, and I'm certainly not finished." - - **Use Case:** Represents a stream that simply never emits or terminates. This can be useful in testing scenarios or when you want to keep a combination operator (like `race` or `combineLatest`) alive, even if one of its potential sources will never produce a relevant value or complete. It effectively keeps that "slot" open indefinitely without signalling completion or error. It can also be used intentionally to prevent parts of an Observable chain from completing. + - **Use Case:** Represents a stream that simply never emits or terminates. Useful in testing, and for deliberately preventing completion in operators where completion is what matters: a `NEVER` input keeps [`merge`](../combination/merge.md) or [`concat`](../combination/concat.md) from ever completing, and `switchMap(() => NEVER)` parks a pipeline until the next outer value. **It does not "keep `combineLatest` or `race` alive":** `combineLatest` needs every source to emit at least once, so a `NEVER` input suppresses all output permanently, and in `race` a `NEVER` contender simply loses and is unsubscribed. !!! abstract "At a glance" - **Signature:** `EMPTY` and `NEVER` are constants, not functions; import and use them directly - **Use `EMPTY` when:** a branch should produce nothing but still complete, typically inside `switchMap`/`concatMap` conditionals or `catchError` - - **Use `NEVER` when:** a stream must stay open silently: tests, keeping combinators alive, deliberately preventing completion + - **Use `NEVER` when:** a stream must stay open silently: tests, preventing completion in `merge`/`concat` pipelines, parking a branch inside a higher-order map - **Top gotcha:** returning `NEVER` where `EMPTY` was meant leaves queues (`concatMap`) and joins (`forkJoin`) waiting forever ## Direct Comparison @@ -168,7 +168,7 @@ Choose `NEVER` when you need an Observable that does nothing and _never_ signals ??? question "What breaks if you use NEVER instead of EMPTY inside concatMap?" - `concatMap` waits for each inner Observable to complete before starting the next. `NEVER` never completes, so the queue stalls permanently: every later source value waits behind a stream that will not end. The same trap freezes `forkJoin` and delays `combineLatest` first emissions. + `concatMap` waits for each inner Observable to complete before starting the next. `NEVER` never completes, so the queue stalls permanently: every later source value waits behind a stream that will not end. The same trap freezes `forkJoin`, and a `NEVER` source in `combineLatest` blocks it from ever emitting, since every input must emit at least once. ??? question "Does subscribing to EMPTY require cleanup?" diff --git a/docs/operators/filtering/take.md b/docs/operators/filtering/take.md index fc46cc5..f3935e0 100644 --- a/docs/operators/filtering/take.md +++ b/docs/operators/filtering/take.md @@ -107,7 +107,11 @@ export class TakeDemoComponent { ??? question "What is the difference between take(1) and first()?" - Both emit at most one value and complete. `first()` accepts an optional predicate and **errors** with `EmptyError` if the source completes without a matching value; `take(1)` never errors on empty completion. `first(predicate)` is also equivalent to `filter(predicate)` + `take(1)`. + Both emit at most one value and complete. `first()` accepts an optional predicate and **errors** with `EmptyError` if the source completes without a matching value; `take(1)` never errors on empty completion. That is why `first(predicate)` expands to `filter(predicate)` + `take(1)` + `throwIfEmpty()`: drop the `throwIfEmpty` and a no-match source completes silently instead of erroring. + +??? question "What does take(0) do?" + + It completes immediately and emits nothing; the source is never even subscribed. A common edge-case probe, and occasionally useful when a computed count can legitimately be zero. ??? question "Why is take useful on infinite streams?" diff --git a/docs/operators/utility/delay.md b/docs/operators/utility/delay.md index 440e15d..5e34993 100644 --- a/docs/operators/utility/delay.md +++ b/docs/operators/utility/delay.md @@ -161,14 +161,15 @@ export class SaveStatusComponent { // --------------------- catchError((err: Error) => { - // Handle the error AFTER the delay - console.error("UI: Handling error after delay:", err.message); + // Handle the error immediately (errors skip the delay) + console.error("UI: Handling error immediately:", err.message); this.isSuccess.set(false); this.statusMessage.set(`Error: ${err.message}`); // Return EMPTY to gracefully complete the stream for finalize return EMPTY; }), - // finalize runs after delay + next/error/complete + // finalize runs after next/complete pass through the delay; + // on the error path it runs immediately, since errors are not delayed finalize(() => { console.log('UI: Finalizing save operation (hiding "Saving...")'); this.saving.set(false); diff --git a/docs/operators/utility/timeout.md b/docs/operators/utility/timeout.md index 1b7fc47..caaf264 100644 --- a/docs/operators/utility/timeout.md +++ b/docs/operators/utility/timeout.md @@ -7,14 +7,14 @@ tags: # timeout -The `timeout` operator sets a time limit. If the source Observable doesn't emit its **first value** or **complete** within that specified duration, the `timeout` operator will cause the stream to **emit a `TimeoutError`** and terminate. +The `timeout` operator sets a time limit on **every gap** in a stream. `timeout(n)` is shorthand for `timeout({ each: n })`: if more than `n` milliseconds pass between the subscription and the first value, or between any two consecutive values, the operator **emits a `TimeoutError`** and terminates the stream. To bound only the wait for the first value, use `timeout({ first: n })`. -Think of it like setting an **egg timer** for an operation: +Think of it like setting an **egg timer** that you rewind after every delivery: - You start an operation (subscribe to the source Observable). - You start the timer (`timeout` operator). -- If the operation finishes (emits a value or completes) _before_ the timer goes off, everything is fine. -- If the timer goes off _before_ the operation finishes, the timer rings loudly (`TimeoutError` is emitted), and you stop waiting for the original operation. +- Every time a value arrives _before_ the timer goes off, the timer resets and the wait starts over. +- If the timer goes off first, it rings loudly (`TimeoutError` is emitted), and you stop waiting for the original operation. ## Key Configurations & Behaviors From 011b7cec32fb044540cf46d0f060cb6c4ba68a46 Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:02:09 +0530 Subject: [PATCH 3/8] Precision pass: 18 small accuracy and consistency fixes --- docs/angular/http-patterns.md | 5 +- docs/angular/interceptors-retry.md | 10 +- .../switchMap-mergeMap-concatMap.md | 2 +- docs/interview-prep/cheat-sheet-operators.md | 84 +++++++------- docs/interview-prep/coding-challenges.md | 4 +- docs/interview-prep/scenarios.md | 1 + docs/learn/hot-observables.md | 3 +- docs/operators/combination/combineLatest.md | 3 +- docs/operators/combination/forkJoin.md | 1 + docs/operators/combination/startWith.md | 5 +- docs/operators/creation/empty-never.md | 105 ++++++------------ docs/operators/error-handling/retry.md | 15 ++- docs/operators/filtering/auditTime.md | 2 +- docs/operators/filtering/debounceTime.md | 2 +- .../filtering/distinctUntilChanged.md | 2 +- docs/operators/transformation/buffer.md | 2 +- docs/subjects/behaviorSubject.md | 2 +- docs/subjects/share.md | 2 +- 18 files changed, 109 insertions(+), 141 deletions(-) diff --git a/docs/angular/http-patterns.md b/docs/angular/http-patterns.md index 36dbd7b..6bbc90b 100644 --- a/docs/angular/http-patterns.md +++ b/docs/angular/http-patterns.md @@ -26,7 +26,8 @@ type State = @Component({ selector: "app-products", template: ` - @switch (state().status) { + @let s = state(); + @switch (s.status) { @case ("loading") {

Loading...

} @@ -34,7 +35,7 @@ type State =

Could not load products.

} @case ("loaded") { -

{{ $any(state()).data.length }} products

+

{{ s.data.length }} products

} } `, diff --git a/docs/angular/interceptors-retry.md b/docs/angular/interceptors-retry.md index e2b0539..23d52f9 100644 --- a/docs/angular/interceptors-retry.md +++ b/docs/angular/interceptors-retry.md @@ -18,7 +18,7 @@ HTTP interceptors are RxJS pipelines: an interceptor receives the request, calls ```typescript import { HttpErrorResponse, HttpInterceptorFn } from "@angular/common/http"; -import { retry, throwError, timeout, timer } from "rxjs"; +import { retry, throwError, timeout, TimeoutError, timer } from "rxjs"; export const resilienceInterceptor: HttpInterceptorFn = (req, next) => { // only idempotent requests are safe to retry blindly @@ -28,9 +28,11 @@ export const resilienceInterceptor: HttpInterceptorFn = (req, next) => { timeout(10_000), // bound every attempt retry({ count: retryCount, - delay: (error: HttpErrorResponse, attempt) => - // transient failures only: network errors and 5xx - error.status === 0 || error.status >= 500 + // transient failures only: timeouts, network errors, and 5xx + delay: (error: unknown, attempt) => + error instanceof TimeoutError || + (error instanceof HttpErrorResponse && + (error.status === 0 || error.status >= 500)) ? timer(500 * Math.pow(2, attempt - 1)) // 500ms, 1s backoff : throwError(() => error), }), diff --git a/docs/comparisons/switchMap-mergeMap-concatMap.md b/docs/comparisons/switchMap-mergeMap-concatMap.md index 7ff67e2..f6786e6 100644 --- a/docs/comparisons/switchMap-mergeMap-concatMap.md +++ b/docs/comparisons/switchMap-mergeMap-concatMap.md @@ -19,7 +19,7 @@ Here’s a theoretical comparison: - **Order:** Output values come only from the most recent inner Observable. The order depends on that inner Observable, but older inner streams are cancelled entirely. - **Use When:** You only care about the results corresponding to the **most recent** source emission. Useful for scenarios like type-ahead search suggestions where previous requests become irrelevant. -2. **`mergeMap` (alias: `flatMap`)** +2. **`mergeMap`** (its old alias `flatMap` still exists but is deprecated and slated for removal) - **Strategy:** Concurrency / Merging. - **Behavior:** When the source Observable emits a value, `mergeMap` maps it to an inner Observable and subscribes. If the source emits a _new_ value, `mergeMap` **does not cancel** any previous inner Observables. It simply creates and subscribes to the new inner Observable, allowing multiple inner Observables to run **concurrently**. - **Concurrency:** Can have multiple inner Observables running in parallel. The level of concurrency can optionally be limited by passing a second argument to `mergeMap`. diff --git a/docs/interview-prep/cheat-sheet-operators.md b/docs/interview-prep/cheat-sheet-operators.md index 45216e1..fa572d8 100644 --- a/docs/interview-prep/cheat-sheet-operators.md +++ b/docs/interview-prep/cheat-sheet-operators.md @@ -10,34 +10,34 @@ One page, every decision table. Built for the last hour before an interview. ## "I want to..." → Operator -| I want to... | Use | Why | -| ------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------- | -| Transform each value | [`map`](../operators/transformation/map.md) | Pure per-value projection | -| Run a side effect without changing values | [`tap`](../operators/utility/tap.md) | Return value ignored | -| Accumulate state across emissions | [`scan`](../operators/transformation/scan.md) | Emits every intermediate result | -| Call an API per value, latest wins | [`switchMap`](../operators/transformation/switchMap.md) | Cancels the stale inner stream | -| Call an API per value, all in parallel | [`mergeMap`](../operators/transformation/mergeMap.md) | Optional concurrency cap | -| Call an API per value, strictly in order | [`concatMap`](../operators/transformation/concatMap.md) | Queues; = `mergeMap(fn, 1)` | -| Ignore triggers while one is running | [`exhaustMap`](../operators/transformation/exhaustMap.md) | Drops, does not queue | -| Drop values failing a condition | [`filter`](../operators/filtering/filter.md) | Stream stays alive | -| Settle bursty input (typing) | [`debounceTime`](../operators/filtering/debounceTime.md) | Emits after silence | -| Steady rate from continuous events | [`throttleTime`](../operators/filtering/throttleTime.md) | Consider `{ trailing: true }` | -| Skip unchanged repeats | [`distinctUntilChanged`](../operators/filtering/distinctUntilChanged.md) | `===` by default; objects need a comparator | -| First N values then stop | [`take`](../operators/filtering/take.md) | Completes + unsubscribes | -| Stop on an external signal | [`takeUntil`](../operators/filtering/takeUntil.md) | Keep it last in the pipe | -| Stop when the data says so | [`takeWhile`](../operators/filtering/takeWhile.md) | `inclusive: true` for the final value | -| Combine latest values of live streams | [`combineLatest`](../operators/combination/combineLatest.md) | Silent until all emit once | -| All parallel calls, one final result | [`forkJoin`](../operators/combination/forkJoin.md) | Inputs must complete | -| Attach current state to an event | [`withLatestFrom`](../operators/combination/withLatestFrom.md) | Only the source triggers | -| Funnel independent triggers into one stream | [`merge`](../operators/combination/merge.md) | First come, first served | -| One stream after another completes | [`concat`](../operators/combination/concat.md) | Cache-then-network | -| Give a stream an immediate first value | [`startWith`](../operators/combination/startWith.md) | Unblocks combineLatest; loading states | -| Recover from errors with a fallback | [`catchError`](../operators/error-handling/catchError.md) | Must return an Observable | -| Retry transient failures | [`retry`](../operators/error-handling/retry.md) | `{ count, delay }`; gate on error type | -| Bound waiting time | [`timeout`](../operators/utility/timeout.md) | Number = every gap; `with` for fallback | -| Guaranteed cleanup | [`finalize`](../operators/utility/finalize.md) | Runs on complete, error, AND unsubscribe | -| Share one execution, live only | [`share`](../subjects/share.md) | Resets by default at zero subscribers | -| Share + cache for late subscribers | [`shareReplay`](../subjects/shareReplay.md) | `{ bufferSize: 1, refCount: true }` | +| I want to... | Use | Why | +| ------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | +| Transform each value | [`map`](../operators/transformation/map.md) | Pure per-value projection | +| Run a side effect without changing values | [`tap`](../operators/utility/tap.md) | Return value ignored | +| Accumulate state across emissions | [`scan`](../operators/transformation/scan.md) | Emits every intermediate result | +| Call an API per value, latest wins | [`switchMap`](../operators/transformation/switchMap.md) | Cancels the stale inner stream | +| Call an API per value, all in parallel | [`mergeMap`](../operators/transformation/mergeMap.md) | Optional concurrency cap | +| Call an API per value, strictly in order | [`concatMap`](../operators/transformation/concatMap.md) | Queues; = `mergeMap(fn, 1)` | +| Ignore triggers while one is running | [`exhaustMap`](../operators/transformation/exhaustMap.md) | Drops, does not queue | +| Drop values failing a condition | [`filter`](../operators/filtering/filter.md) | Stream stays alive | +| Settle bursty input (typing) | [`debounceTime`](../operators/filtering/debounceTime.md) | Emits after silence | +| Steady rate from continuous events | [`throttleTime`](../operators/filtering/throttleTime.md) | Consider `{ trailing: true }` | +| Skip unchanged repeats | [`distinctUntilChanged`](../operators/filtering/distinctUntilChanged.md) | `===` by default; objects need a comparator | +| First N values then stop | [`take`](../operators/filtering/take.md) | Completes + unsubscribes | +| Stop on an external signal | [`takeUntil`](../operators/filtering/takeUntil.md) | Keep it last in the pipe | +| Stop when the data says so | [`takeWhile`](../operators/filtering/takeWhile.md) | `inclusive: true` for the final value | +| Combine latest values of live streams | [`combineLatest`](../operators/combination/combineLatest.md) | Silent until all emit once | +| All parallel calls, one final result | [`forkJoin`](../operators/combination/forkJoin.md) | Inputs must complete | +| Attach current state to an event | [`withLatestFrom`](../operators/combination/withLatestFrom.md) | Only the source triggers | +| Funnel independent triggers into one stream | [`merge`](../operators/combination/merge.md) | First come, first served | +| One stream after another completes | [`concat`](../operators/combination/concat.md) | Cache-then-network | +| Give a stream an immediate first value | [`startWith`](../operators/combination/startWith.md) | Unblocks combineLatest; loading states | +| Recover from errors with a fallback | [`catchError`](../operators/error-handling/catchError.md) | Must return an Observable | +| Retry transient failures | [`retry`](../operators/error-handling/retry.md) | `{ count, delay }`; gate on error type | +| Bound waiting time | [`timeout`](../operators/utility/timeout.md) | Number = every gap; `with` for fallback | +| Guaranteed cleanup | [`finalize`](../operators/utility/finalize.md) | Runs on complete, error, AND unsubscribe | +| Share one execution, live only | [`share`](../subjects/share.md) | Resets at zero subscribers or on complete/error | +| Share + cache for late subscribers | [`shareReplay`](../subjects/shareReplay.md) | `{ bufferSize: 1, refCount: true }` | ## Higher-Order Mapping: The Quadrant @@ -60,23 +60,23 @@ Triple-click Save: `mergeMap` 3 racing requests · `concatMap` 3 sequential · ` ## Timing Operators -| Operator | Behavior | Fits | -| ----------------------------------------------------------- | ------------------------------------------ | ------------------------- | -| [`debounceTime(t)`](../operators/filtering/debounceTime.md) | Last value after t of silence | Typing | -| [`throttleTime(t)`](../operators/filtering/throttleTime.md) | Value, then cooldown t | Scroll, clicks | -| [`auditTime(t)`](../operators/filtering/auditTime.md) | On activity, wait t, emit latest | Steady sampling of bursts | -| [`sampleTime(t)`](../operators/filtering/sampleTime.md) | Every t, emit latest if any | Fixed-clock readouts | -| [`delay(t)`](../operators/utility/delay.md) | Shift each value by t (errors NOT delayed) | Minimum display time | +| Operator | Behavior | Fits | +| ----------------------------------------------------------- | ------------------------------------------------ | ------------------------- | +| [`debounceTime(t)`](../operators/filtering/debounceTime.md) | Last value after t of silence | Typing | +| [`throttleTime(t)`](../operators/filtering/throttleTime.md) | Value, then cooldown t | Scroll, clicks | +| [`auditTime(t)`](../operators/filtering/auditTime.md) | On activity, wait t, emit latest | Steady sampling of bursts | +| [`sampleTime(t)`](../operators/filtering/sampleTime.md) | Every t, emit latest only if new since last tick | Fixed-clock readouts | +| [`delay(t)`](../operators/utility/delay.md) | Shift each value by t (errors NOT delayed) | Minimum display time | ## Unsubscribe Strategies -| Strategy | When | Notes | -| ----------------------- | ------------------------- | -------------------------------------------- | -| Async pipe / `toSignal` | Display data | No manual subscription at all | -| `takeUntilDestroyed()` | Class-code pipelines | Injection context (or pass `DestroyRef`) | -| `takeUntil(destroy$)` | Legacy / explicit control | `next()` then `complete()`; keep it **last** | -| `take(1)` / `first()` | One-shot reads | Self-completing | -| Manual `unsubscribe()` | Last resort | Collect with `subscription.add()` | +| Strategy | When | Notes | +| ----------------------- | ------------------------- | ----------------------------------------------------------------- | +| Async pipe / `toSignal` | Display data | No manual subscription at all | +| `takeUntilDestroyed()` | Class-code pipelines | Injection context (or pass `DestroyRef`) | +| `takeUntil(destroy$)` | Legacy / explicit control | `next()` then `complete()`; keep it **last** | +| `take(1)` / `first()` | One-shot reads | Self-completing; on empty: `take(1)` silent, `first()` EmptyError | +| Manual `unsubscribe()` | Last resort | Collect with `subscription.add()` | Not needed for plain `HttpClient` calls: they complete themselves. Needed for: intervals, `fromEvent`, Subjects, `valueChanges`, `Router.events`. diff --git a/docs/interview-prep/coding-challenges.md b/docs/interview-prep/coding-challenges.md index 636de0a..c554a40 100644 --- a/docs/interview-prep/coding-challenges.md +++ b/docs/interview-prep/coding-challenges.md @@ -84,7 +84,7 @@ Live-coding rounds ask for small, precise pipelines. Write each one yourself (ed Without `startWith`, nothing emits until **both** controls have been touched, the classic `combineLatest` trap. -## 6. Retry a request 3 times with exponential backoff, then fall back to a default +## 6. Retry a request up to 3 times (4 attempts total) with exponential backoff, then fall back to a default ??? example "Solution" @@ -98,7 +98,7 @@ Live-coding rounds ask for small, precise pipelines. Write each one yourself (ed ); ``` - 1s, 2s, 4s delays; `retry` before `catchError` or there is nothing left to retry. + `count: 3` means three retries after the initial attempt, four requests worst case, with 1s, 2s, 4s delays; `retry` before `catchError` or there is nothing left to retry. ## 7. Click counter with a reset button diff --git a/docs/interview-prep/scenarios.md b/docs/interview-prep/scenarios.md index c2628f9..0397eec 100644 --- a/docs/interview-prep/scenarios.md +++ b/docs/interview-prep/scenarios.md @@ -71,6 +71,7 @@ forkJoin({ this.route.paramMap.pipe( map((p) => p.get("id")!), switchMap((id) => this.api.getUser(id)), + takeUntilDestroyed(), ); ``` diff --git a/docs/learn/hot-observables.md b/docs/learn/hot-observables.md index 6d240b4..e4189a4 100644 --- a/docs/learn/hot-observables.md +++ b/docs/learn/hot-observables.md @@ -113,7 +113,8 @@ Remember, Cold Observables only start when you subscribe, and each subscription constructor() { console.log("UserService: Setting up shared user profile fetch."); this.userProfile$ = this.http.get("/api/user/profile").pipe( - shareReplay(1), // Cache the latest value and share across subscribers + // explicit config beats bare shareReplay(1); see the shareReplay page + shareReplay({ bufferSize: 1, refCount: true }), ); } diff --git a/docs/operators/combination/combineLatest.md b/docs/operators/combination/combineLatest.md index 28454bd..f8e60ac 100644 --- a/docs/operators/combination/combineLatest.md +++ b/docs/operators/combination/combineLatest.md @@ -18,13 +18,14 @@ After that initial combination, _any time_ any _one_ of the sources sends out a - **Signature:** `combineLatest([a$, b$, ...])` or the readable dictionary form `combineLatest({ a: a$, b: b$ })` - **Use when:** deriving state from several live inputs: filters + data, route params + settings - **Avoid when:** you need one snapshot of completing calls (`forkJoin`) or index-wise pairing (`zip`) - - **Top gotcha:** it emits nothing until **every** source has emitted at least once; one silent source blocks the whole combination (fix with `startWith`) + - **Top gotcha:** it emits nothing until **every** source has emitted at least once; a silent source that never completes blocks the whole combination forever (fix with `startWith`), while a source that completes without emitting makes it complete silently instead of hanging ## Key Characteristics 1. **Waits for Initial Values:** It won't emit anything until _all_ input Observables have emitted at least one value. 2. **Emits Latest Combination:** Once initialized, it emits an array containing the most recent value from each input Observable. 3. **Reacts to Any Input:** As soon as _any_ of the input Observables emits a new value, `combineLatest` emits a new array with the updated combination. +4. **Empty-Completion Behavior:** A source that completes without ever emitting makes `combineLatest` complete immediately with no output; the "hangs forever" intuition only applies to silent sources that never complete. ## Minimal Example diff --git a/docs/operators/combination/forkJoin.md b/docs/operators/combination/forkJoin.md index 059dbd8..2f49c49 100644 --- a/docs/operators/combination/forkJoin.md +++ b/docs/operators/combination/forkJoin.md @@ -25,6 +25,7 @@ Think of it like starting several independent tasks (e.g., downloading multiple 3. **Single Emission:** It emits only _one_ value (or an error). 4. **Array of Last Values:** The emitted value is an array containing the _last_ value from each input Observable. 5. **Error Behavior:** If _any_ of the input Observables error out, `forkJoin` immediately errors out as well. It will _not_ wait for the other Observables to complete and will _not_ emit the array of results. +6. **Empty-Completion Behavior:** If any input completes without ever emitting (like `EMPTY`), `forkJoin` completes immediately **with no emission**: there is no last value to report for that slot, so the whole join produces nothing. ## Minimal Example diff --git a/docs/operators/combination/startWith.md b/docs/operators/combination/startWith.md index adc0ef8..24767c4 100644 --- a/docs/operators/combination/startWith.md +++ b/docs/operators/combination/startWith.md @@ -47,7 +47,8 @@ type ViewState = @Component({ selector: "app-user-list", template: ` - @switch (state().status) { + @let s = state(); + @switch (s.status) { @case ("loading") {

Loading users...

} @@ -56,7 +57,7 @@ type ViewState = } @case ("loaded") {
    - @for (user of $any(state()).users; track user) { + @for (user of s.users; track user) {
  • {{ user }}
  • }
diff --git a/docs/operators/creation/empty-never.md b/docs/operators/creation/empty-never.md index 2dcacba..28ce4c4 100644 --- a/docs/operators/creation/empty-never.md +++ b/docs/operators/creation/empty-never.md @@ -60,97 +60,58 @@ NEVER.subscribe({ ## Code Snippet Demonstration ```typescript -import { Component, OnInit, OnDestroy } from "@angular/core"; -import { EMPTY, NEVER, Subscription } from "rxjs"; +import { Component, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { EMPTY, NEVER } from "rxjs"; @Component({ selector: "app-empty-never-demo", template: `

EMPTY vs NEVER Demo

Check the console log.

-

EMPTY Status: {{ emptyStatus }}

-

NEVER Status: {{ neverStatus }}

+

EMPTY Status: {{ emptyStatus() }}

+

NEVER Status: {{ neverStatus() }}

`, }) -export class EmptyNeverDemoComponent implements OnInit, OnDestroy { - emptyStatus = "Subscribing..."; - neverStatus = "Subscribing..."; - - private emptySub: Subscription | undefined; - private neverSub: Subscription | undefined; - - ngOnInit(): void { - console.log(`--- Subscribing to EMPTY ---`); - this.emptySub = EMPTY.subscribe({ - next: () => { - console.log("EMPTY: next (This will not be logged)"); - this.emptyStatus = "Got next (unexpected)"; - }, - error: (err) => { - console.error("EMPTY: error", err); - this.emptyStatus = `Error: ${err}`; - }, +export class EmptyNeverDemoComponent { + protected readonly emptyStatus = signal("Subscribing..."); + protected readonly neverStatus = signal("Subscribing..."); + + constructor() { + console.log("--- Subscribing to EMPTY ---"); + // EMPTY completes synchronously on subscribe; no teardown needed + EMPTY.subscribe({ + next: () => this.emptyStatus.set("Got next (unexpected)"), complete: () => { - // This is called immediately! - console.log( - `EMPTY: complete! (Called immediately) at ${new Date().toLocaleTimeString()}`, - ); - this.emptyStatus = "Completed immediately"; + // called immediately, before the subscribe() call even returns + console.log("EMPTY: complete! (Called immediately)"); + this.emptyStatus.set("Completed immediately"); }, }); - // The line below will likely log 'Completed immediately' because EMPTY completes synchronously - console.log( - `Current EMPTY status after sync subscribe: ${this.emptyStatus}`, - ); - - console.log(`\n--- Subscribing to NEVER ---`); - this.neverSub = NEVER.subscribe({ - next: () => { - console.log("NEVER: next (This will not be logged)"); - this.neverStatus = "Got next (unexpected)"; - }, - error: (err) => { - console.error("NEVER: error (This will not be logged)"); - this.neverStatus = `Error: ${err}`; - }, - complete: () => { - // This is never called! - console.log("NEVER: complete! (This will never be logged)"); - this.neverStatus = "Completed (unexpected)"; - }, + console.log(`EMPTY status right after subscribe: ${this.emptyStatus()}`); + + console.log("--- Subscribing to NEVER ---"); + // NEVER never terminates on its own; tie the subscription to the + // component's lifetime or it leaks + NEVER.pipe(takeUntilDestroyed()).subscribe({ + next: () => this.neverStatus.set("Got next (unexpected)"), + complete: () => + // NEVER itself never completes; this fires only because + // takeUntilDestroyed completes the stream on destroy + console.log("NEVER: complete (from takeUntilDestroyed on destroy)"), }); - // NEVER does nothing, so status remains 'Subscribing...' - console.log( - `Current NEVER status after sync subscribe: ${this.neverStatus}`, - ); + console.log(`NEVER status right after subscribe: ${this.neverStatus()}`); - // Set a timeout just to show NEVER doesn't complete on its own + // show that NEVER does not complete on its own setTimeout(() => { - if (this.neverStatus === "Subscribing...") { + if (this.neverStatus() === "Subscribing...") { console.log( - "\nAfter 2 seconds, NEVER still hasn't emitted or completed.", + "After 2 seconds, NEVER still has not emitted or completed.", ); - this.neverStatus = "Still running after 2s (as expected)"; + this.neverStatus.set("Still running after 2s (as expected)"); } }, 2000); } - - ngOnDestroy(): void { - console.log("\n--- Component Destroying ---"); - if (this.emptySub && !this.emptySub.closed) { - // This check is usually false as EMPTY completes immediately - console.log("Unsubscribing from EMPTY (already closed likely)."); - this.emptySub.unsubscribe(); - } else { - console.log("EMPTY subscription was already closed."); - } - - if (this.neverSub && !this.neverSub.closed) { - // This is important for NEVER if the component is destroyed - console.log("Unsubscribing from NEVER."); - this.neverSub.unsubscribe(); - } - } } ``` diff --git a/docs/operators/error-handling/retry.md b/docs/operators/error-handling/retry.md index 5bac9c6..38871a7 100644 --- a/docs/operators/error-handling/retry.md +++ b/docs/operators/error-handling/retry.md @@ -230,6 +230,13 @@ export class UserProfileComponent { **Placing `retry` after `catchError`.** `catchError` swallows the error first, so `retry` never sees one. The resilient order is `retry(...)` then `catchError(...)`. +## Important Considerations for `retry` + +- **Don't Retry Everything:** Only retry operations that might succeed on a subsequent attempt (transient network/server errors). Don't retry things like "404 Not Found" or "400 Bad Request" errors, as retrying won't fix the underlying problem. The `delay` function in the `retry` configuration object is ideal for this conditional retrying logic. +- **Avoid Infinite Retries:** Always specify a reasonable retry count. +- **Side Effects:** Be very careful retrying Observables that have side effects (e.g., POST or PUT requests). Retrying might cause the action (like creating a resource) to happen multiple times. It's generally safer for idempotent requests like GET or DELETE. +- **Delay:** Retrying immediately might not be helpful if the server needs a moment to recover. Adding a delay, possibly increasing with each retry (exponential backoff), is often recommended. The `retry({ delay: ... })` configuration makes this much easier than the older `retryWhen` operator. + ## Interview Q&A ??? question "What does retry actually do under the hood?" @@ -249,11 +256,3 @@ export class UserProfileComponent { - [catchError](catchError.md) for the fallback once retries are exhausted - [retryWhen](retryWhen.md), the deprecated predecessor kept for interview awareness - [timer](../creation/timer.md), the usual delay source for backoff strategies - - `.subscribe()`: Updates the `userProfile` signal when data is successfully received. - -## Important Considerations for `retry` - -- **Don't Retry Everything:** Only retry operations that might succeed on a subsequent attempt (transient network/server errors). Don't retry things like "404 Not Found" or "400 Bad Request" errors, as retrying won't fix the underlying problem. The `delay` function in the `retry` configuration object is ideal for this conditional retrying logic. -- **Avoid Infinite Retries:** Always specify a reasonable retry count. -- **Side Effects:** Be very careful retrying Observables that have side effects (e.g., POST or PUT requests). Retrying might cause the action (like creating a resource) to happen multiple times. It's generally safer for idempotent requests like GET or DELETE. -- **Delay:** Retrying immediately might not be helpful if the server needs a moment to recover. Adding a delay, possibly increasing with each retry (exponential backoff), is often recommended. The `retry({ delay: ... })` configuration makes this much easier than the older `retryWhen` operator. diff --git a/docs/operators/filtering/auditTime.md b/docs/operators/filtering/auditTime.md index 0443628..a97af9b 100644 --- a/docs/operators/filtering/auditTime.md +++ b/docs/operators/filtering/auditTime.md @@ -95,7 +95,7 @@ export class DragTrackerComponent { ??? question "auditTime vs throttleTime: what is the real difference?" - Default `throttleTime` is leading-edge: it emits the first value of a burst, then drops the rest of the window. `auditTime` is trailing-edge: it stays silent for the window and then emits the latest value. `throttleTime(d, undefined, { leading: false, trailing: true })` behaves very similarly to `auditTime(d)`; the practical rule is throttle for instant first response, audit for freshest periodic state. + Default `throttleTime` is leading-edge: it emits the first value of a burst, then drops the rest of the window. `auditTime` is trailing-edge: it stays silent for the window and then emits the latest value. `throttleTime(d, undefined, { leading: false, trailing: true })` is similar in intent but **not equivalent**: throttle anchors fixed windows to emissions during a window, audit starts a fresh window on each emission after idle. On `interval(100).pipe(take(10))`, `auditTime(350)` emits 3, 7, 9 while trailing `throttleTime(350)` emits 3, 6, 9. The practical rule stands: throttle for instant first response, audit for freshest periodic state. ??? question "auditTime vs sampleTime?" diff --git a/docs/operators/filtering/debounceTime.md b/docs/operators/filtering/debounceTime.md index 8c68903..37f2ba2 100644 --- a/docs/operators/filtering/debounceTime.md +++ b/docs/operators/filtering/debounceTime.md @@ -140,7 +140,7 @@ Using `debounceTime` here dramatically improves user experience and reduces unne ??? question "What happens to the values emitted during the quiet-period timer?" - Each new value replaces the pending one and restarts the timer; the replaced values are discarded permanently. `debounceTime` is lossy by design, which is exactly why it fits "only the final intent matters" inputs. + Each new value replaces the pending one and restarts the timer; the replaced values are discarded permanently. One exception at the end of life: if the source **completes** while a value is pending, that value is flushed before the completion (an error, by contrast, discards it). `debounceTime` is lossy by design, which is exactly why it fits "only the final intent matters" inputs. ## Related diff --git a/docs/operators/filtering/distinctUntilChanged.md b/docs/operators/filtering/distinctUntilChanged.md index be33e9e..d633cfa 100644 --- a/docs/operators/filtering/distinctUntilChanged.md +++ b/docs/operators/filtering/distinctUntilChanged.md @@ -107,7 +107,7 @@ export class DistinctSearchComponent { ## Common Mistakes -**Comparing objects by reference.** `{ id: 1 } !== { id: 1 }`, so an object stream is never filtered by the default check. Provide a comparator, `distinctUntilChanged((a, b) => a.id === b.id)`, or use `distinctUntilKeyChanged("id")`. +**Comparing objects by reference.** `{ id: 1 } !== { id: 1 }`, so an object stream is never filtered by the default check. Provide a comparator, `distinctUntilChanged((a, b) => a.id === b.id)`, or use `distinctUntilKeyChanged("id")`. Mind the comparator contract: return `true` when the values count as **equal**, meaning the emission is skipped. Inverting it silently passes every duplicate and drops every change. **Expecting global de-duplication.** Only consecutive repeats are dropped; `1, 2, 1` passes through untouched. Removing all duplicates ever seen is `distinct`, which also means unbounded memory for the seen-set. diff --git a/docs/operators/transformation/buffer.md b/docs/operators/transformation/buffer.md index 2f85ad5..3b71dd0 100644 --- a/docs/operators/transformation/buffer.md +++ b/docs/operators/transformation/buffer.md @@ -17,7 +17,7 @@ Unlike the timing operators (`debounceTime`, `sampleTime`), nothing is dropped: !!! abstract "At a glance" - - **Signatures:** `buffer(notifier$)`, `bufferTime(span, creationInterval?, maxSize?)`, `bufferCount(size, startEvery?)` + - **Signatures:** `buffer(notifier$)`, `bufferTime(span, creationInterval?, maxBufferSize?)`, `bufferCount(size, startEvery?)` - **Use when:** values should be processed in batches rather than one by one: bulk API writes, analytics batching, grouping rapid events - **Avoid when:** only the latest value matters (`sampleTime`, `auditTime`) or values need individual handling - **Top gotcha:** `bufferTime` emits **empty arrays** on quiet windows; filter them out before acting on each batch diff --git a/docs/subjects/behaviorSubject.md b/docs/subjects/behaviorSubject.md index ec8efb2..9e030c5 100644 --- a/docs/subjects/behaviorSubject.md +++ b/docs/subjects/behaviorSubject.md @@ -36,7 +36,7 @@ Like a regular `Subject`, it still multicasts – pushing values to all current - The latest value of filters applied to a list. - Configuration settings. 2. **Providing Initial Data:** Components often need to know the _current_ state as soon as they initialize to render correctly. `BehaviorSubject` guarantees they get a value immediately upon subscription. -3. **Synchronous Access (Use Sparingly):** `BehaviorSubject` has a `getValue()` method that allows you to synchronously get its current value _without_ subscribing. While useful occasionally, relying heavily on this can be an anti-pattern compared to reactive subscriptions. Signals often provide a better way to get current values reactively. +3. **Synchronous Access (Use Sparingly):** `BehaviorSubject` has a `getValue()` method that allows you to synchronously get its current value _without_ subscribing. While useful occasionally, relying heavily on this can be an anti-pattern compared to reactive subscriptions. Signals often provide a better way to get current values reactively. Terminal-state behavior is worth knowing: after `complete()`, `getValue()` still returns the last value; after `error()`, it **rethrows that error**; after `unsubscribe()`, it throws `ObjectUnsubscribedError`. ## Minimal Example diff --git a/docs/subjects/share.md b/docs/subjects/share.md index 4010ba6..1b7b002 100644 --- a/docs/subjects/share.md +++ b/docs/subjects/share.md @@ -13,7 +13,7 @@ However, `share` behaves like it's using a plain `Subject` internally for multic 1. **Shares a Single Subscription:** It subscribes to the source Observable only when the _first_ subscriber arrives. 2. **Multicasts Live Values:** It pushes values from the source to all _currently active_ subscribers. 3. **No Replay:** If a subscriber joins _after_ the source has already emitted some values, that new subscriber **will not** receive those past values. They will only get emissions that happen _after_ they subscribed. -4. **Reference Counting:** It uses reference counting (`refCount` is implicitly true). The subscription to the source is active only as long as there's at least one downstream subscriber. When the last subscriber leaves, `share` unsubscribes from the source. If a new subscriber arrives later, it will re-subscribe to the source, potentially restarting it. +4. **Reference Counting & Resets:** `share()` tracks its subscribers and unsubscribes from the source when the count drops to zero (`resetOnRefCountZero: true`, the default). It also resets when the source **completes or errors** (`resetOnComplete: true`, `resetOnError: true`), so a subscriber arriving after completion re-executes the source instead of joining a finished one. If a new subscriber arrives later, it re-subscribes to the source, potentially restarting it. !!! abstract "At a glance" From 3d52027156ce8f863fa594e26d230d498a8cb27d Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:04:10 +0530 Subject: [PATCH 4/8] State the RxJS-only scope on the landing page and README --- README.md | 2 ++ docs/index.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index d2fa9f7..a35e1dc 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ An open-source interview preparation site for Angular developers. It covers RxJS - Engineers who want a structured refresher on reactive programming in Angular - Interviewers sourcing questions, scenarios, and coding exercises +The scope is deliberate: RxJS in depth, through an Angular lens. This is not a general Angular interview guide. Dependency injection, change detection mechanics, and routing fundamentals are out of scope except where an RxJS answer depends on them. + ## Content principles - **Evergreen.** No version numbers in content. Examples follow the current [angular.dev](https://angular.dev/) and [rxjs.dev](https://rxjs.dev/) documentation: standalone components, signals, `inject()`, built-in control flow, and `takeUntilDestroyed`. diff --git a/docs/index.md b/docs/index.md index 91c83ee..4c07460 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,8 @@ description: "An open-source RxJS interview guide for Angular developers: fundam Master RxJS for Angular interviews: clear explanations, realistic Angular examples, and side-by-side comparisons, organized so you can learn the concepts, look up operators fast, and revise before the interview. +This guide covers **RxJS in depth, through an Angular lens**. It is deliberately not a general Angular interview guide: no dependency injection, change detection, or routing fundamentals here, only the reactive parts, and the Angular topics that an RxJS answer depends on. + ## Where do you want to start?
From 35b699565f43b13cb112ed9068cb183c89a95166 Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:08:51 +0530 Subject: [PATCH 5/8] Add marble testing page with executed TestScheduler examples --- docs/learn/index.md | 1 + docs/learn/marble-testing.md | 171 +++++++++++++++++++++++++++++++++++ docs/learn/schedulers.md | 6 +- mkdocs.yml | 1 + 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 docs/learn/marble-testing.md diff --git a/docs/learn/index.md b/docs/learn/index.md index 01b7590..d7f97c7 100644 --- a/docs/learn/index.md +++ b/docs/learn/index.md @@ -17,6 +17,7 @@ Work through these topics in order. Each one builds on the previous, and togethe 7. **[Unicast vs Multicast](unicast-vs-multicast.md)**: the precise vocabulary behind cold and hot, and how sharing actually works. 8. **[Promise vs Observable](promise-vs-observable.md)**: single value vs stream, eager vs lazy, and why cancellation matters. A very common interview opener. 9. **[Schedulers, observeOn & subscribeOn](schedulers.md)**: who decides when stream code runs, and the one place it really matters: testing with virtual time. An advanced topic; safe to save for last. +10. **[Marble Testing](marble-testing.md)**: `TestScheduler`, virtual time, and marble diagrams. Prove a debounced search pipeline, including its cancellation, in a test that runs in milliseconds. ## After the fundamentals diff --git a/docs/learn/marble-testing.md b/docs/learn/marble-testing.md new file mode 100644 index 0000000..85b6369 --- /dev/null +++ b/docs/learn/marble-testing.md @@ -0,0 +1,171 @@ +--- +description: "Test debounce, retry, and switchMap pipelines in milliseconds with TestScheduler, virtual time, and marble diagrams." +tags: + - Fundamentals +--- + +# Marble Testing with TestScheduler + +The [schedulers page](schedulers.md) calls virtual time the single most practical scheduler skill. This page is that skill. `TestScheduler` replaces the real clock with a virtual one, so a pipeline full of `debounceTime(300)` and `timer` calls runs **deterministically and instantly**: no waiting, no flaky sleeps, and exact assertions on _when_ every value fires. + +The test language is the **marble diagram**: a string where each character is a moment in virtual time. + +!!! abstract "At a glance" + + - **Setup:** `new TestScheduler(assertDeepEqual)` and everything inside `testScheduler.run(helpers => { ... })` + - **Helpers:** `cold()` and `hot()` build sources from marbles; `expectObservable().toBe()` asserts output; `expectSubscriptions()` proves (un)subscription timing + - **Use when:** any pipeline whose behavior depends on time or ordering: debounce, throttle, retry backoff, switchMap cancellation + - **Top gotcha:** each value character **consumes one virtual millisecond**, so frame arithmetic is off by one until you account for it + +## The Marble Syntax + +| Symbol | Meaning | +| ---------------- | ------------------------------------------------------------------------------------------------- | +| `-` | One frame of virtual time (1ms inside `run()`) passes, nothing happens | +| `a`, `b`, `0`... | The source emits the value bound to that character (also consumes 1ms) | +| `\|` | The stream completes | +| `#` | The stream errors | +| `()` | Grouping: everything inside happens in the same frame, e.g. `(a\|)` = emit then complete together | +| `^` | In `hot()` marbles: the moment the test subscribes. In subscription marbles: subscription starts | +| `!` | In subscription marbles: unsubscription | +| `300ms` | Time-progression shorthand: advances virtual time without spelling out 300 dashes | +| whitespace | Ignored entirely; use it to align related marbles | + +## Setup and a Minimal Test + +```typescript +import { TestScheduler } from "rxjs/testing"; +import { map } from "rxjs"; + +describe("map pipeline", () => { + let testScheduler: TestScheduler; + + beforeEach(() => { + // the callback is how marble expectations get asserted; + // wire it to your test framework's deep-equal + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it("multiplies each value by ten", () => { + testScheduler.run(({ cold, expectObservable }) => { + const source$ = cold("-a-b-|", { a: 1, b: 2 }); + + expectObservable(source$.pipe(map((x) => x * 10))).toBe("-a-b-|", { + a: 10, + b: 20, + }); + }); + }); +}); +``` + +Inside `run()`, nothing is asynchronous: when the callback returns, the scheduler flushes all virtual time synchronously and the assertion callback fires for every expectation. Errors are marbles too: `cold("-a-#", { a: 1 }, new Error("boom"))` builds a stream that emits and then errors, and `.toBe("-a-#", values, error)` asserts it. + +## The Frame Arithmetic + +The one rule that prevents most confusion: **a value character consumes one virtual millisecond**. In `"a 250ms b"`, `a` emits at frame 0 and occupies 1ms, the shorthand advances 250ms, so `b` emits at frame 251, not 250. + +Worked example with `debounceTime(300)` (this test executes green, like every example on this page): + +```typescript +it("debounces to the settled value", () => { + testScheduler.run(({ cold, expectObservable }) => { + // a at 0; b at 251 (a consumed 1ms); source completes at 552 + const input$ = cold("a 250ms b 300ms |", { a: "ng", b: "ngrx" }); + + // a's timer is cancelled by b; b settles at 251 + 300 = 551; + // completion passes through at 552, right after the emission + expectObservable(input$.pipe(debounceTime(300))).toBe("551ms b|", { + b: "ngrx", + }); + }); +}); +``` + +When a duration appears in several places, the `time` helper converts a marble into its number: `const t = time("--|")` is 2, so operators and expectations stay in sync if the timing changes. + +## The Real Test: Debounced Search with Cancellation + +The site's most-taught pipeline is `debounceTime` + `switchMap`. The claim that makes it interview gold is that `switchMap` **cancels the stale in-flight request**. A marble test can prove that claim, not just assert outputs, using `expectSubscriptions`: + +```typescript +it("cancels the stale request when a new term settles", () => { + testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => { + // "ng" at 0, "ngrx" at 401, completion at 1401 + const input$ = cold("a 400ms b 999ms |", { a: "ng", b: "ngrx" }); + + // each fake request takes 500ms + const reqA = cold("500ms r|", { r: "ng results" }); + const reqB = cold("500ms r|", { r: "ngrx results" }); + + const result$ = input$.pipe( + debounceTime(300), + switchMap((term) => (term === "ng" ? reqA : reqB)), + ); + + // only the second result arrives (1201 = 701 + 500); + // the stream completes with the source at 1401 + expectObservable(result$).toBe("1201ms b 199ms |", { b: "ngrx results" }); + + // the proof: reqA was subscribed when "ng" settled (300) and + // UNSUBSCRIBED the moment "ngrx" settled (701), mid-flight + expectSubscriptions(reqA.subscriptions).toBe("300ms ^ 400ms !"); + + // reqB ran to completion (701 + 1 + 500 = 1202) + expectSubscriptions(reqB.subscriptions).toBe("701ms ^ 500ms !"); + }); +}); +``` + +Walk the timeline: "ng" settles at 300 and its request starts. "ngrx" arrives at 401, settles at 701. `switchMap` unsubscribes `reqA` at 701, 100ms before its response would have landed, and subscribes `reqB`. That unsubscription marble is the cancellation guarantee made visible, and it is exactly what distinguishes this pipeline from plain value debouncing. + +## Hot Sources and the Subscription Point + +`hot()` marbles model streams that were already running before the test subscribed. The `^` marks the subscription moment; anything to its left is missed, which is the whole cold/hot distinction in one assertion: + +```typescript +it("misses values emitted before subscription", () => { + testScheduler.run(({ hot, expectObservable }) => { + const source$ = hot("-a-^-b-|", { a: 1, b: 2 }); + + // 'a' happened before we subscribed; only 'b' is observed + expectObservable(source$).toBe("--b-|", { b: 2 }); + }); +}); +``` + +## Common Mistakes + +**Off-by-one frame math.** Every value character consumes a millisecond, so `"a 250ms b"` puts `b` at 251. When an expectation fails by exactly one frame, this is almost always why. Let the failure output guide you: it prints actual versus expected frames. + +**Testing time-based logic with real timers instead.** A `setTimeout`-and-wait test for a 300ms debounce is slow, and flaky under load. The virtual clock exists precisely so the same logic runs in microseconds with exact timing assertions. + +**Doing real async inside `run()`.** Virtual time only controls scheduler-based time (`debounceTime`, `timer`, `delay`, `interval`). A real `Promise` or actual HTTP call resolves outside the virtual clock and breaks determinism; model collaborators as `cold()` marbles instead, as the search test does with its fake requests. + +**Asserting only values, never subscriptions.** Output-only tests cannot distinguish `switchMap` from `mergeMap` when responses happen to arrive in order. `expectSubscriptions` pins the cancellation behavior itself. + +## Interview Q&A + +??? question "Why do marble tests run instantly even with a 30-second debounce?" + + Time-based operators never consult the wall clock directly; they ask their scheduler. `TestScheduler` substitutes a virtual clock and, inside `run()`, flushes all scheduled work synchronously, advancing virtual time frame by frame. A `debounceTime(30000)` is just "emit at virtual frame 30000", which is reached immediately. + +??? question "What is the difference between cold() and hot() in a test?" + + `cold()` starts its marbles from the moment each subscriber arrives, modeling per-subscriber producers like HTTP. `hot()` runs on the test's global timeline and the `^` marks where the subscriber joins, modeling shared live sources; values left of `^` are missed. Choosing the wrong one silently changes what the test claims. + +??? question "How would you prove switchMap cancels a stale request?" + + Model the requests as separate `cold()` observables and assert `expectSubscriptions(reqA.subscriptions)` with a `!` before the response frame. The unsubscription marble is the cancellation; asserting output alone cannot prove it. + +??? question "What are the limits of marble testing?" + + It controls scheduler time only: real Promises, real network, `requestAnimationFrame` timing, and anything outside RxJS's schedulers are invisible to the virtual clock. Those seams get modeled as marble stand-ins, or the pipeline is restructured so the time-dependent core is testable in isolation. + +## Related + +- [Schedulers, observeOn & subscribeOn](schedulers.md), the concept that makes virtual time possible +- [debounceTime](../operators/filtering/debounceTime.md) and [switchMap](../operators/transformation/switchMap.md), the pipeline tested above +- [Cold Observables](cold-observables.md) and [Hot Observables](hot-observables.md), the distinction `cold()`/`hot()` encode diff --git a/docs/learn/schedulers.md b/docs/learn/schedulers.md index c6c7939..293f226 100644 --- a/docs/learn/schedulers.md +++ b/docs/learn/schedulers.md @@ -91,7 +91,7 @@ Day to day, Angular developers almost never pass schedulers explicitly: The cases where schedulers earn their keep: -1. **Testing with virtual time.** RxJS's `TestScheduler` swaps the clock, so a `debounceTime(30000)` test finishes in milliseconds. This is the single most practical scheduler skill. +1. **Testing with virtual time.** RxJS's `TestScheduler` swaps the clock, so a `debounceTime(30000)` test finishes in milliseconds. This is the single most practical scheduler skill, and it has its own page: [Marble Testing](marble-testing.md). 2. **Animation-adjacent streams**, via `animationFrameScheduler`. 3. **Breaking up synchronous floods.** A source that emits thousands of values synchronously can starve the main thread; `observeOn(asyncScheduler)` yields between deliveries. 4. **Deliberate async boundaries.** Making a synchronous stream deliver asynchronously to keep API timing consistent (the same problem `asapScheduler` solves for Promise-like ordering). @@ -102,7 +102,7 @@ The cases where schedulers earn their keep: **Reaching for schedulers to fix change detection.** If the view is not updating, the answer is signals, the async pipe, or fixing a broken subscription, not `observeOn`. Scheduler tweaks that "fix" rendering usually paper over a real bug. -**Testing debounce logic with real timers.** Tests that `setTimeout`-wait for debounce windows are slow and flaky. Virtual time via `TestScheduler` runs the same logic deterministically and instantly. +**Testing debounce logic with real timers.** Tests that `setTimeout`-wait for debounce windows are slow and flaky. Virtual time via `TestScheduler` runs the same logic deterministically and instantly; see [Marble Testing](marble-testing.md). ## Interview Q&A @@ -116,7 +116,7 @@ The cases where schedulers earn their keep: ??? question "Why do schedulers matter for testing?" - Because operators ask the scheduler for the time instead of the real clock, tests can substitute virtual time. With `TestScheduler`, a pipeline full of `debounceTime` and `timer` calls runs deterministically in milliseconds, with marble diagrams describing expected timing. + Because operators ask the scheduler for the time instead of the real clock, tests can substitute virtual time. With `TestScheduler`, a pipeline full of `debounceTime` and `timer` calls runs deterministically in milliseconds, with marble diagrams describing expected timing. Worked examples live on the [Marble Testing](marble-testing.md) page. ??? question "Which scheduler does delay(1000) use if you pass nothing?" diff --git a/mkdocs.yml b/mkdocs.yml index 36aba4a..dd99c29 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ nav: - "Unicast vs Multicast": learn/unicast-vs-multicast.md - "Promise vs Observable": learn/promise-vs-observable.md - "Schedulers, observeOn & subscribeOn": learn/schedulers.md + - "Marble Testing": learn/marble-testing.md - Operators: - operators/index.md - "Creation": From 378d335cd5a88587074120f7a0139ce3e950803f Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:12:45 +0530 Subject: [PATCH 6/8] Add the RxJS-vs-Resource boundary and complete rxjs-interop coverage --- docs/angular/caching.md | 2 +- docs/angular/http-patterns.md | 2 +- docs/angular/index.md | 1 + docs/angular/reactive-forms.md | 2 + docs/angular/rxjs-vs-resource.md | 102 +++++++++++++++++++++++++++++++ docs/angular/signals-interop.md | 11 ++++ mkdocs.yml | 1 + 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 docs/angular/rxjs-vs-resource.md diff --git a/docs/angular/caching.md b/docs/angular/caching.md index 400d1e5..7f81c9a 100644 --- a/docs/angular/caching.md +++ b/docs/angular/caching.md @@ -53,7 +53,7 @@ export class UserListService { } ``` -Mutations call `invalidate()` after a successful write; every consumer, template or effect, updates automatically. This trigger + `switchMap` + `shareReplay` trio is the single most useful caching pattern to know by heart. +Mutations call `invalidate()` after a successful write; every consumer, template or effect, updates automatically. This trigger + `switchMap` + `shareReplay` trio is the single most useful caching pattern to know by heart. (For a single consumer, Angular's Resource API covers this exact case with `reload()`; the trigger design earns its keep when the cache is **shared**, see [RxJS or the Resource API?](rxjs-vs-resource.md)) ## Level 3: Per-Key Caches diff --git a/docs/angular/http-patterns.md b/docs/angular/http-patterns.md index 6bbc90b..8985e5a 100644 --- a/docs/angular/http-patterns.md +++ b/docs/angular/http-patterns.md @@ -54,7 +54,7 @@ export class ProductsComponent { } ``` -One stream, no boolean flag drift, exhaustive template handling. The pieces are explained on [startWith](../operators/combination/startWith.md) and [catchError](../operators/error-handling/catchError.md). +One stream, no boolean flag drift, exhaustive template handling. The pieces are explained on [startWith](../operators/combination/startWith.md) and [catchError](../operators/error-handling/catchError.md). For the narrow "fetch on param change with loading/error state" case, Angular's `httpResource` now provides this state machine out of the box; when that is enough, and when it is not, is covered on [RxJS or the Resource API?](rxjs-vs-resource.md) ## Pattern 2: Sequential (Dependent) Requests diff --git a/docs/angular/index.md b/docs/angular/index.md index 8a196ae..50d89ee 100644 --- a/docs/angular/index.md +++ b/docs/angular/index.md @@ -12,6 +12,7 @@ RxJS is woven into Angular: `HttpClient` returns Observables, reactive forms exp - **[Async Pipe](async-pipe.md)**: let the template subscribe and unsubscribe for you, and avoid the most common source of memory leaks. - **[Signals & RxJS Interop](signals-interop.md)**: `toSignal`, `toObservable`, and deciding when signals replace RxJS. +- **[RxJS or the Resource API?](rxjs-vs-resource.md)**: when `resource`/`httpResource` are enough, when streams win, and `rxResource` as the bridge. - **[HttpClient Patterns](http-patterns.md)**: loading/error/success state, sequential and parallel requests, cancellation, and resilient pipelines. - **[Reactive Forms](reactive-forms.md)**: `valueChanges` pipelines for debounced search, autosave, and dependent fields. - **[Router](router.md)**: `paramMap` + `switchMap`, router events, and the component-reuse gotcha. diff --git a/docs/angular/reactive-forms.md b/docs/angular/reactive-forms.md index b970631..3c9906e 100644 --- a/docs/angular/reactive-forms.md +++ b/docs/angular/reactive-forms.md @@ -19,6 +19,8 @@ Reactive forms are stream factories: every control exposes `valueChanges` and `s Programmatic model updates (`setValue`, `patchValue`, `FormArray` mutations) update form state and emit through `valueChanges`/`statusChanges`, but they do **not** schedule change detection under Angular's default zoneless mode. Templates that display form-derived state must consume it through a notification source: pipe the form streams into signals with `toSignal`, or bind with the async pipe, as the patterns on this page do. +One positioning note: Angular also ships **Signal Forms**, a signals-native forms system with its own field-level debouncing and async validation. It is a forms topic beyond this guide's RxJS scope; this page covers the `valueChanges`/`statusChanges` streams of the classic reactive forms API, which remain the interview staple. + ## Pattern 1: Debounced Search The canonical pipeline, covered in depth on [debounceTime](../operators/filtering/debounceTime.md) and [switchMap](../operators/transformation/switchMap.md): diff --git a/docs/angular/rxjs-vs-resource.md b/docs/angular/rxjs-vs-resource.md new file mode 100644 index 0000000..a769208 --- /dev/null +++ b/docs/angular/rxjs-vs-resource.md @@ -0,0 +1,102 @@ +--- +description: "When Angular's resource, httpResource, and rxResource are enough, and when a hand-built RxJS pipeline is still the right answer." +tags: + - Angular + - Comparisons +--- + +# RxJS or the Resource API? + +Angular ships a signals-native answer for async data: `resource`, its HTTP wrapper `httpResource`, and `rxResource`, which lives in `@angular/core/rxjs-interop`, the same package as `toSignal` and `takeUntilDestroyed`. Interviewers now follow any hand-built HTTP pipeline with the obvious question: **"why not just use `httpResource`?"** This page is the answer, and the decision rule behind it. + +!!! abstract "At a glance" + + - **Resources win** for the narrow, common case: re-fetch when a parameter changes, expose `value()`, `isLoading()`, `error()`, and `status()` as signals + - **RxJS wins** the moment anything is stream-shaped: debouncing, retry with backoff, cancellation you control, combining event sources, multicasting, or the stream itself as the value + - **`rxResource`** is the bridge: an Observable-returning loader with resource semantics for the consumer + - **Top gotcha:** resources solve *data fetching*; they do not replace RxJS's *event and time* toolbox + +## What a resource gives you + +A resource is a reactive fetch bound to signal inputs. When the `params` computation changes, the loader re-runs and the previous request is aborted: + +```typescript +import { Component, inject, input } from "@angular/core"; +import { httpResource } from "@angular/common/http"; + +@Component({ + selector: "app-user-detail", + template: ` + @if (user.isLoading()) { +

Loading...

+ } @else if (user.error()) { +

Could not load user.

+ } @else if (user.hasValue()) { +

{{ user.value().name }}

+ } + `, +}) +export class UserDetailComponent { + readonly userId = input.required(); + + // re-fetches whenever userId changes; previous request is aborted + protected readonly user = httpResource<{ name: string }>( + () => `/api/users/${this.userId()}`, + ); +} +``` + +Compare that with the equivalent stream on the [HttpClient patterns](http-patterns.md) page: `toObservable` + `switchMap` + a discriminated-union state + `toSignal`. The resource version is shorter, and the loading/error state comes built in (`status()` reports `idle`, `loading`, `reloading`, `resolved`, `local`, or `error`). It also has `reload()` for the refresh-trigger case that [Caching](caching.md) Level 2 builds by hand with a `Subject` + `switchMap`, and it supports server-side rendering caching through a stable `id`. + +**For "fetch this when that changes, and show me loading and error state", the resource is enough.** Reaching for RxJS there is no longer the strongest answer; knowing that is part of the interview. + +## Where RxJS stays the right tool + +Everything stream-shaped sits outside the resource model: + +| Need | Why a resource cannot | RxJS tool | +| ---------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Debounce user input before fetching | `params` reacts to every change immediately | [`debounceTime`](../operators/filtering/debounceTime.md) | +| Retry with backoff, gated by error type | Loader failure goes straight to `error()` | [`retry({ count, delay })`](../operators/error-handling/retry.md) | +| Queue or drop bursts (saves, submits) | One loader per params change, latest wins, always | [`concatMap`](../operators/transformation/concatMap.md) / [`exhaustMap`](../operators/transformation/exhaustMap.md) | +| Combine several live sources | Resources track one params computation | [`combineLatest`](../operators/combination/combineLatest.md), [`merge`](../operators/combination/merge.md) | +| Share one execution among many consumers | Each resource owns its own loader | [`shareReplay`](../subjects/shareReplay.md) | +| Continuous streams (WebSockets, events) | The loader resolves once per request | Subjects, [`fromEvent`](../operators/creation/fromEvent.md), `resource`'s `stream` option for simple cases | + +The interview framing: a resource replaces the **last step** of many pipelines (fetch + expose state), never the **middle** (time, ordering, combination, sharing). The site's typeahead pipeline survives intact: debounce, `distinctUntilChanged`, and cancellation semantics are stream concerns. + +## rxResource, the bridge + +When the loader is naturally an Observable (an existing service method, a stream with retry logic baked in) but the consumer wants resource semantics: + +```typescript +import { rxResource } from "@angular/core/rxjs-interop"; + +protected readonly user = rxResource({ + params: () => ({ id: this.userId() }), + stream: ({ params }) => + this.api.getUser(params.id).pipe(retry({ count: 2, delay: 1000 })), +}); +``` + +The pipeline keeps its RxJS powers; the template gets `value()`, `isLoading()`, and `error()`. This is often the honest best-of-both answer. + +## Interview Q&A + +??? question "Why not just use httpResource for the typeahead search?" + + Because typeahead is mostly not a fetching problem. The pipeline needs debouncing (a resource's `params` reacts to every keystroke instantly) and deliberate cancellation of the settled-but-stale request. A resource does abort on params changes, but it cannot debounce, dedupe, or throttle them. The strong answer: debounce and shape the stream with RxJS, then hand the final fetch to `rxResource`, or keep `switchMap` + `toSignal`. + +??? question "When would you actively choose a resource over a stream?" + + Parameter-driven reads with standard loading/error UI: detail pages keyed by a route param or input signal, dashboards re-fetching on filter changes. The resource's abort-on-change covers the stale-response problem, `reload()` covers refresh, and there is less code to review. + +??? question "Does the Resource API make shareReplay-style caching obsolete?" + + No. A resource is owned by its consumer; two components creating resources for the same URL fetch twice. Sharing one execution across consumers is still multicasting: a service exposing a `shareReplay` stream, or a service-owned resource that everyone reads. + +## Related + +- [HttpClient Patterns](http-patterns.md), the stream versions of these flows +- [Caching](caching.md), where `reload()` maps to the refresh-trigger design +- [Signals & RxJS Interop](signals-interop.md) for the rest of the interop package diff --git a/docs/angular/signals-interop.md b/docs/angular/signals-interop.md index 537dd17..46f77bb 100644 --- a/docs/angular/signals-interop.md +++ b/docs/angular/signals-interop.md @@ -113,6 +113,16 @@ export class ResultsComponent { **Coalescing caveat:** `toObservable` observes the signal with an effect, so several synchronous `set()` calls produce **one** emission with the final value. It is a state bridge, not an event log; if every event matters, keep a `Subject` as the source of truth. +**"Why not `debounced()`?"** Angular ships a (currently experimental) `debounced()` helper that debounces a signal's value and exposes settling state as a resource, and Signal Forms has its own field-level debouncing. Neither replaces this pipeline, for one precise reason: they debounce the **value**, while `switchMap` also **cancels the in-flight request** the previous value triggered. Debouncing alone cannot stop a stale response from landing after a fresh one; cancellation is exactly what an interviewer is probing with this example. Full decision guide: [RxJS or the Resource API?](rxjs-vs-resource.md) + +## The Rest of the Interop Package + +Three more exports round out `@angular/core/rxjs-interop`, and all are fair interview game precisely because they live in the RxJS interop layer: + +- **`outputFromObservable(stream$)`**: exposes a stream as a component output, so `(saved)="..."` event bindings work against an RxJS pipeline. The natural "how do you emit a debounced output?" answer: build the pipeline, wrap it once. Its inverse, **`outputToObservable(componentRef.instance.saved)`**, turns any output back into a stream for composition. +- **`pendingUntilEvent()`**: an operator that keeps a server-rendered app "unstable" until the observable emits, completes, errors, or is unsubscribed, so serialization waits for the data. It is the RxJS-shaped half of the SSR-stability story and a strong senior mention. +- **`rxResource()`**: the Observable-loader variant of the Resource API, covered on [RxJS or the Resource API?](rxjs-vs-resource.md) + ## Common Mistakes **Calling `toSignal` outside an injection context.** In a method or callback it throws (or, with a manually passed injector, silently creates long-lived subscriptions per call). Create interop signals in field initializers or the constructor. @@ -137,6 +147,7 @@ export class ResultsComponent { ## Related +- [RxJS or the Resource API?](rxjs-vs-resource.md), the boundary this page's pipelines sit on - [Async Pipe](async-pipe.md), the template-side alternative to toSignal - [BehaviorSubject](../subjects/behaviorSubject.md), the RxJS state-holder signals often replace - [switchMap](../operators/transformation/switchMap.md) and [debounceTime](../operators/filtering/debounceTime.md), the stream tools worth bridging for diff --git a/mkdocs.yml b/mkdocs.yml index dd99c29..b34b7c5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,6 +88,7 @@ nav: - angular/index.md - "Async Pipe": angular/async-pipe.md - "Signals & RxJS Interop": angular/signals-interop.md + - "RxJS or the Resource API?": angular/rxjs-vs-resource.md - "HttpClient Patterns": angular/http-patterns.md - "Reactive Forms": angular/reactive-forms.md - "Router": angular/router.md From 9e89f2b8160be16395991d34727e767b3aa3458e Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:55:20 +0530 Subject: [PATCH 7/8] Normalize example components to constructor-based stream setup --- docs/learn/cold-observables.md | 2 +- docs/operators/combination/withLatestFrom.md | 24 +++++---- docs/operators/combination/zip.md | 12 ++--- docs/operators/error-handling/catchError.md | 18 ++++--- docs/operators/error-handling/retry.md | 22 +++----- docs/operators/error-handling/retryWhen.md | 16 ++---- docs/operators/filtering/find.md | 25 +++------ docs/operators/filtering/first.md | 35 ++++++------- docs/operators/filtering/last.md | 19 ++++--- docs/operators/utility/delay.md | 13 ++--- docs/operators/utility/finalize.md | 13 ++--- docs/operators/utility/tap.md | 16 ++---- docs/operators/utility/timeout.md | 15 ++---- docs/subjects/behaviorSubject.md | 30 ++++------- docs/subjects/replaySubject.md | 27 ++++------ docs/subjects/share.md | 45 ++++++----------- docs/subjects/shareReplay.md | 53 +++++++------------- docs/subjects/subject.md | 25 +++------ 18 files changed, 155 insertions(+), 255 deletions(-) diff --git a/docs/learn/cold-observables.md b/docs/learn/cold-observables.md index e18da93..1f822df 100644 --- a/docs/learn/cold-observables.md +++ b/docs/learn/cold-observables.md @@ -35,7 +35,7 @@ Crucially, **each time you subscribe** to a cold Observable, it starts its work ```typescript // In some component: - ngOnInit() { + constructor() { console.log("Subscribing first time to getItems..."); this.dataService.getItems().subscribe(data => { console.log("First subscription received data."); diff --git a/docs/operators/combination/withLatestFrom.md b/docs/operators/combination/withLatestFrom.md index 12eef59..c9cd7b6 100644 --- a/docs/operators/combination/withLatestFrom.md +++ b/docs/operators/combination/withLatestFrom.md @@ -56,7 +56,7 @@ saves$.next("save"); // ["save", "draft v2"] Let's see how this looks in an Angular component: ```typescript -import { Component, inject, DestroyRef, OnInit, signal } from "@angular/core"; +import { Component, signal } from "@angular/core"; import { ReactiveFormsModule, FormControl } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { @@ -93,18 +93,22 @@ import { } `, }) -export class ProductSearchComponent implements OnInit { - // --- Dependencies --- - private destroyRef = inject(DestroyRef); // For automatic unsubscription - +export class ProductSearchComponent { // --- Form Controls --- - searchTermControl = new FormControl("", { nonNullable: true }); - categoryFilterControl = new FormControl("all", { nonNullable: true }); + protected readonly searchTermControl = new FormControl("", { + nonNullable: true, + }); + protected readonly categoryFilterControl = new FormControl("all", { + nonNullable: true, + }); // --- Component State --- - searchResults = signal<{ term: string; category: string } | null>(null); + protected readonly searchResults = signal<{ + term: string; + category: string; + } | null>(null); - ngOnInit(): void { + constructor() { // --- Observables --- // Source: Search term, debounced const searchTerm$ = this.searchTermControl.valueChanges.pipe( @@ -121,7 +125,7 @@ export class ProductSearchComponent implements OnInit { searchTerm$ .pipe( withLatestFrom(categoryFilter$), // Combine search term with the LATEST category - takeUntilDestroyed(this.destroyRef), // Auto-unsubscribe when component is destroyed + takeUntilDestroyed(), // constructor is an injection context ) .subscribe(([term, category]) => { // Runs ONLY when searchTerm$ emits (after debounce), diff --git a/docs/operators/combination/zip.md b/docs/operators/combination/zip.md index ce80340..b9e9042 100644 --- a/docs/operators/combination/zip.md +++ b/docs/operators/combination/zip.md @@ -59,7 +59,7 @@ Another scenario (less common for APIs, more for UI events or other streams): Su Let's create a simple Angular component example using `zip`. We'll zip together values from two simple streams: one emitting letters ('A', 'B', 'C') quickly, and another emitting numbers (10, 20, 30, 40) more slowly. ```typescript -import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { Component, signal } from "@angular/core"; import { JsonPipe } from "@angular/common"; import { interval, map, of, take, zip } from "rxjs"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -88,12 +88,10 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; `, ], }) -export class ZipExampleComponent implements OnInit { - private destroyRef = inject(DestroyRef); +export class ZipExampleComponent { + protected readonly zippedResult = signal>([]); // Signal to hold the result - zippedResult = signal>([]); // Signal to hold the result - - ngOnInit() { + constructor() { // Source 1: Emits 'A', 'B', 'C' one after another immediately const letters$ = of("A", "B", "C"); @@ -107,7 +105,7 @@ export class ZipExampleComponent implements OnInit { zip(letters$, numbers$) .pipe( // zip emits arrays like [string, number] - takeUntilDestroyed(this.destroyRef), // Auto-unsubscribe + takeUntilDestroyed(), // constructor is an injection context ) .subscribe({ next: (value) => { diff --git a/docs/operators/error-handling/catchError.md b/docs/operators/error-handling/catchError.md index 37196ee..043f73a 100644 --- a/docs/operators/error-handling/catchError.md +++ b/docs/operators/error-handling/catchError.md @@ -60,7 +60,7 @@ This is the most common use case in Angular. Imagine fetching user data from an Let's create a component that tries to fetch user data. We'll use `catchError` to handle potential `HttpClient` errors. ```typescript -import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { Component, DestroyRef, inject, signal } from "@angular/core"; import { HttpClient, HttpErrorResponse } from "@angular/common/http"; import { Observable, of, EMPTY, throwError, catchError, tap } from "rxjs"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -94,18 +94,20 @@ interface User { `, }) -export class UserProfileComponent implements OnInit { - private http = inject(HttpClient); - private destroyRef = inject(DestroyRef); +export class UserProfileComponent { + private readonly http = inject(HttpClient); + // loadUser() is re-invoked from the template, outside any injection + // context, so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- - user = signal(null); - loading = signal(false); - errorMsg = signal(null); + protected readonly user = signal(null); + protected readonly loading = signal(false); + protected readonly errorMsg = signal(null); private userId = 1; // Example user ID - ngOnInit() { + constructor() { this.loadUser(); } diff --git a/docs/operators/error-handling/retry.md b/docs/operators/error-handling/retry.md index 38871a7..9581696 100644 --- a/docs/operators/error-handling/retry.md +++ b/docs/operators/error-handling/retry.md @@ -127,14 +127,7 @@ export class UserService { **2. User Profile Component (`user-profile.component.ts`)** ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - OnInit, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { UserService, UserData } from "./user.service"; import { EMPTY, catchError, tap, finalize } from "rxjs"; @@ -159,16 +152,17 @@ import { EMPTY, catchError, tap, finalize } from "rxjs"; `, - changeDetection: ChangeDetectionStrategy.OnPush, }) export class UserProfileComponent { - private userService = inject(UserService); - private destroyRef = inject(DestroyRef); + private readonly userService = inject(UserService); + // loadProfile() runs from the template, outside any injection context, + // so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- - userProfile = signal(null); - loading = signal(false); - errorMsg = signal(null); + protected readonly userProfile = signal(null); + protected readonly loading = signal(false); + protected readonly errorMsg = signal(null); loadProfile(): void { this.loading.set(true); diff --git a/docs/operators/error-handling/retryWhen.md b/docs/operators/error-handling/retryWhen.md index 7e19874..560707e 100644 --- a/docs/operators/error-handling/retryWhen.md +++ b/docs/operators/error-handling/retryWhen.md @@ -179,14 +179,7 @@ export class UserService { **2. User Profile Component** ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - OnInit, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { UserService, UserData } from "./user.service"; // Adjust path if needed import { EMPTY, catchError, tap, finalize } from "rxjs"; @@ -213,12 +206,13 @@ import { EMPTY, catchError, tap, finalize } from "rxjs"; Load Profile (retryWhen) `, - changeDetection: ChangeDetectionStrategy.OnPush, }) export class UserProfileRetryWhenComponent { // Renamed component - private userService = inject(UserService); - private destroyRef = inject(DestroyRef); + private readonly userService = inject(UserService); + // loadProfile() runs from the template, outside any injection context, + // so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- userProfile = signal(null); diff --git a/docs/operators/filtering/find.md b/docs/operators/filtering/find.md index 3b6797f..233207d 100644 --- a/docs/operators/filtering/find.md +++ b/docs/operators/filtering/find.md @@ -105,14 +105,7 @@ export class UserStreamService { **2. Component Using `find`** ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - OnInit, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { JsonPipe } from "@angular/common"; // for the json pipe import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { UserStreamService, User } from "./user-stream.service"; // Adjust path @@ -141,17 +134,15 @@ import { find, tap } from "rxjs";
`, // No 'styles' section - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FindAdminComponent implements OnInit { - private userStreamService = inject(UserStreamService); - private destroyRef = inject(DestroyRef); +export class FindAdminComponent { + private readonly userStreamService = inject(UserStreamService); // --- State Signals --- - foundAdmin = signal(undefined); // Result can be User or undefined - searchComplete = signal(false); // Track if the find operation finished + protected readonly foundAdmin = signal(undefined); // Result can be User or undefined + protected readonly searchComplete = signal(false); // Track if the find operation finished - ngOnInit(): void { + constructor() { console.log("FindAdminComponent: Subscribing to find the first admin..."); this.userStreamService @@ -166,7 +157,7 @@ export class FindAdminComponent implements OnInit { find((user) => user.isAdmin === true), // -------------------------------- - takeUntilDestroyed(this.destroyRef), // Standard cleanup + takeUntilDestroyed(), // constructor is an injection context ) .subscribe({ next: (adminUser) => { @@ -203,7 +194,7 @@ export class FindAdminComponent implements OnInit { **Explanation:** 1. `UserStreamService` provides an Observable `getUsers()` that emits user objects sequentially with a delay. -2. `FindAdminComponent` subscribes to this stream in `ngOnInit`. +2. `FindAdminComponent` subscribes to this stream in its constructor. 3. **`find(user => user.isAdmin === true)`**: This is the core. For each user emitted by `getUsers()`: - The predicate `user => user.isAdmin === true` is evaluated. - It checks Alice (false), Bob (false). diff --git a/docs/operators/filtering/first.md b/docs/operators/filtering/first.md index 353eab4..485da7a 100644 --- a/docs/operators/filtering/first.md +++ b/docs/operators/filtering/first.md @@ -53,17 +53,8 @@ Imagine your Angular application needs to load some initial configuration data w ## Code Snippet ```typescript -import { Component, OnInit } from "@angular/core"; -import { - of, - first, - catchError, - EMPTY, - throwError, - timer, - map, - concat, -} from "rxjs"; +import { Component, signal } from "@angular/core"; +import { first, catchError, EMPTY, throwError, timer, map, concat } from "rxjs"; import { EmptyError } from "rxjs"; @Component({ @@ -71,13 +62,13 @@ import { EmptyError } from "rxjs"; template: `

First Operator Demo

Looking for the first 'active' status. Check console.

-

Result: {{ resultStatus }}

+

Result: {{ resultStatus() }}

`, }) -export class FirstDemoComponent implements OnInit { - resultStatus = "Waiting..."; +export class FirstDemoComponent { + protected readonly resultStatus = signal("Waiting..."); - ngOnInit(): void { + constructor() { const statusUpdates$ = concat( timer(500).pipe(map(() => "pending")), timer(1000).pipe(map(() => "pending")), @@ -90,6 +81,7 @@ export class FirstDemoComponent implements OnInit { `[${new Date().toLocaleTimeString()}] Subscribing to find first 'active' status...`, ); + // no teardown needed: first() emits once, then completes the stream itself statusUpdates$ .pipe( first((status) => status === "active"), @@ -98,14 +90,14 @@ export class FirstDemoComponent implements OnInit { console.warn( `[${new Date().toLocaleTimeString()}] No 'active' status found before stream completed.`, ); - this.resultStatus = "No active status found."; + this.resultStatus.set("No active status found."); return EMPTY; } else { console.error( `[${new Date().toLocaleTimeString()}] Stream error:`, error, ); - this.resultStatus = `Error: ${error.message}`; + this.resultStatus.set(`Error: ${error.message}`); return throwError(() => error); } }), @@ -115,16 +107,17 @@ export class FirstDemoComponent implements OnInit { console.log( `[${new Date().toLocaleTimeString()}] Found first active status: ${activeStatus}`, ); - this.resultStatus = `First active status: ${activeStatus}`; + this.resultStatus.set(`First active status: ${activeStatus}`); }, complete: () => { console.log( `[${new Date().toLocaleTimeString()}] Stream completed by first().`, ); // Note: resultStatus might already be set by next or catchError - if (this.resultStatus === "Waiting...") { - this.resultStatus = - "Stream completed (likely handled by catchError/default)"; + if (this.resultStatus() === "Waiting...") { + this.resultStatus.set( + "Stream completed (likely handled by catchError/default)", + ); } }, }); diff --git a/docs/operators/filtering/last.md b/docs/operators/filtering/last.md index 8320744..eadbfd3 100644 --- a/docs/operators/filtering/last.md +++ b/docs/operators/filtering/last.md @@ -48,7 +48,7 @@ Imagine you have an Observable representing a sequence of operations that must f ## Code Snippet ```typescript -import { Component, OnInit } from "@angular/core"; +import { Component, signal } from "@angular/core"; import { of, last, catchError, EMPTY, throwError } from "rxjs"; import { EmptyError } from "rxjs"; @@ -57,19 +57,22 @@ import { EmptyError } from "rxjs"; template: `

Last Operator Demo

Getting the final score from a completed round. Check console.

-

Result: {{ finalScoreStatus }}

+

Result: {{ finalScoreStatus() }}

`, }) -export class LastDemoComponent implements OnInit { - finalScoreStatus = "Waiting for round to complete..."; +export class LastDemoComponent { + protected readonly finalScoreStatus = signal( + "Waiting for round to complete...", + ); - ngOnInit(): void { + constructor() { const roundScores$ = of(10, 50, 20, 100); // Finite, completes after 100 console.log( `[${new Date().toLocaleTimeString()}] Subscribing to get final score...`, ); + // no teardown needed: last() emits at completion, then the stream ends roundScores$ .pipe( last(), // No predicate, just get the very last value @@ -78,14 +81,14 @@ export class LastDemoComponent implements OnInit { console.warn( `[${new Date().toLocaleTimeString()}] Source completed without emitting values.`, ); - this.finalScoreStatus = "Round completed with no scores."; + this.finalScoreStatus.set("Round completed with no scores."); return EMPTY; } else { console.error( `[${new Date().toLocaleTimeString()}] Stream error:`, error, ); - this.finalScoreStatus = `Error: ${error.message}`; + this.finalScoreStatus.set(`Error: ${error.message}`); return throwError(() => error); } }), @@ -95,7 +98,7 @@ export class LastDemoComponent implements OnInit { console.log( `[${new Date().toLocaleTimeString()}] Final score received: ${finalScore}`, ); - this.finalScoreStatus = `Final Score: ${finalScore}`; + this.finalScoreStatus.set(`Final Score: ${finalScore}`); }, complete: () => { console.log( diff --git a/docs/operators/utility/delay.md b/docs/operators/utility/delay.md index 5e34993..3626f19 100644 --- a/docs/operators/utility/delay.md +++ b/docs/operators/utility/delay.md @@ -57,13 +57,7 @@ Imagine clicking a "Save" button. The backend operation might be incredibly fast ## Code Snippet ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { EMPTY, @@ -126,10 +120,11 @@ function mockSaveOperation(): Observable<{ } `, - changeDetection: ChangeDetectionStrategy.OnPush, }) export class SaveStatusComponent { - private destroyRef = inject(DestroyRef); + // saveData() runs from the template, outside any injection context, + // so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- saving = signal(false); diff --git a/docs/operators/utility/finalize.md b/docs/operators/utility/finalize.md index 21877ff..4496f4f 100644 --- a/docs/operators/utility/finalize.md +++ b/docs/operators/utility/finalize.md @@ -70,13 +70,7 @@ This is the classic example. We fetch data, show a loading indicator, and use `f ### Code Snippet ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { CurrencyPipe } from "@angular/common"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { @@ -154,10 +148,11 @@ function mockFetchProducts( } `, - changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProductListComponent { - private destroyRef = inject(DestroyRef); + // loadProducts() runs from the template, outside any injection context, + // so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- loading = signal(false); diff --git a/docs/operators/utility/tap.md b/docs/operators/utility/tap.md index 9a54ed0..3af1563 100644 --- a/docs/operators/utility/tap.md +++ b/docs/operators/utility/tap.md @@ -92,14 +92,7 @@ export class UserDataService { **2. User Profile Component (`user-profile.component.ts`) - Uses `tap`** ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - OnInit, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { UserDataService, SimpleUser } from "./user-data.service"; // Adjust path import { EMPTY, Observable, catchError, delay, finalize, tap } from "rxjs"; @@ -127,11 +120,12 @@ import { EMPTY, Observable, catchError, delay, finalize, tap } from "rxjs";

Click a button to load user data.

} `, - changeDetection: ChangeDetectionStrategy.OnPush, }) export class UserProfileComponent { - private userDataService = inject(UserDataService); - private destroyRef = inject(DestroyRef); + private readonly userDataService = inject(UserDataService); + // loadUser() runs from the template, outside any injection context, + // so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- user = signal(null); diff --git a/docs/operators/utility/timeout.md b/docs/operators/utility/timeout.md index caaf264..22fac19 100644 --- a/docs/operators/utility/timeout.md +++ b/docs/operators/utility/timeout.md @@ -92,13 +92,7 @@ export class SlowDataService { **2. Data Fetching Component (Applies `timeout`)** ```typescript -import { - Component, - inject, - signal, - ChangeDetectionStrategy, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { JsonPipe } from "@angular/common"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { SlowDataService, ExternalData } from "./slow-data.service"; // Adjust path @@ -132,11 +126,12 @@ import { EMPTY, TimeoutError, catchError, finalize, tap, timeout } from "rxjs"; `, // No 'styles' section as per previous request - changeDetection: ChangeDetectionStrategy.OnPush, }) export class DataFetcherComponent { - private dataService = inject(SlowDataService); - private destroyRef = inject(DestroyRef); + private readonly dataService = inject(SlowDataService); + // getData() runs from the template, outside any injection context, + // so takeUntilDestroyed needs an explicit DestroyRef + private readonly destroyRef = inject(DestroyRef); // --- State Signals --- loading = signal(false); diff --git a/docs/subjects/behaviorSubject.md b/docs/subjects/behaviorSubject.md index 9e030c5..e3194ae 100644 --- a/docs/subjects/behaviorSubject.md +++ b/docs/subjects/behaviorSubject.md @@ -143,15 +143,7 @@ export class ThemeSwitcherComponent { **3. Themed Content Component - Reacts to the theme** ```typescript -import { - Component, - inject, - signal, - OnInit, - DestroyRef, - ChangeDetectionStrategy, - HostBinding, -} from "@angular/core"; +import { Component, inject, signal, HostBinding } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ThemeService, AppTheme } from "./theme.service"; // Adjust path @@ -185,30 +177,28 @@ import { ThemeService, AppTheme } from "./theme.service"; // Adjust path } `, ], - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ThemedContentComponent implements OnInit { - private themeService = inject(ThemeService); - private destroyRef = inject(DestroyRef); +export class ThemedContentComponent { + private readonly themeService = inject(ThemeService); // Signal to hold the current theme for the template - currentThemeSignal = signal("light"); // Initialize with default + protected readonly currentThemeSignal = signal("light"); // Initialize with default // Use HostBinding to apply theme class to the component's host element @HostBinding("class.dark-theme") isDark = false; @HostBinding("class.light-theme") isLight = true; - ngOnInit(): void { + constructor() { // Subscribe to the theme state from the service this.themeService.theme$ .pipe( - // Automatically unsubscribe when the component is destroyed - takeUntilDestroyed(this.destroyRef), + // constructor runs in an injection context, so no DestroyRef needed + takeUntilDestroyed(), ) .subscribe((theme) => { // *** Key Point *** - // This code runs IMMEDIATELY when ngOnInit executes because - // BehaviorSubject emits the current value ('light') upon subscription. + // This code runs IMMEDIATELY on subscription because + // BehaviorSubject emits the current value ('light') right away. console.log(`ThemedContentComponent received theme: ${theme}`); // Update the signal for the template @@ -245,7 +235,7 @@ export class AppComponent {} 1. `ThemeService` creates a `BehaviorSubject` called `themeSubject` initialized with `'light'`. 2. It exposes `theme$` as an observable. -3. When `ThemedContentComponent` initializes (`ngOnInit`), it subscribes to `themeService.theme$`. +3. When `ThemedContentComponent` initializes, its constructor subscribes to `themeService.theme$`. 4. **Crucially:** Because it's a `BehaviorSubject`, the subscription immediately receives the _current_ value (`'light'`) without waiting for `setTheme` to be called. The component can instantly update its `currentThemeSignal` and apply the correct styling. 5. When the user clicks buttons in `ThemeSwitcherComponent`, `themeService.setTheme()` is called. 6. This calls `themeSubject.next(newTheme)`, broadcasting the new theme to all subscribers. diff --git a/docs/subjects/replaySubject.md b/docs/subjects/replaySubject.md index 7698e9f..da56679 100644 --- a/docs/subjects/replaySubject.md +++ b/docs/subjects/replaySubject.md @@ -147,14 +147,7 @@ export class ActionSimulatorComponent { **3. Activity Log Display Component - Shows Logs** ```typescript -import { - Component, - inject, - signal, - OnInit, - DestroyRef, - ChangeDetectionStrategy, -} from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { DatePipe } from "@angular/common"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivityLogService, LogEntry } from "./activity-log.service"; // Adjust path @@ -196,24 +189,22 @@ import { ActivityLogService, LogEntry } from "./activity-log.service"; // Adjust } `, ], - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ActivityDisplayComponent implements OnInit { - private logService = inject(ActivityLogService); - private destroyRef = inject(DestroyRef); +export class ActivityDisplayComponent { + private readonly logService = inject(ActivityLogService); // Expose buffer size to template (public readonly field on the service) - bufferSize = this.logService.logBufferSize; + protected readonly bufferSize = this.logService.logBufferSize; // Use a signal to hold the logs for the template - logMessages = signal([]); + protected readonly logMessages = signal([]); - ngOnInit(): void { + constructor() { // Subscribe to the log entries from the service this.logService.logEntries$ .pipe( - // Automatically unsubscribe when the component is destroyed - takeUntilDestroyed(this.destroyRef), + // constructor runs in an injection context, so no DestroyRef needed + takeUntilDestroyed(), ) .subscribe((entry) => { // *** Key Point *** @@ -279,7 +270,7 @@ export class AppComponent { 1. `ActivityLogService` creates a `ReplaySubject` configured to buffer the last 5 (`logBufferSize`) `LogEntry` objects. 2. When `addLog` is called (by `ActionSimulatorComponent` or directly), the new `LogEntry` is pushed into the `logSubject`. It's stored in the buffer (replacing the oldest if the buffer is full) and sent to any _currently subscribed_ components. -3. When `ActivityDisplayComponent` initializes (`ngOnInit`), it subscribes to `logService.logEntries$`. +3. When `ActivityDisplayComponent` initializes, its constructor subscribes to `logService.logEntries$`. 4. **Crucially:** The `ReplaySubject` immediately replays its buffered messages (up to 5 of the most recent ones, including the "Application session started." log added in the `AppComponent` constructor) to the new subscription in `ActivityDisplayComponent`. The component doesn't start with an empty list; it gets the recent history right away. 5. As new logs are added via `addLog`, they are pushed live to the `ActivityDisplayComponent`'s subscription and added to its display. diff --git a/docs/subjects/share.md b/docs/subjects/share.md index 1b7b002..0acac6d 100644 --- a/docs/subjects/share.md +++ b/docs/subjects/share.md @@ -109,14 +109,7 @@ export class SharedTimerService { **2. Component Displaying Ticks** ```typescript -import { - Component, - inject, - signal, - OnInit, - OnDestroy, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { SharedTimerService } from "./timer.service"; // Adjust path @@ -133,16 +126,15 @@ import { SharedTimerService } from "./timer.service"; // Adjust path ".display-box { border: 1px solid purple; padding: 10px; margin: 10px; }", ], }) -export class TickDisplayAComponent implements OnInit { - private timerService = inject(SharedTimerService); - private destroyRef = inject(DestroyRef); +export class TickDisplayAComponent { + private readonly timerService = inject(SharedTimerService); - lastTick = signal("Waiting..."); + protected readonly lastTick = signal("Waiting..."); - ngOnInit(): void { + constructor() { console.log("TickDisplayA: Subscribing to sharedTicks$"); this.timerService.sharedTicks$ - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe(takeUntilDestroyed()) .subscribe((tickValue) => { console.log(`TickDisplayA: Received tick ${tickValue}`); this.lastTick.set(tickValue); @@ -154,14 +146,7 @@ export class TickDisplayAComponent implements OnInit { **3. Another Component Displaying Ticks - Subscribes Late** ```typescript -import { - Component, - inject, - signal, - OnInit, - OnDestroy, - DestroyRef, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { SharedTimerService } from "./timer.service"; // Adjust path @@ -178,13 +163,15 @@ import { SharedTimerService } from "./timer.service"; // Adjust path ".display-box { border: 1px solid purple; padding: 10px; margin: 10px; }", ], }) -export class TickDisplayBComponent implements OnInit { - private timerService = inject(SharedTimerService); - private destroyRef = inject(DestroyRef); +export class TickDisplayBComponent { + private readonly timerService = inject(SharedTimerService); + // the setTimeout callback is NOT an injection context, + // so takeUntilDestroyed needs an explicit DestroyRef there + private readonly destroyRef = inject(DestroyRef); - lastTick = signal("Waiting..."); + protected readonly lastTick = signal("Waiting..."); - ngOnInit(): void { + constructor() { // Simulate this component loading or deciding to subscribe later setTimeout(() => { console.log("TickDisplayB: Subscribing to sharedTicks$ (after delay)"); @@ -224,8 +211,8 @@ export class AppComponent {} **Explanation:** 1. `SharedTimerService` creates an `interval(2000)` Observable and applies `share()` to it, storing the result in `sharedTicks$`. -2. `TickDisplayAComponent` subscribes immediately in `ngOnInit`. This is the first subscription. `share` subscribes to the source `interval`, which starts emitting 0, 1, 2... every 2 seconds. Component A receives all these ticks. -3. `TickDisplayBComponent` waits 5 seconds before subscribing in its `ngOnInit`. +2. `TickDisplayAComponent` subscribes immediately in its constructor. This is the first subscription. `share` subscribes to the source `interval`, which starts emitting 0, 1, 2... every 2 seconds. Component A receives all these ticks. +3. `TickDisplayBComponent` waits 5 seconds before subscribing. 4. When Component B subscribes, the source `interval` (shared via `share`) is already running and might have already emitted ticks 0 and 1. 5. Component B **will not** receive ticks 0 and 1. Its subscription will start receiving ticks from the _next_ emission of the shared interval (likely tick 2 or 3, depending on timing). 6. Both components receive subsequent ticks (3, 4, 5...) simultaneously as they are emitted by the single, shared interval. diff --git a/docs/subjects/shareReplay.md b/docs/subjects/shareReplay.md index 16f5cb5..3f0a883 100644 --- a/docs/subjects/shareReplay.md +++ b/docs/subjects/shareReplay.md @@ -128,14 +128,7 @@ export class ConfigService { **2. Component A - Consumes Config** ```typescript -import { - Component, - inject, - signal, - OnInit, - DestroyRef, - ChangeDetectionStrategy, -} from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ConfigService, AppConfig } from "./config.service"; // Adjust path @@ -155,22 +148,18 @@ import { ConfigService, AppConfig } from "./config.service"; // Adjust path styles: [ ".component-box { border: 1px solid blue; padding: 10px; margin: 10px; }", ], - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CompAComponent implements OnInit { - private configService = inject(ConfigService); - private destroyRef = inject(DestroyRef); +export class CompAComponent { + private readonly configService = inject(ConfigService); - config = signal(null); + protected readonly config = signal(null); - ngOnInit(): void { + constructor() { console.log("CompA: Subscribing to config$"); - this.configService.config$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((cfg) => { - console.log("CompA: Received config", cfg); - this.config.set(cfg); - }); + this.configService.config$.pipe(takeUntilDestroyed()).subscribe((cfg) => { + console.log("CompA: Received config", cfg); + this.config.set(cfg); + }); } } ``` @@ -178,14 +167,7 @@ export class CompAComponent implements OnInit { **3. Component B - Consumes Config** ```typescript -import { - Component, - inject, - signal, - OnInit, - DestroyRef, - ChangeDetectionStrategy, -} from "@angular/core"; +import { Component, inject, signal, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ConfigService, AppConfig } from "./config.service"; // Adjust path @@ -210,15 +192,16 @@ import { ConfigService, AppConfig } from "./config.service"; // Adjust path styles: [ ".component-box { border: 1px solid blue; padding: 10px; margin: 10px; }", ], - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CompBComponent implements OnInit { - private configService = inject(ConfigService); - private destroyRef = inject(DestroyRef); +export class CompBComponent { + private readonly configService = inject(ConfigService); + // the setTimeout callback is NOT an injection context, + // so takeUntilDestroyed needs an explicit DestroyRef there + private readonly destroyRef = inject(DestroyRef); - config = signal(null); + protected readonly config = signal(null); - ngOnInit(): void { + constructor() { console.log("CompB: Subscribing to config$"); // Simulate CompB loading slightly later setTimeout(() => { @@ -255,7 +238,7 @@ export class AppComponent {} **Explanation:** 1. The `ConfigService` defines `config$`. Inside the constructor, it chains `http.get(...)` with `tap()` (for logging the fetch attempt) and then `shareReplay({ bufferSize: 1, refCount: true })`. -2. When `CompAComponent` initializes (`ngOnInit`), it subscribes to `configService.config$`. This is the _first_ subscription. +2. When `CompAComponent` initializes, its constructor subscribes to `configService.config$`. This is the _first_ subscription. 3. Because it's the first subscription and `refCount` is true, `shareReplay` subscribes to its source (the `http.get`). The HTTP request is made. You'll see the "Fetching application config from API..." log message **once**. 4. When the HTTP request completes, `shareReplay` receives the `AppConfig` data. It buffers this single value (`bufferSize: 1`) and sends it to `CompAComponent`. 5. A moment later, `CompBComponent` initializes and subscribes to the _same_ `configService.config$`. diff --git a/docs/subjects/subject.md b/docs/subjects/subject.md index 577ee9c..dd48974 100644 --- a/docs/subjects/subject.md +++ b/docs/subjects/subject.md @@ -147,14 +147,7 @@ export class HeaderComponent { **3. User Profile Component - Listens for Changes** ```typescript -import { - Component, - inject, - signal, - OnInit, - DestroyRef, - ChangeDetectionStrategy, -} from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { AuthService, User } from "./auth.service"; // Adjust path if needed @@ -181,21 +174,19 @@ import { AuthService, User } from "./auth.service"; // Adjust path if needed } `, ], - changeDetection: ChangeDetectionStrategy.OnPush, // Good practice with signals/observables }) -export class UserProfileComponent implements OnInit { - private authService = inject(AuthService); - private destroyRef = inject(DestroyRef); +export class UserProfileComponent { + private readonly authService = inject(AuthService); // Use a signal to hold the user state for the template - loggedInUser = signal(null); + protected readonly loggedInUser = signal(null); - ngOnInit(): void { + constructor() { // Subscribe to the service's Observable this.authService.userLoginStatus$ .pipe( - // Automatically unsubscribe when the component is destroyed - takeUntilDestroyed(this.destroyRef), + // constructor runs in an injection context, so no DestroyRef needed + takeUntilDestroyed(), ) .subscribe((user) => { // Update the signal when a new status is broadcast by the Subject @@ -233,7 +224,7 @@ export class AppComponent {} 1. The `AuthService` creates a `Subject` (`userLoginStatusSubject`) to manage the login state. 2. It exposes only an `Observable` (`userLoginStatus$`) derived from the subject using `.asObservable()`. This is good practice – it prevents components from accidentally calling `next()` on the service's subject. Only the service itself controls when broadcasts happen. 3. When `authService.login()` or `authService.logout()` is called (triggered by `HeaderComponent`), the service calls `this.userLoginStatusSubject.next(...)`, pushing the new user data (or `null`) into the Subject. -4. The `UserProfileComponent` subscribes to `authService.userLoginStatus$` in its `ngOnInit`. +4. The `UserProfileComponent` subscribes to `authService.userLoginStatus$` in its constructor. 5. Whenever the Subject broadcasts a new value, the subscription in `UserProfileComponent` receives it, and updates the `loggedInUser` signal, causing the component's template to reactively display the current status. 6. `takeUntilDestroyed` ensures the subscription is cleaned up when the `UserProfileComponent` is destroyed. From f914000e14270d6046b30bf8bd9b64e03c9acba9 Mon Sep 17 00:00:00 2001 From: Ankit Sharma <33789321+AnkitSharma-007@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:10:46 +0530 Subject: [PATCH 8/8] Address PR review --- docs/angular/rxjs-vs-resource.md | 6 +++--- docs/angular/signals-interop.md | 2 +- docs/operators/combination/withLatestFrom.md | 2 +- docs/operators/filtering/first.md | 6 +++++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/angular/rxjs-vs-resource.md b/docs/angular/rxjs-vs-resource.md index a769208..afcc19e 100644 --- a/docs/angular/rxjs-vs-resource.md +++ b/docs/angular/rxjs-vs-resource.md @@ -18,7 +18,7 @@ Angular ships a signals-native answer for async data: `resource`, its HTTP wrapp ## What a resource gives you -A resource is a reactive fetch bound to signal inputs. When the `params` computation changes, the loader re-runs and the previous request is aborted: +A resource is a reactive fetch bound to signal inputs. When the `params` computation changes, the loader re-runs and the previous request is dropped. One cancellation nuance up front: `httpResource` genuinely **cancels** the stale HTTP request, while the generic `resource()` only signals its loader's `AbortSignal`; the underlying work stops only if the loader honors that signal (for example by passing it to `fetch`). ```typescript import { Component, inject, input } from "@angular/core"; @@ -85,11 +85,11 @@ The pipeline keeps its RxJS powers; the template gets `value()`, `isLoading()`, ??? question "Why not just use httpResource for the typeahead search?" - Because typeahead is mostly not a fetching problem. The pipeline needs debouncing (a resource's `params` reacts to every keystroke instantly) and deliberate cancellation of the settled-but-stale request. A resource does abort on params changes, but it cannot debounce, dedupe, or throttle them. The strong answer: debounce and shape the stream with RxJS, then hand the final fetch to `rxResource`, or keep `switchMap` + `toSignal`. + Because typeahead is mostly not a fetching problem. The pipeline needs debouncing (a resource's `params` reacts to every keystroke instantly) and deliberate cancellation of the settled-but-stale request. `httpResource` does cancel its stale request on params changes (a custom `resource()` loader must honor the `AbortSignal` itself), but no resource can debounce, dedupe, or throttle its inputs. The strong answer: debounce and shape the stream with RxJS, then hand the final fetch to `rxResource`, or keep `switchMap` + `toSignal`. ??? question "When would you actively choose a resource over a stream?" - Parameter-driven reads with standard loading/error UI: detail pages keyed by a route param or input signal, dashboards re-fetching on filter changes. The resource's abort-on-change covers the stale-response problem, `reload()` covers refresh, and there is less code to review. + Parameter-driven reads with standard loading/error UI: detail pages keyed by a route param or input signal, dashboards re-fetching on filter changes. `httpResource`'s cancel-on-change covers the stale-response problem (custom loaders get an `AbortSignal` to honor), `reload()` covers refresh, and there is less code to review. ??? question "Does the Resource API make shareReplay-style caching obsolete?" diff --git a/docs/angular/signals-interop.md b/docs/angular/signals-interop.md index 46f77bb..85baeac 100644 --- a/docs/angular/signals-interop.md +++ b/docs/angular/signals-interop.md @@ -113,7 +113,7 @@ export class ResultsComponent { **Coalescing caveat:** `toObservable` observes the signal with an effect, so several synchronous `set()` calls produce **one** emission with the final value. It is a state bridge, not an event log; if every event matters, keep a `Subject` as the source of truth. -**"Why not `debounced()`?"** Angular ships a (currently experimental) `debounced()` helper that debounces a signal's value and exposes settling state as a resource, and Signal Forms has its own field-level debouncing. Neither replaces this pipeline, for one precise reason: they debounce the **value**, while `switchMap` also **cancels the in-flight request** the previous value triggered. Debouncing alone cannot stop a stale response from landing after a fresh one; cancellation is exactly what an interviewer is probing with this example. Full decision guide: [RxJS or the Resource API?](rxjs-vs-resource.md) +**"Why not `debounced()`?"** Angular ships a (currently experimental) `debounced()` helper that debounces a signal's value, and Signal Forms has its own field-level debouncing. For the plain search case, a debounced signal driving `httpResource` is a legitimate composition: the debounce settles the parameter and `httpResource` cancels the stale request on each change. The stream pipeline earns its place when the requirement grows past that pairing: skipping unchanged values (`distinctUntilChanged`), retry with backoff, combining several inputs, cancelling non-HTTP work (any inner Observable, not just requests), or keeping the entire policy in one reviewable pipeline. Naming that boundary, rather than defending RxJS reflexively, is the strong interview answer. Full decision guide: [RxJS or the Resource API?](rxjs-vs-resource.md) ## The Rest of the Interop Package diff --git a/docs/operators/combination/withLatestFrom.md b/docs/operators/combination/withLatestFrom.md index c9cd7b6..4a2858c 100644 --- a/docs/operators/combination/withLatestFrom.md +++ b/docs/operators/combination/withLatestFrom.md @@ -152,7 +152,7 @@ export class ProductSearchComponent { 3. **`categoryFilter$`:** We get an Observable of the category changes using `valueChanges`, plus `startWith(control.value)` so the stream has a value before the user touches the dropdown. This seed is essential, and it belongs on the **secondary** stream: `withLatestFrom` silently drops source emissions until every secondary has produced at least one value, so without it, early searches would vanish. 4. **`withLatestFrom(categoryFilter$)`:** We pipe the `searchTerm$` (our source). When `searchTerm$` emits a value (after debouncing), `withLatestFrom` looks at `categoryFilter$` and gets its _most recently emitted value_. 5. **`subscribe(([term, category]) => ...)`:** The result is an array `[sourceValue, latestOtherValue]`. We destructure this into `term` and `category`. This callback function is executed _only_ when the debounced search term changes. Inside, we have exactly what we need: the current search term and the _latest_ selected category at that moment. -6. **`takeUntilDestroyed(this.destroyRef)`:** This is the modern Angular way to handle unsubscriptions. When the `ProductSearchComponent` is destroyed, this operator automatically completes the Observable stream, preventing memory leaks without manual cleanup. +6. **`takeUntilDestroyed()`:** This is the modern Angular way to handle unsubscriptions. Called with no arguments here because the constructor is an injection context; when the `ProductSearchComponent` is destroyed, this operator automatically completes the Observable stream, preventing memory leaks without manual cleanup. So, `withLatestFrom()` is incredibly useful when an action (like searching) depends on the latest state of other configuration or filter inputs at the exact moment the action is triggered. diff --git a/docs/operators/filtering/first.md b/docs/operators/filtering/first.md index 485da7a..df70957 100644 --- a/docs/operators/filtering/first.md +++ b/docs/operators/filtering/first.md @@ -54,6 +54,7 @@ Imagine your Angular application needs to load some initial configuration data w ```typescript import { Component, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { first, catchError, EMPTY, throwError, timer, map, concat } from "rxjs"; import { EmptyError } from "rxjs"; @@ -81,7 +82,9 @@ export class FirstDemoComponent { `[${new Date().toLocaleTimeString()}] Subscribing to find first 'active' status...`, ); - // no teardown needed: first() emits once, then completes the stream itself + // first() ends the stream only when a match (or completion/error) + // arrives; until then the timer chain is live, so tie the wait to the + // component's lifetime statusUpdates$ .pipe( first((status) => status === "active"), @@ -101,6 +104,7 @@ export class FirstDemoComponent { return throwError(() => error); } }), + takeUntilDestroyed(), // constructor is an injection context ) .subscribe({ next: (activeStatus) => {