Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Implement RxJS patterns for reactive programming in Angular. Use this skill when working with Observables, operators, subscriptions, async data flows, and error handling. Covers common patterns like combineLatest, switchMap, debounceTime, catchError, retry logic, and integration with Angular Signals using toSignal() and toObservable(). Ensures proper subscription cleanup with takeUntilDestroyed().
.claude/skills/aiskillstore-rxjs-patterns-for-angular/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | 135% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 131% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 109% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 177% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 299% | 0% |
This skill helps implement reactive patterns using RxJS in Angular applications.
takeUntilDestroyed() for subscription managementtoSignal() and toObservable() for Signal/Observable conversiontypescriptimport { Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-task-list', template: ` @if (tasks(); as taskList) { @for (task of taskList; track task.id) { <div>{{ task.title }}</div> } } ` }) export class TaskListComponent { private http = inject(HttpClient); // Convert Observable to Signal tasks = toSignal( this.http.get<Task[]>('/api/tasks'), { initialValue: [] } ); }
typescriptimport { Component, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { switchMap } from 'rxjs/operators'; @Component({ selector: 'app-search', template: ` <input nz-input [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event)" /> @if (results(); as resultList) { @for (result of resultList; track result.id) { <div>{{ result.name }}</div> } } ` }) export class SearchComponent { searchQuery = signal(''); // Convert Signal to Observable and transform private searchQuery$ = toObservable(this.searchQuery); results = toSignal( this.searchQuery$.pipe( debounceTime(300), distinctUntilChanged(), switchMap(query => this.searchService.search(query)) ), { initialValue: [] } ); }
typescriptimport { Component, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { interval } from 'rxjs'; @Component({ selector: 'app-timer', template: `<div>Time: {{ time() }}</div>` }) export class TimerComponent { private destroyRef = inject(DestroyRef); time = signal(0); constructor() { // Subscription automatically cleaned up on component destroy interval(1000) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(value => this.time.set(value)); } }
typescript// ❌ DON'T: Manual subscription management (old pattern) export class LegacyComponent implements OnDestroy { private subscription = new Subscription(); ngOnInit() { this.subscription.add( this.dataService.getData().subscribe(data => { // handle data }) ); } ngOnDestroy() { this.subscription.unsubscribe(); } } // ✅ DO: Use takeUntilDestroyed() export class ModernComponent { private destroyRef = inject(DestroyRef); data = signal<any>(null); constructor() { this.dataService.getData() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(data => this.data.set(data)); } }
typescript// Switch to new search on every query change searchResults$ = this.searchQuery$.pipe( debounceTime(300), switchMap(query => this.http.get(`/api/search?q=${query}`)) );
typescript// Process all tasks in parallel processTasks$ = this.tasks$.pipe( mergeMap(tasks => from(tasks).pipe( mergeMap(task => this.processTask(task)) ) ) );
typescript// Process tasks one by one in order processTasks$ = this.tasks$.pipe( concatMap(tasks => from(tasks).pipe( concatMap(task => this.processTask(task)) ) ) );
typescript// Wait 300ms after user stops typing search$ = this.searchInput$.pipe( debounceTime(300), distinctUntilChanged(), switchMap(query => this.searchService.search(query)) );
typescript// Only emit when value actually changes status$ = this.statusSubject$.pipe( distinctUntilChanged() );
typescript// Only emit non-empty strings nonEmptySearch$ = this.searchQuery$.pipe( filter(query => query.trim().length > 0), switchMap(query => this.search(query)) );
typescript// Transform task to display format taskDisplay$ = this.task$.pipe( map(task => ({ title: task.title, status: task.status.toUpperCase(), dueDate: formatDate(task.dueDate) })) );
typescript// Log without transforming tasks$ = this.http.get<Task[]>('/api/tasks').pipe( tap(tasks => console.log('Loaded tasks:', tasks.length)), tap(tasks => this.analyticsService.track('tasks_loaded')) );
typescriptimport { combineLatest } from 'rxjs'; // Combine multiple observables viewModel$ = combineLatest([ this.tasks$, this.users$, this.settings$ ]).pipe( map(([tasks, users, settings]) => ({ tasks, users, settings })) ); // Convert to Signal viewModel = toSignal(this.viewModel$);
typescriptimport { forkJoin } from 'rxjs'; // Load multiple resources in parallel loadAll$ = forkJoin({ tasks: this.taskService.getTasks(), users: this.userService.getUsers(), projects: this.projectService.getProjects() }).pipe( map(({ tasks, users, projects }) => ({ tasks, users, projects })) );
typescriptimport { merge } from 'rxjs'; // Combine multiple event streams allEvents$ = merge( this.createEvent$, this.updateEvent$, this.deleteEvent$ ).pipe( tap(event => this.handleEvent(event)) );
typescriptimport { zip } from 'rxjs'; // Pair up matching values from two streams paired$ = zip( this.stream1$, this.stream2$ ).pipe( map(([value1, value2]) => ({ value1, value2 })) );
typescripttasks$ = this.http.get<Task[]>('/api/tasks').pipe( catchError(error => { console.error('Failed to load tasks:', error); this.notificationService.error('Failed to load tasks'); return of([]); // Return empty array as fallback }) );
typescripttasks$ = this.http.get<Task[]>('/api/tasks').pipe( retry(3), // Retry up to 3 times catchError(error => { console.error('Failed after 3 retries:', error); return of([]); }) );
typescriptimport { retryWhen, delay, scan, throwError } from 'rxjs'; tasks$ = this.http.get<Task[]>('/api/tasks').pipe( retryWhen(errors => errors.pipe( scan((retryCount, error) => { if (retryCount >= 3) { throw error; // Max retries reached } console.log(`Retry ${retryCount + 1}/3`); return retryCount + 1; }, 0), delay(1000) // Wait 1 second between retries ) ), catchError(error => { console.error('Failed after retries:', error); return of([]); }) );
typescriptimport { interval, switchMap } from 'rxjs'; // Poll every 30 seconds liveData$ = interval(30000).pipe( startWith(0), // Emit immediately switchMap(() => this.http.get('/api/live-data')), takeUntilDestroyed(this.destroyRef) ); liveData = toSignal(this.liveData$);
typescriptimport { webSocket } from 'rxjs/webSocket'; export class RealtimeService { private socket$ = webSocket('wss://api.example.com/ws'); messages$ = this.socket$.pipe( catchError(error => { console.error('WebSocket error:', error); return EMPTY; }), retry({ delay: 5000 }) // Reconnect after 5 seconds ); sendMessage(msg: any): void { this.socket$.next(msg); } }
typescriptimport { shareReplay } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class TaskService { private http = inject(HttpClient); // Cache and share the result tasks$ = this.http.get<Task[]>('/api/tasks').pipe( shareReplay({ bufferSize: 1, refCount: true }) ); }
typescript@Component({ selector: 'app-task-list', template: ` @if (loading()) { <nz-spin /> } @else if (error()) { <nz-alert nzType="error" [nzMessage]="error()!" /> } @else { @for (task of tasks(); track task.id) { <div>{{ task.title }}</div> } } ` }) export class TaskListComponent { private taskService = inject(TaskService); private destroyRef = inject(DestroyRef); loading = signal(false); error = signal<string | null>(null); tasks = signal<Task[]>([]); constructor() { this.loadTasks(); } loadTasks(): void { this.loading.set(true); this.error.set(null); this.taskService.tasks$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (tasks) => { this.tasks.set(tasks); this.loading.set(false); }, error: (err) => { this.error.set(err.message || 'Failed to load tasks'); this.loading.set(false); } }); } }
typescriptimport { throttleTime, debounceTime } from 'rxjs'; // Throttle: Emit first, then ignore for duration throttled$ = this.clicks$.pipe( throttleTime(1000) // Max once per second ); // Debounce: Wait for quiet period debounced$ = this.input$.pipe( debounceTime(300) // Wait 300ms after last input );
typescript// Running total total$ = this.amounts$.pipe( scan((acc, value) => acc + value, 0) ); // History accumulation history$ = this.events$.pipe( scan((history, event) => [...history, event], [] as Event[]) );
typescript// Start with loading state status$ = this.dataLoad$.pipe( map(() => 'loaded'), startWith('loading') );
typescript// Compare with previous value changes$ = this.value$.pipe( pairwise(), map(([prev, curr]) => ({ previous: prev, current: curr, diff: curr - prev })) );
typescript// Use toSignal() for reactive data in templates data = toSignal(this.data$, { initialValue: [] }); // Use takeUntilDestroyed() for cleanup this.data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); // Use switchMap for user-triggered requests search$ = this.query$.pipe(switchMap(q => this.search(q))); // Handle errors explicitly data$ = this.http.get('/api/data').pipe( catchError(err => of(null)) );
typescript// Don't forget to unsubscribe this.data$.subscribe(); // Memory leak! // Don't use nested subscribes this.data$.subscribe(data => { this.process(data).subscribe(); // Anti-pattern! }); // Don't use async pipe with signals @if (data$ | async) { } // Use signals instead
When using RxJS:
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 11,542 | 8,508 | -26% | 1 | 1 | 0% | 2,271 | 5,237 | +131% | 0 | 0 | — |
case-02 | pass→pass | 12,691 | 6,709 | -47% | 1 | 1 | 0% | 2,301 | 4,817 | +109% | 0 | 0 | — |
case-03 | pass→pass | 10,555 | 7,948 | -25% | 1 | 1 | 0% | 1,834 | 5,079 | +177% | 0 | 0 | — |
case-04 | pass→pass | 6,414 | 5,278 | -18% | 1 | 1 | 0% | 1,125 | 4,486 | +299% | 0 | 0 | — |
case-05 | pass→pass | 5,811 | 5,508 | -5% | 1 | 1 | 0% | 994 | 4,650 | +368% | 0 | 0 | — |
case-06 | pass→pass | 5,510 | 6,546 | +19% | 1 | 1 | 0% | 965 | 4,755 | +393% | 0 | 0 | — |
case-07 | pass→pass | 6,426 | 5,043 | -22% | 1 | 1 | 0% | 1,009 | 4,471 | +343% | 0 | 0 | — |
case-08 | pass→pass | 6,823 | 7,094 | +4% | 1 | 1 | 0% | 1,129 | 4,790 | +324% | 0 | 0 | — |
case-09 | pass→pass | 8,256 | 25,328 | +207% | 1 | 1 | 0% | 1,527 | 5,166 | +238% | 0 | 0 | — |
case-10 | fail→pass | 11,790 | 9,328 | -21% | 1 | 1 | 0% | 2,277 | 5,347 | +135% | 0 | 0 | — |
case-11 | pass→pass | 15,019 | 12,368 | -18% | 1 | 1 | 0% | 2,911 | 6,128 | +111% | 0 | 0 | — |
case-12 | pass→pass | 10,850 | 6,680 | -38% | 1 | 1 | 0% | 1,911 | 4,836 | +153% | 0 | 0 | — |
case-17 | pass→pass | 5,036 | 4,982 | -1% | 1 | 1 | 0% | 854 | 4,418 | +417% | 0 | 0 | — |
case-13 | pass→pass | 9,500 | 7,694 | -19% | 1 | 1 | 0% | 1,827 | 5,094 | +179% | 0 | 0 | — |
case-14 | pass→pass | 4,843 | 3,960 | -18% | 1 | 1 | 0% | 703 | 4,162 | +492% | 0 | 0 | — |
case-15 | pass→pass | 4,813 | 5,028 | +4% | 1 | 1 | 0% | 771 | 4,421 | +473% | 0 | 0 | — |
case-16 | pass→pass | 5,530 | 6,349 | +15% | 1 | 1 | 0% | 967 | 4,694 | +385% | 0 | 0 | — |
case-18 | pass→pass | 5,606 | 2,898 | -48% | 1 | 1 | 0% | 929 | 4,080 | +339% | 0 | 0 | — |
case-19 | pass→pass | 13,292 | 10,796 | -19% | 1 | 1 | 0% | 2,238 | 5,631 | +152% | 0 | 0 | — |
case-20 | pass→pass | 8,116 | 7,574 | -7% | 1 | 1 | 0% | 1,707 | 5,161 | +202% | 0 | 0 | — |
case-21 | pass→pass | 5,154 | 5,633 | +9% | 1 | 1 | 0% | 916 | 4,511 | +392% | 0 | 0 | — |
case-22 | pass→pass | 9,367 | 7,883 | -16% | 1 | 1 | 0% | 1,610 | 4,995 | +210% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.