Initial commit
This commit is contained in:
627
skills/rxjs-operators/SKILL.md
Normal file
627
skills/rxjs-operators/SKILL.md
Normal file
@@ -0,0 +1,627 @@
|
||||
# RxJS Operators for Angular
|
||||
|
||||
**Purpose:** Essential RxJS operators every Angular developer should master
|
||||
**Level:** Intermediate to Advanced
|
||||
**Version:** RxJS 7+
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**Operators** are functions that enable composing asynchronous operations with observables. They transform, filter, combine, and manage observable streams.
|
||||
|
||||
**Key Principles:**
|
||||
- Operators are **pure functions**
|
||||
- They **don't modify** the source observable
|
||||
- They **return a new** observable
|
||||
- They can be **chained** together
|
||||
|
||||
---
|
||||
|
||||
## Category 1: Transformation Operators
|
||||
|
||||
### map
|
||||
|
||||
**Purpose:** Transform each value emitted
|
||||
|
||||
```typescript
|
||||
import { of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
// Example 1: Simple transformation
|
||||
of(1, 2, 3).pipe(
|
||||
map(x => x * 10)
|
||||
).subscribe(console.log);
|
||||
// Output: 10, 20, 30
|
||||
|
||||
// Example 2: Object transformation
|
||||
interface User { id: number; name: string; }
|
||||
interface UserDisplay { id: number; displayName: string; }
|
||||
|
||||
this.users$.pipe(
|
||||
map((users: User[]) => users.map(u => ({
|
||||
id: u.id,
|
||||
displayName: u.name.toUpperCase()
|
||||
})))
|
||||
);
|
||||
```
|
||||
|
||||
### switchMap
|
||||
|
||||
**Purpose:** Switch to a new observable, canceling previous
|
||||
|
||||
**Use when:** Making HTTP requests based on user input
|
||||
|
||||
```typescript
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
|
||||
// Search as user types
|
||||
this.searchTerm$.pipe(
|
||||
debounceTime(300),
|
||||
switchMap(term => this.http.get(`/api/search?q=${term}`))
|
||||
).subscribe(results => console.log(results));
|
||||
|
||||
// Load user details when ID changes
|
||||
this.userId$.pipe(
|
||||
switchMap(id => this.http.get(`/api/users/${id}`))
|
||||
).subscribe(user => this.user.set(user));
|
||||
```
|
||||
|
||||
**Why switchMap?** Automatically cancels previous HTTP request if new search term arrives.
|
||||
|
||||
### mergeMap (flatMap)
|
||||
|
||||
**Purpose:** Merge all inner observables
|
||||
|
||||
**Use when:** You want all requests to complete, not cancel previous
|
||||
|
||||
```typescript
|
||||
import { mergeMap } from 'rxjs/operators';
|
||||
|
||||
// Send analytics for each click (don't cancel)
|
||||
this.clicks$.pipe(
|
||||
mergeMap(event => this.analytics.track(event))
|
||||
).subscribe();
|
||||
|
||||
// Process multiple files in parallel
|
||||
this.files$.pipe(
|
||||
mergeMap(file => this.uploadFile(file))
|
||||
).subscribe(result => console.log('Uploaded:', result));
|
||||
```
|
||||
|
||||
### concatMap
|
||||
|
||||
**Purpose:** Process observables in order, wait for each to complete
|
||||
|
||||
**Use when:** Order matters (e.g., sequential API calls)
|
||||
|
||||
```typescript
|
||||
import { concatMap } from 'rxjs/operators';
|
||||
|
||||
// Process queue in order
|
||||
this.queue$.pipe(
|
||||
concatMap(task => this.processTask(task))
|
||||
).subscribe(result => console.log('Processed:', result));
|
||||
|
||||
// Sequential API calls
|
||||
this.users$.pipe(
|
||||
concatMap(user => this.http.post('/api/users', user))
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
### exhaustMap
|
||||
|
||||
**Purpose:** Ignore new values while current is processing
|
||||
|
||||
**Use when:** Prevent duplicate submissions
|
||||
|
||||
```typescript
|
||||
import { exhaustMap } from 'rxjs/operators';
|
||||
|
||||
// Prevent double-click on submit button
|
||||
this.submitClick$.pipe(
|
||||
exhaustMap(() => this.http.post('/api/form', this.formData))
|
||||
).subscribe();
|
||||
|
||||
// Login button (ignore clicks while logging in)
|
||||
this.loginAttempt$.pipe(
|
||||
exhaustMap(credentials => this.auth.login(credentials))
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Category 2: Filtering Operators
|
||||
|
||||
### filter
|
||||
|
||||
**Purpose:** Emit only values that pass a condition
|
||||
|
||||
```typescript
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
// Only even numbers
|
||||
of(1, 2, 3, 4, 5).pipe(
|
||||
filter(x => x % 2 === 0)
|
||||
).subscribe(console.log);
|
||||
// Output: 2, 4
|
||||
|
||||
// Only non-null users
|
||||
this.user$.pipe(
|
||||
filter(user => user !== null)
|
||||
).subscribe(user => console.log(user.name));
|
||||
|
||||
// Only valid emails
|
||||
this.emailInput$.pipe(
|
||||
filter(email => this.isValidEmail(email))
|
||||
).subscribe(email => this.checkAvailability(email));
|
||||
```
|
||||
|
||||
### debounceTime
|
||||
|
||||
**Purpose:** Wait for silence before emitting
|
||||
|
||||
**Use when:** Search input, window resize
|
||||
|
||||
```typescript
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
|
||||
// Wait 300ms after user stops typing
|
||||
this.searchInput$.pipe(
|
||||
debounceTime(300),
|
||||
switchMap(term => this.search(term))
|
||||
).subscribe(results => this.results.set(results));
|
||||
|
||||
// Window resize handler
|
||||
fromEvent(window, 'resize').pipe(
|
||||
debounceTime(200)
|
||||
).subscribe(() => this.handleResize());
|
||||
```
|
||||
|
||||
### throttleTime
|
||||
|
||||
**Purpose:** Emit first value, then ignore for duration
|
||||
|
||||
**Use when:** Scroll events, rapid clicks
|
||||
|
||||
```typescript
|
||||
import { throttleTime } from 'rxjs/operators';
|
||||
|
||||
// Handle scroll at most once per 100ms
|
||||
fromEvent(window, 'scroll').pipe(
|
||||
throttleTime(100)
|
||||
).subscribe(() => this.checkScrollPosition());
|
||||
|
||||
// Rate-limit button clicks
|
||||
this.buttonClick$.pipe(
|
||||
throttleTime(1000)
|
||||
).subscribe(() => this.handleClick());
|
||||
```
|
||||
|
||||
### distinctUntilChanged
|
||||
|
||||
**Purpose:** Only emit when value changes
|
||||
|
||||
```typescript
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
|
||||
// Only emit when search term actually changes
|
||||
this.searchInput$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap(term => this.search(term))
|
||||
).subscribe();
|
||||
|
||||
// Only emit when user ID changes
|
||||
this.userId$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap(id => this.loadUser(id))
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
### take / takeUntil
|
||||
|
||||
**Purpose:** Take specific number or until condition
|
||||
|
||||
```typescript
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
|
||||
// Take first 5 values
|
||||
this.stream$.pipe(
|
||||
take(5)
|
||||
).subscribe();
|
||||
|
||||
// Take until component destroyed
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
this.data$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(data => console.log(data));
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// Modern approach with takeUntilDestroyed
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
this.data$.pipe(
|
||||
takeUntilDestroyed()
|
||||
).subscribe(data => console.log(data));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Category 3: Combination Operators
|
||||
|
||||
### combineLatest
|
||||
|
||||
**Purpose:** Emit when ANY source emits (after all emit at least once)
|
||||
|
||||
**Use when:** Combining multiple form fields, filters
|
||||
|
||||
```typescript
|
||||
import { combineLatest } from 'rxjs';
|
||||
|
||||
// Wait for both user and settings to load
|
||||
combineLatest([
|
||||
this.user$,
|
||||
this.settings$
|
||||
]).pipe(
|
||||
map(([user, settings]) => ({ user, settings }))
|
||||
).subscribe(data => console.log(data));
|
||||
|
||||
// Combine multiple filters
|
||||
combineLatest([
|
||||
this.searchTerm$,
|
||||
this.category$,
|
||||
this.priceRange$
|
||||
]).pipe(
|
||||
map(([search, category, price]) => ({
|
||||
search, category, price
|
||||
})),
|
||||
switchMap(filters => this.fetchProducts(filters))
|
||||
).subscribe(products => this.products.set(products));
|
||||
```
|
||||
|
||||
### forkJoin
|
||||
|
||||
**Purpose:** Emit when ALL sources complete (like Promise.all)
|
||||
|
||||
**Use when:** Loading multiple independent resources
|
||||
|
||||
```typescript
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
// Load multiple resources on init
|
||||
forkJoin({
|
||||
user: this.http.get('/api/user'),
|
||||
config: this.http.get('/api/config'),
|
||||
permissions: this.http.get('/api/permissions')
|
||||
}).subscribe(({ user, config, permissions }) => {
|
||||
this.initialize(user, config, permissions);
|
||||
});
|
||||
|
||||
// Parallel API calls
|
||||
forkJoin([
|
||||
this.http.get('/api/products'),
|
||||
this.http.get('/api/categories'),
|
||||
this.http.get('/api/brands')
|
||||
]).subscribe(([products, categories, brands]) => {
|
||||
// All loaded
|
||||
});
|
||||
```
|
||||
|
||||
### merge
|
||||
|
||||
**Purpose:** Emit from any source as soon as it emits
|
||||
|
||||
**Use when:** Combining event streams
|
||||
|
||||
```typescript
|
||||
import { merge } from 'rxjs';
|
||||
|
||||
// Combine multiple event sources
|
||||
merge(
|
||||
this.clicks$,
|
||||
this.hovers$,
|
||||
this.focuses$
|
||||
).subscribe(event => this.trackEvent(event));
|
||||
|
||||
// Combine refresh triggers
|
||||
merge(
|
||||
this.manualRefresh$,
|
||||
this.autoRefresh$,
|
||||
this.dataChanged$
|
||||
).pipe(
|
||||
switchMap(() => this.loadData())
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
### withLatestFrom
|
||||
|
||||
**Purpose:** Combine with latest value from other observables
|
||||
|
||||
**Use when:** Need secondary data with primary stream
|
||||
|
||||
```typescript
|
||||
import { withLatestFrom } from 'rxjs/operators';
|
||||
|
||||
// Submit form with latest user data
|
||||
this.submitButton$.pipe(
|
||||
withLatestFrom(this.form$, this.user$),
|
||||
map(([_, formData, user]) => ({ formData, user }))
|
||||
).subscribe(({ formData, user }) => {
|
||||
this.submit(formData, user);
|
||||
});
|
||||
|
||||
// Apply filter with latest settings
|
||||
this.searchTerm$.pipe(
|
||||
withLatestFrom(this.filters$, this.sortOrder$),
|
||||
switchMap(([term, filters, sort]) =>
|
||||
this.search(term, filters, sort)
|
||||
)
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Category 4: Error Handling
|
||||
|
||||
### catchError
|
||||
|
||||
**Purpose:** Catch errors and return fallback observable
|
||||
|
||||
```typescript
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { of, EMPTY } from 'rxjs';
|
||||
|
||||
// Return empty array on error
|
||||
this.http.get('/api/data').pipe(
|
||||
catchError(error => {
|
||||
console.error('Failed to load:', error);
|
||||
return of([]); // Fallback value
|
||||
})
|
||||
).subscribe(data => console.log(data));
|
||||
|
||||
// Return empty observable (complete immediately)
|
||||
this.http.get('/api/data').pipe(
|
||||
catchError(() => EMPTY)
|
||||
).subscribe();
|
||||
|
||||
// Re-throw after logging
|
||||
this.http.get('/api/data').pipe(
|
||||
catchError(error => {
|
||||
console.error(error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
### retry
|
||||
|
||||
**Purpose:** Retry failed observable
|
||||
|
||||
```typescript
|
||||
import { retry } from 'rxjs/operators';
|
||||
|
||||
// Retry 3 times on failure
|
||||
this.http.get('/api/data').pipe(
|
||||
retry(3),
|
||||
catchError(error => {
|
||||
console.error('Failed after 3 retries');
|
||||
return of([]);
|
||||
})
|
||||
).subscribe();
|
||||
|
||||
// Retry with delay (RxJS 7+)
|
||||
import { retry } from 'rxjs/operators';
|
||||
|
||||
this.http.get('/api/data').pipe(
|
||||
retry({
|
||||
count: 3,
|
||||
delay: 1000 // Wait 1s between retries
|
||||
})
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Category 5: Utility Operators
|
||||
|
||||
### tap
|
||||
|
||||
**Purpose:** Perform side effects without modifying stream
|
||||
|
||||
```typescript
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
// Log for debugging
|
||||
this.http.get('/api/data').pipe(
|
||||
tap(data => console.log('Received:', data)),
|
||||
map(data => data.items),
|
||||
tap(items => console.log('Mapped:', items))
|
||||
).subscribe();
|
||||
|
||||
// Track analytics
|
||||
this.searchTerm$.pipe(
|
||||
tap(term => this.analytics.track('search', { term })),
|
||||
switchMap(term => this.search(term))
|
||||
).subscribe();
|
||||
|
||||
// Update loading state
|
||||
this.loadData().pipe(
|
||||
tap(() => this.loading.set(true)),
|
||||
finalize(() => this.loading.set(false))
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
### shareReplay
|
||||
|
||||
**Purpose:** Share observable and replay values to new subscribers
|
||||
|
||||
```typescript
|
||||
import { shareReplay } from 'rxjs/operators';
|
||||
|
||||
// Cache HTTP request
|
||||
private config$ = this.http.get('/api/config').pipe(
|
||||
shareReplay(1) // Cache last value
|
||||
);
|
||||
|
||||
// Multiple subscribers get same value
|
||||
this.config$.subscribe(config => console.log('Sub 1:', config));
|
||||
this.config$.subscribe(config => console.log('Sub 2:', config));
|
||||
// Only one HTTP request made!
|
||||
```
|
||||
|
||||
### finalize
|
||||
|
||||
**Purpose:** Execute code when observable completes or errors
|
||||
|
||||
```typescript
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
// Always hide loading spinner
|
||||
this.loadData().pipe(
|
||||
tap(() => this.loading.set(true)),
|
||||
finalize(() => this.loading.set(false))
|
||||
).subscribe({
|
||||
next: data => console.log(data),
|
||||
error: err => console.error(err)
|
||||
// loading.set(false) runs regardless
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Search with Debounce
|
||||
|
||||
```typescript
|
||||
this.searchControl.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
switchMap(term => this.http.get(`/api/search?q=${term}`)),
|
||||
catchError(() => of([]))
|
||||
).subscribe(results => this.results.set(results));
|
||||
```
|
||||
|
||||
### Pattern 2: Auto-Save
|
||||
|
||||
```typescript
|
||||
this.form.valueChanges.pipe(
|
||||
debounceTime(2000),
|
||||
distinctUntilChanged(),
|
||||
tap(() => this.saving.set(true)),
|
||||
switchMap(value => this.http.put('/api/save', value)),
|
||||
finalize(() => this.saving.set(false))
|
||||
).subscribe();
|
||||
```
|
||||
|
||||
### Pattern 3: Polling
|
||||
|
||||
```typescript
|
||||
import { interval, switchMap } from 'rxjs';
|
||||
|
||||
interval(5000).pipe(
|
||||
switchMap(() => this.http.get('/api/status')),
|
||||
catchError(() => of(null))
|
||||
).subscribe(status => this.status.set(status));
|
||||
```
|
||||
|
||||
### Pattern 4: Type-ahead with Minimum Length
|
||||
|
||||
```typescript
|
||||
this.searchInput$.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
filter(term => term.length >= 3),
|
||||
switchMap(term => this.search(term)),
|
||||
catchError(() => of([]))
|
||||
).subscribe(results => this.results.set(results));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
**Need to transform values?** → `map`
|
||||
**Need to switch to new observable?** → `switchMap`
|
||||
**Need to wait for all to complete?** → `forkJoin`
|
||||
**Need to combine latest values?** → `combineLatest`
|
||||
**Need to filter values?** → `filter`
|
||||
**Need to handle errors?** → `catchError`
|
||||
**Need to retry?** → `retry`
|
||||
**Need to share result?** → `shareReplay`
|
||||
**Need to debounce?** → `debounceTime`
|
||||
**Need to throttle?** → `throttleTime`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always unsubscribe** - Use `takeUntilDestroyed()` or `async` pipe
|
||||
2. **Prefer `async` pipe** over manual subscriptions
|
||||
3. **Use `switchMap`** for dependent HTTP requests
|
||||
4. **Use `forkJoin`** for parallel independent requests
|
||||
5. **Add error handling** with `catchError`
|
||||
6. **Cache with `shareReplay`** for expensive operations
|
||||
7. **Debounce user input** with `debounceTime`
|
||||
8. **Log with `tap`** for debugging
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**❌ Memory leaks:**
|
||||
```typescript
|
||||
// Bad - no unsubscribe
|
||||
this.data$.subscribe(data => console.log(data));
|
||||
```
|
||||
|
||||
**✅ Fixed:**
|
||||
```typescript
|
||||
// Good - auto unsubscribe
|
||||
this.data$.pipe(
|
||||
takeUntilDestroyed()
|
||||
).subscribe(data => console.log(data));
|
||||
|
||||
// Or use async pipe
|
||||
template: `{{ data$ | async }}`
|
||||
```
|
||||
|
||||
**❌ Nested subscriptions:**
|
||||
```typescript
|
||||
// Bad - pyramid of doom
|
||||
this.users$.subscribe(users => {
|
||||
this.http.get('/api/settings').subscribe(settings => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**✅ Fixed:**
|
||||
```typescript
|
||||
// Good - use switchMap
|
||||
this.users$.pipe(
|
||||
switchMap(users => this.http.get('/api/settings').pipe(
|
||||
map(settings => ({ users, settings }))
|
||||
))
|
||||
).subscribe(({ users, settings }) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Master these operators:
|
||||
- **Transformation**: `map`, `switchMap`, `mergeMap`
|
||||
- **Filtering**: `filter`, `debounceTime`, `distinctUntilChanged`
|
||||
- **Combination**: `combineLatest`, `forkJoin`, `withLatestFrom`
|
||||
- **Error Handling**: `catchError`, `retry`
|
||||
- **Utility**: `tap`, `shareReplay`, `take`, `takeUntil`
|
||||
|
||||
**Key Takeaway:** Choose the right operator for the job. `switchMap` for search, `forkJoin` for parallel loads, `combineLatest` for combining streams.
|
||||
626
skills/signals-patterns/SKILL.md
Normal file
626
skills/signals-patterns/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
||||
# Angular Signals Patterns
|
||||
|
||||
**Version:** Angular 16+
|
||||
**Status:** Stable (Developer Preview in 16, Stable in 17+)
|
||||
**Purpose:** Modern reactive state management without Zone.js overhead
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
**Signals** are Angular's modern approach to reactive state management. Unlike observables, signals are synchronous, glitch-free, and optimized for change detection.
|
||||
|
||||
**Key Benefits:**
|
||||
- ✅ Simpler mental model than RxJS
|
||||
- ✅ Better performance (no Zone.js overhead)
|
||||
- ✅ Fine-grained reactivity
|
||||
- ✅ Type-safe
|
||||
- ✅ Works seamlessly with OnPush change detection
|
||||
|
||||
---
|
||||
|
||||
## Signal Basics
|
||||
|
||||
### Creating Signals
|
||||
|
||||
```typescript
|
||||
import { signal, computed, effect } from '@angular/core';
|
||||
|
||||
// Writable signal
|
||||
const count = signal(0); // number signal
|
||||
const name = signal('John'); // string signal
|
||||
const user = signal<User | null>(null); // object signal with null
|
||||
|
||||
// Read value (call as function)
|
||||
console.log(count()); // 0
|
||||
console.log(name()); // "John"
|
||||
|
||||
// Write value
|
||||
count.set(5); // Set to exact value
|
||||
name.set('Jane');
|
||||
|
||||
// Update value (based on current)
|
||||
count.update(n => n + 1); // Increment
|
||||
```
|
||||
|
||||
### Computed Signals
|
||||
|
||||
Computed signals automatically recalculate when dependencies change:
|
||||
|
||||
```typescript
|
||||
const count = signal(0);
|
||||
|
||||
// Derived value
|
||||
const double = computed(() => count() * 2);
|
||||
const isEven = computed(() => count() % 2 === 0);
|
||||
const message = computed(() =>
|
||||
`Count is ${count()} and ${isEven() ? 'even' : 'odd'}`
|
||||
);
|
||||
|
||||
console.log(double()); // 0
|
||||
console.log(message()); // "Count is 0 and even"
|
||||
|
||||
count.set(3);
|
||||
|
||||
console.log(double()); // 6
|
||||
console.log(message()); // "Count is 3 and odd"
|
||||
```
|
||||
|
||||
### Effects
|
||||
|
||||
Effects run side effects when signals change:
|
||||
|
||||
```typescript
|
||||
import { effect } from '@angular/core';
|
||||
|
||||
const count = signal(0);
|
||||
|
||||
// Effect runs when count changes
|
||||
effect(() => {
|
||||
console.log(`Count changed to: ${count()}`);
|
||||
localStorage.setItem('count', count().toString());
|
||||
});
|
||||
|
||||
count.set(5); // Logs: "Count changed to: 5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Component State
|
||||
|
||||
### Basic Component State
|
||||
|
||||
```typescript
|
||||
import { Component, signal, computed } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-todo-list',
|
||||
standalone: true,
|
||||
template: `
|
||||
<input
|
||||
[value]="newTodo()"
|
||||
(input)="newTodo.set($any($event.target).value)"
|
||||
/>
|
||||
<button (click)="addTodo()">Add</button>
|
||||
|
||||
<p>Total: {{ total() }} | Active: {{ active() }} | Completed: {{ completed() }}</p>
|
||||
|
||||
@for (todo of todos(); track todo.id) {
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="todo.completed"
|
||||
(change)="toggle(todo.id)"
|
||||
/>
|
||||
<span [class.line-through]="todo.completed">
|
||||
{{ todo.text }}
|
||||
</span>
|
||||
<button (click)="remove(todo.id)">×</button>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class TodoListComponent {
|
||||
// State
|
||||
todos = signal<Todo[]>([]);
|
||||
newTodo = signal('');
|
||||
|
||||
// Computed
|
||||
total = computed(() => this.todos().length);
|
||||
active = computed(() => this.todos().filter(t => !t.completed).length);
|
||||
completed = computed(() => this.todos().filter(t => t.completed).length);
|
||||
|
||||
addTodo() {
|
||||
if (this.newTodo().trim()) {
|
||||
this.todos.update(todos => [
|
||||
...todos,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
text: this.newTodo(),
|
||||
completed: false
|
||||
}
|
||||
]);
|
||||
this.newTodo.set('');
|
||||
}
|
||||
}
|
||||
|
||||
toggle(id: string) {
|
||||
this.todos.update(todos =>
|
||||
todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
|
||||
);
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
this.todos.update(todos => todos.filter(t => t.id !== id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Derived State
|
||||
|
||||
### Computed from Multiple Signals
|
||||
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class ShoppingCartComponent {
|
||||
items = signal<CartItem[]>([]);
|
||||
taxRate = signal(0.08);
|
||||
shippingCost = signal(5.99);
|
||||
|
||||
// Computed from items
|
||||
subtotal = computed(() =>
|
||||
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
);
|
||||
|
||||
// Computed from subtotal and taxRate
|
||||
tax = computed(() => this.subtotal() * this.taxRate());
|
||||
|
||||
// Computed from multiple signals
|
||||
total = computed(() =>
|
||||
this.subtotal() + this.tax() + this.shippingCost()
|
||||
);
|
||||
|
||||
// Computed boolean
|
||||
hasItems = computed(() => this.items().length > 0);
|
||||
canCheckout = computed(() =>
|
||||
this.hasItems() && this.total() > 0
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Signal Arrays
|
||||
|
||||
### Immutable Array Updates
|
||||
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class ListComponent {
|
||||
items = signal<Item[]>([]);
|
||||
|
||||
// Add item
|
||||
addItem(item: Item) {
|
||||
this.items.update(current => [...current, item]);
|
||||
}
|
||||
|
||||
// Remove item
|
||||
removeItem(id: string) {
|
||||
this.items.update(current => current.filter(item => item.id !== id));
|
||||
}
|
||||
|
||||
// Update item
|
||||
updateItem(id: string, updates: Partial<Item>) {
|
||||
this.items.update(current =>
|
||||
current.map(item =>
|
||||
item.id === id ? { ...item, ...updates } : item
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort items
|
||||
sortBy(key: keyof Item) {
|
||||
this.items.update(current =>
|
||||
[...current].sort((a, b) => a[key] > b[key] ? 1 : -1)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter items
|
||||
filteredItems = computed(() =>
|
||||
this.items().filter(item => item.active)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Signal Objects
|
||||
|
||||
### Nested Object Updates
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
preferences: {
|
||||
theme: 'light' | 'dark';
|
||||
language: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Component({...})
|
||||
export class UserProfileComponent {
|
||||
user = signal<User>({
|
||||
id: '1',
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
preferences: {
|
||||
theme: 'light',
|
||||
language: 'en'
|
||||
}
|
||||
});
|
||||
|
||||
// Update top-level property
|
||||
updateName(name: string) {
|
||||
this.user.update(u => ({ ...u, name }));
|
||||
}
|
||||
|
||||
// Update nested property
|
||||
updateTheme(theme: 'light' | 'dark') {
|
||||
this.user.update(u => ({
|
||||
...u,
|
||||
preferences: {
|
||||
...u.preferences,
|
||||
theme
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Computed from nested property
|
||||
isDarkMode = computed(() => this.user().preferences.theme === 'dark');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Loading States
|
||||
|
||||
### Common Loading Pattern
|
||||
|
||||
```typescript
|
||||
interface LoadingState<T> {
|
||||
loading: boolean;
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Component({...})
|
||||
export class DataComponent {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
state = signal<LoadingState<Product[]>>({
|
||||
loading: false,
|
||||
data: null,
|
||||
error: null
|
||||
});
|
||||
|
||||
// Computed
|
||||
isLoading = computed(() => this.state().loading);
|
||||
hasError = computed(() => this.state().error !== null);
|
||||
hasData = computed(() => this.state().data !== null);
|
||||
products = computed(() => this.state().data ?? []);
|
||||
|
||||
loadData() {
|
||||
this.state.update(s => ({ ...s, loading: true, error: null }));
|
||||
|
||||
this.http.get<Product[]>('/api/products').subscribe({
|
||||
next: data => this.state.set({ loading: false, data, error: null }),
|
||||
error: err => this.state.set({ loading: false, data: null, error: err.message })
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6: Form State
|
||||
|
||||
### Signal-Based Form
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
selector: 'app-signup-form',
|
||||
template: `
|
||||
<form (submit)="handleSubmit($event)">
|
||||
<input
|
||||
type="email"
|
||||
[value]="email()"
|
||||
(input)="email.set($any($event.target).value)"
|
||||
[class.invalid]="emailError()"
|
||||
/>
|
||||
@if (emailError()) {
|
||||
<span class="error">{{ emailError() }}</span>
|
||||
}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
[value]="password()"
|
||||
(input)="password.set($any($event.target).value)"
|
||||
/>
|
||||
|
||||
<button [disabled]="!isValid()">Sign Up</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class SignupFormComponent {
|
||||
// Form fields
|
||||
email = signal('');
|
||||
password = signal('');
|
||||
|
||||
// Validation
|
||||
emailError = computed(() => {
|
||||
const value = this.email();
|
||||
if (!value) return 'Email is required';
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
passwordError = computed(() => {
|
||||
const value = this.password();
|
||||
if (!value) return 'Password is required';
|
||||
if (value.length < 8) return 'Minimum 8 characters';
|
||||
return null;
|
||||
});
|
||||
|
||||
isValid = computed(() =>
|
||||
!this.emailError() && !this.passwordError()
|
||||
);
|
||||
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
if (this.isValid()) {
|
||||
console.log({ email: this.email(), password: this.password() });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7: Signal Effects
|
||||
|
||||
### Side Effects with Cleanup
|
||||
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class AutoSaveComponent {
|
||||
content = signal('');
|
||||
|
||||
constructor() {
|
||||
// Auto-save effect
|
||||
effect(() => {
|
||||
const data = this.content();
|
||||
|
||||
if (data) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log('Auto-saving:', data);
|
||||
this.save(data);
|
||||
}, 1000);
|
||||
|
||||
// Cleanup function
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
// Log changes
|
||||
effect(() => {
|
||||
console.log('Content length:', this.content().length);
|
||||
});
|
||||
}
|
||||
|
||||
save(data: string) {
|
||||
localStorage.setItem('draft', data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8: Signals with RxJS
|
||||
|
||||
### Converting Between Signals and Observables
|
||||
|
||||
```typescript
|
||||
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { interval } from 'rxjs';
|
||||
|
||||
@Component({...})
|
||||
export class MixedComponent {
|
||||
// Observable to Signal
|
||||
private tick$ = interval(1000);
|
||||
tick = toSignal(this.tick$, { initialValue: 0 });
|
||||
|
||||
// Signal to Observable
|
||||
count = signal(0);
|
||||
count$ = toObservable(this.count);
|
||||
|
||||
constructor() {
|
||||
// Subscribe to signal as observable
|
||||
this.count$.subscribe(value => {
|
||||
console.log('Count changed:', value);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Combining Signals with HTTP
|
||||
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class UserComponent {
|
||||
private http = inject(HttpClient);
|
||||
userId = signal('123');
|
||||
|
||||
// Convert signal to observable, then fetch
|
||||
user = toSignal(
|
||||
toObservable(this.userId).pipe(
|
||||
switchMap(id => this.http.get<User>(`/api/users/${id}`))
|
||||
),
|
||||
{ initialValue: null }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 9: Global State with Signals
|
||||
|
||||
### Signal-Based Store
|
||||
|
||||
```typescript
|
||||
// store.service.ts
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export interface AppState {
|
||||
user: User | null;
|
||||
theme: 'light' | 'dark';
|
||||
notifications: Notification[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Store {
|
||||
// Private state
|
||||
private state = signal<AppState>({
|
||||
user: null,
|
||||
theme: 'light',
|
||||
notifications: []
|
||||
});
|
||||
|
||||
// Public selectors
|
||||
user = computed(() => this.state().user);
|
||||
theme = computed(() => this.state().theme);
|
||||
notifications = computed(() => this.state().notifications);
|
||||
unreadCount = computed(() =>
|
||||
this.state().notifications.filter(n => !n.read).length
|
||||
);
|
||||
|
||||
// Actions
|
||||
setUser(user: User | null) {
|
||||
this.state.update(s => ({ ...s, user }));
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
this.state.update(s => ({
|
||||
...s,
|
||||
theme: s.theme === 'light' ? 'dark' : 'light'
|
||||
}));
|
||||
}
|
||||
|
||||
addNotification(notification: Notification) {
|
||||
this.state.update(s => ({
|
||||
...s,
|
||||
notifications: [...s.notifications, notification]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
@Component({...})
|
||||
export class AppComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
user = this.store.user;
|
||||
theme = this.store.theme;
|
||||
unreadCount = this.store.unreadCount;
|
||||
|
||||
logout() {
|
||||
this.store.setUser(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
1. **Use signals for synchronous state**
|
||||
2. **Prefer computed over manual updates**
|
||||
3. **Keep signals immutable** - always create new objects/arrays
|
||||
4. **Use effects for side effects only**
|
||||
5. **Combine with OnPush** change detection
|
||||
6. **Use toSignal for observables** in components
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
1. **Don't mutate signal values directly**
|
||||
```typescript
|
||||
// ❌ Bad
|
||||
items().push(newItem);
|
||||
|
||||
// ✅ Good
|
||||
items.update(current => [...current, newItem]);
|
||||
```
|
||||
|
||||
2. **Don't use effects for derived state**
|
||||
```typescript
|
||||
// ❌ Bad - Use computed instead
|
||||
const count = signal(0);
|
||||
const double = signal(0);
|
||||
effect(() => double.set(count() * 2));
|
||||
|
||||
// ✅ Good
|
||||
const double = computed(() => count() * 2);
|
||||
```
|
||||
|
||||
3. **Don't create signals in loops**
|
||||
4. **Don't read signals in constructors** (use ngOnInit or effects)
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Computed signals are cached** - only recalculate when dependencies change
|
||||
2. **Signals trigger change detection only when value changes**
|
||||
3. **Use OnPush** change detection with signals for best performance
|
||||
4. **Signals are more efficient than observables** for synchronous state
|
||||
|
||||
---
|
||||
|
||||
## When to Use Signals vs RxJS
|
||||
|
||||
**Use Signals for:**
|
||||
- Local component state
|
||||
- Derived/computed values
|
||||
- Form state
|
||||
- UI state (loading, errors)
|
||||
|
||||
**Use RxJS for:**
|
||||
- Asynchronous operations
|
||||
- HTTP requests
|
||||
- WebSocket streams
|
||||
- Time-based operations (debounce, throttle)
|
||||
- Complex async flows
|
||||
|
||||
**Use Both:**
|
||||
- Convert observables to signals with `toSignal()`
|
||||
- Convert signals to observables with `toObservable()`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Signals provide:
|
||||
- ✅ Simpler reactive state management
|
||||
- ✅ Better performance
|
||||
- ✅ Type safety
|
||||
- ✅ Fine-grained reactivity
|
||||
- ✅ Seamless integration with Angular
|
||||
|
||||
**Key Takeaway:** Signals are the future of Angular state management. Use them for synchronous state, computed values, and reactive UI updates.
|
||||
Reference in New Issue
Block a user