Skip to content
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
14 changes: 10 additions & 4 deletions docs/angular/anti-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User | null>(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
Expand All @@ -48,15 +52,17 @@ 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<string[]>([]);

stream$
.pipe(
map((data) =>
data.filter((d) => d.active).map((d) => d.name.toUpperCase()),
),
filter((names) => names.length > 0),
)
.subscribe((names) => (this.names = names));
.subscribe((names) => this.names.set(names));
```

## 3. Exposing Raw Subjects
Expand Down Expand Up @@ -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

Expand Down
86 changes: 22 additions & 64 deletions docs/angular/async-pipe.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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: `
<div class="user-card">
<h4>User Profile (ID: {{ userId }})</h4>
<h4>User Profile (ID: {{ userId() }})</h4>

<!-- Use the async pipe here -->
@if (user$ | async; as user) {
Expand All @@ -108,63 +102,24 @@ import { Observable, EMPTY } from "rxjs"; // Import Observable and EMPTY
<p><strong>Username:</strong> {{ user.username }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
</div>
<!-- Optional: Show raw data -->
<!-- <details>
<summary>Raw Data</summary>
<pre>{{ user | json }}</pre>
</details> -->
} @else {
<!-- This shows before the observable emits -->
<p>Loading user data...</p>
}
<!-- Note: Error handling needs separate logic or wrapping the source -->
</div>
`,
// 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<number>(); // signal input from the parent

// Expose the Observable directly to the template
user$: Observable<UserProfile> = 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<UserProfile | null>(null);
// loading = signal<boolean>(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<UserProfile> = toObservable(
this.userId,
).pipe(switchMap((id) => this.userService.getUser(id)));
}
```

Expand Down Expand Up @@ -193,18 +148,17 @@ export class AppComponent {}
**Explanation:**

1. `UserService` provides a `getUser(id)` method that returns an `Observable<UserProfile>`. 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

Expand All @@ -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?"

Expand Down
2 changes: 1 addition & 1 deletion docs/angular/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions docs/angular/http-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@ type State<T> =
@Component({
selector: "app-products",
template: `
@switch (state().status) {
@let s = state();
@switch (s.status) {
@case ("loading") {
<p>Loading...</p>
}
@case ("error") {
<p>Could not load products.</p>
}
@case ("loaded") {
<p>{{ $any(state()).data.length }} products</p>
<p>{{ s.data.length }} products</p>
}
}
`,
Expand All @@ -53,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

Expand Down
1 change: 1 addition & 0 deletions docs/angular/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions docs/angular/interceptors-retry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
}),
Expand Down
6 changes: 6 additions & 0 deletions docs/angular/reactive-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ 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.

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):
Expand Down
2 changes: 1 addition & 1 deletion docs/angular/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?"

Expand Down
Loading
Loading