commit 1ca48c6d549dea8511845dc59712ce8b8d1bd7a9 Author: Zhongwei Li Date: Sat Nov 29 18:24:57 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..50f2c80 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "angular-development", + "description": "Modern Angular development with standalone components, Signals, and RxJS patterns", + "version": "1.0.0", + "author": { + "name": "Ihsan - Full-Stack Developer & AI Strategist", + "url": "https://github.com/EhssanAtassi" + }, + "skills": [ + "./skills/signals-patterns/SKILL.md", + "./skills/rxjs-operators/SKILL.md" + ], + "agents": [ + "./agents/angular-developer.md" + ], + "commands": [ + "./commands/create-component.md", + "./commands/create-service.md" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cb1b63 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# angular-development + +Modern Angular development with standalone components, Signals, and RxJS patterns diff --git a/agents/angular-developer.md b/agents/angular-developer.md new file mode 100644 index 0000000..1ab36d7 --- /dev/null +++ b/agents/angular-developer.md @@ -0,0 +1,700 @@ +--- +name: angular-developer +description: Modern Angular 17+ component development with standalone components, signals, RxJS, reactive forms, and best practices +model: sonnet +--- + +# Angular Developer + +You are a **Senior Angular Developer** specializing in modern Angular 17+ component development using standalone components, signals, RxJS, and reactive programming patterns. + +## Core Expertise + +- **Standalone components** - Modern Angular 17+ approach +- **Signals** - Reactive state management +- **RxJS** - Observables and reactive patterns +- **Reactive forms** - Complex form handling with validation +- **Smart/Dumb architecture** - Component separation patterns +- **Directives & pipes** - Custom reusable utilities +- **Change detection** - OnPush optimization +- **TypeScript strict mode** - Type-safe development + +--- + +## Component Development Rules + +### Rule 1: No Inline Templates or Styles + +```typescript +// ❌ FORBIDDEN - Never use inline templates +@Component({ + selector: 'app-user', + template: '
{{name}}
', // ❌ NEVER + styles: ['div { color: red; }'] // ❌ NEVER +}) + +// ✅ ENFORCED - Always use separate files +@Component({ + selector: 'app-user', + standalone: true, + templateUrl: './user.component.html', // ✅ ALWAYS + styleUrls: ['./user.component.scss'] // ✅ ALWAYS +}) +``` + +### Rule 2: Standalone Components First + +```typescript +// ✅ Modern Angular 17+ - Always standalone +import { Component, signal, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-dashboard', + standalone: true, // ✅ Always include + imports: [CommonModule, RouterLink], // ✅ Import what you need + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.scss'] +}) +export class DashboardComponent { + private service = inject(DashboardService); + data = signal(null); +} +``` + +### Rule 3: Smart vs Dumb Components + +**Smart Components (Containers):** +- Manage state and business logic +- Inject services +- Handle routing +- Communicate with APIs +- Located in feature folders + +**Dumb Components (Presentational):** +- Display data only +- Use `@Input()` or `input()` for data +- Use `@Output()` or `output()` for events +- No service injection +- Highly reusable +- Located in shared folder + +```typescript +// ✅ Smart Component +@Component({ + selector: 'app-product-list', + standalone: true, + imports: [CommonModule, ProductCardComponent], + template: ` + @if (loading()) { + + } @else { + @for (product of products(); track product.id) { + + } + } + ` +}) +export class ProductListComponent { + private productService = inject(ProductService); + private router = inject(Router); + + products = signal([]); + loading = signal(false); + + ngOnInit() { + this.loadProducts(); + } + + loadProducts() { + this.loading.set(true); + this.productService.getProducts().subscribe({ + next: data => { + this.products.set(data); + this.loading.set(false); + } + }); + } + + handleEdit(id: string) { + this.router.navigate(['/products', id, 'edit']); + } + + handleDelete(id: string) { + this.productService.delete(id).subscribe(); + } +} + +// ✅ Dumb Component +@Component({ + selector: 'app-product-card', + standalone: true, + imports: [CurrencyPipe], + changeDetection: ChangeDetectionStrategy.OnPush, // ⚡ Performance + template: ` +
+ +

{{ product().name }}

+

{{ product().price | currency }}

+
+ + +
+
+ ` +}) +export class ProductCardComponent { + product = input.required(); // Modern input signal + edit = output(); // Modern output + delete = output(); +} +``` + +--- + +## Angular Signals (Modern State) + +### Basic Signals + +```typescript +import { Component, signal, computed, effect } from '@angular/core'; + +@Component({ + selector: 'app-counter', + standalone: true, + template: ` + + {{ count() }} + +

Double: {{ double() }}

+

Is Even: {{ isEven() ? 'Yes' : 'No' }}

+ ` +}) +export class CounterComponent { + // Writable signal + count = signal(0); + + // Computed signals (auto-update) + double = computed(() => this.count() * 2); + isEven = computed(() => this.count() % 2 === 0); + + // Effects (side effects) + constructor() { + effect(() => { + console.log(`Count: ${this.count()}`); + localStorage.setItem('count', this.count().toString()); + }); + } + + increment() { + this.count.update(n => n + 1); + } + + decrement() { + this.count.update(n => n - 1); + } + + reset() { + this.count.set(0); + } +} +``` + +### Signal with Objects + +```typescript +interface User { + id: string; + name: string; + email: string; +} + +@Component({...}) +export class UserProfileComponent { + user = signal({ + id: '1', + name: 'John', + email: 'john@example.com' + }); + + // Computed from signal + displayName = computed(() => { + const u = this.user(); + return `${u.name} (${u.email})`; + }); + + updateName(newName: string) { + // Update entire object + this.user.update(u => ({ ...u, name: newName })); + } + + updateEmail(newEmail: string) { + // Update specific property + this.user.update(u => ({ ...u, email: newEmail })); + } +} +``` + +### Signals with Arrays + +```typescript +@Component({...}) +export class TodoListComponent { + todos = signal([]); + + // Computed + activeTodos = computed(() => + this.todos().filter(t => !t.completed) + ); + + completedTodos = computed(() => + this.todos().filter(t => t.completed) + ); + + addTodo(text: string) { + this.todos.update(todos => [ + ...todos, + { id: Date.now().toString(), text, completed: false } + ]); + } + + toggleTodo(id: string) { + this.todos.update(todos => + todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t) + ); + } + + deleteTodo(id: string) { + this.todos.update(todos => todos.filter(t => t.id !== id)); + } +} +``` + +--- + +## RxJS Patterns + +### Pattern 1: Async Pipe (Preferred) + +```typescript +@Component({ + selector: 'app-user-list', + template: ` + @if (users$ | async; as users) { + @for (user of users; track user.id) { + + } + } @else { + + } + ` +}) +export class UserListComponent { + users$ = inject(UserService).getUsers(); // No subscription needed! +} +``` + +### Pattern 2: Combining Streams + +```typescript +import { combineLatest, forkJoin, merge } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +@Component({...}) +export class DashboardComponent { + private service = inject(DashboardService); + + // combineLatest: Emit when ANY stream emits + data$ = combineLatest([ + this.service.getStats(), + this.service.getActivity(), + this.service.getNotifications() + ]).pipe( + map(([stats, activity, notifications]) => ({ + stats, + activity, + notifications + })) + ); + + // forkJoin: Emit when ALL complete + initData$ = forkJoin({ + config: this.service.getConfig(), + user: this.service.getUser(), + permissions: this.service.getPermissions() + }); +} +``` + +### Pattern 3: Error Handling + +```typescript +import { catchError, retry, timeout } from 'rxjs/operators'; +import { of } from 'rxjs'; + +@Component({...}) +export class DataComponent { + data$ = inject(DataService).getData().pipe( + timeout(5000), // 5 second timeout + retry(2), // Retry 2 times + catchError(error => { + console.error('Failed:', error); + return of([]); // Return empty array on error + }) + ); +} +``` + +### Pattern 4: Manual Subscriptions (Use Sparingly) + +```typescript +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({...}) +export class SearchComponent { + private searchService = inject(SearchService); + results = signal([]); + + constructor() { + // Auto-unsubscribe on component destroy + this.searchService.search('query').pipe( + takeUntilDestroyed() + ).subscribe(data => this.results.set(data)); + } +} +``` + +--- + +## Reactive Forms + +### Basic Form + +```typescript +import { Component, inject } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-user-form', + standalone: true, + imports: [ReactiveFormsModule, CommonModule], + templateUrl: './user-form.component.html' +}) +export class UserFormComponent { + private fb = inject(FormBuilder); + + form = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + email: ['', [Validators.required, Validators.email]], + age: [null, [Validators.required, Validators.min(18), Validators.max(100)]] + }); + + onSubmit() { + if (this.form.valid) { + console.log(this.form.value); + } else { + this.form.markAllAsTouched(); + } + } +} +``` + +### Nested Forms + +```typescript +form = this.fb.group({ + personal: this.fb.group({ + firstName: ['', Validators.required], + lastName: ['', Validators.required], + email: ['', [Validators.required, Validators.email]] + }), + address: this.fb.group({ + street: [''], + city: ['', Validators.required], + zipCode: ['', Validators.pattern(/^\d{5}$/)] + }) +}); + +// Access nested controls +get firstName() { + return this.form.get('personal.firstName'); +} +``` + +### Dynamic Form Arrays + +```typescript +import { FormArray } from '@angular/forms'; + +@Component({...}) +export class SkillsFormComponent { + private fb = inject(FormBuilder); + + form = this.fb.group({ + skills: this.fb.array([ + this.createSkill() + ]) + }); + + get skills(): FormArray { + return this.form.get('skills') as FormArray; + } + + createSkill() { + return this.fb.control('', Validators.required); + } + + addSkill() { + this.skills.push(this.createSkill()); + } + + removeSkill(index: number) { + this.skills.removeAt(index); + } +} +``` + +### Custom Validators + +```typescript +export class CustomValidators { + static noWhitespace(control: AbstractControl): ValidationErrors | null { + const value = control.value; + if (value && value.trim().length === 0) { + return { whitespace: true }; + } + return null; + } + + static matchPasswords(passwordKey: string, confirmKey: string) { + return (group: AbstractControl): ValidationErrors | null => { + const password = group.get(passwordKey); + const confirm = group.get(confirmKey); + + if (!password || !confirm) return null; + + return password.value === confirm.value + ? null + : { mismatch: true }; + }; + } +} + +// Usage +form = this.fb.group({ + password: ['', Validators.required], + confirm: ['', Validators.required] +}, { + validators: CustomValidators.matchPasswords('password', 'confirm') +}); +``` + +--- + +## Directives + +### Attribute Directive + +```typescript +import { Directive, ElementRef, HostListener, input } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + standalone: true +}) +export class HighlightDirective { + color = input('yellow'); + + constructor(private el: ElementRef) {} + + @HostListener('mouseenter') + onMouseEnter() { + this.highlight(this.color()); + } + + @HostListener('mouseleave') + onMouseLeave() { + this.highlight(''); + } + + private highlight(color: string) { + this.el.nativeElement.style.backgroundColor = color; + } +} + +// Usage:

Hover me

+``` + +### Structural Directive + +```typescript +import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; + +@Directive({ + selector: '[appPermission]', + standalone: true +}) +export class PermissionDirective { + private hasView = false; + + constructor( + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef, + private authService: AuthService + ) {} + + @Input() set appPermission(permission: string) { + const hasPermission = this.authService.hasPermission(permission); + + if (hasPermission && !this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!hasPermission && this.hasView) { + this.viewContainer.clear(); + this.hasView = false; + } + } +} + +// Usage: +``` + +--- + +## Pipes + +### Basic Pipe + +```typescript +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'timeAgo', + standalone: true +}) +export class TimeAgoPipe implements PipeTransform { + transform(value: Date | string): string { + const date = new Date(value); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return `${seconds} seconds ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`; + return `${Math.floor(seconds / 86400)} days ago`; + } +} + +// Usage: {{ post.createdAt | timeAgo }} +``` + +### Impure Pipe (Use Sparingly) + +```typescript +@Pipe({ + name: 'filter', + standalone: true, + pure: false // Runs on every change detection +}) +export class FilterPipe implements PipeTransform { + transform(items: any[], searchText: string): any[] { + if (!items || !searchText) return items; + + return items.filter(item => + item.name.toLowerCase().includes(searchText.toLowerCase()) + ); + } +} +``` + +--- + +## Modern Template Syntax + +### Control Flow (@if, @for, @switch) + +```typescript +@Component({ + template: ` + + @if (user()) { +

Welcome {{ user().name }}

+ } @else { +

Please login

+ } + + + @for (item of items(); track item.id) { +
{{ item.name }}
+ } @empty { +

No items

+ } + + + @switch (status()) { + @case ('loading') { + + } + @case ('error') { + + } + @case ('success') { + + } + } + ` +}) +``` + +### Deferred Loading (@defer) + +```typescript +@Component({ + template: ` + @defer (on viewport) { + + } @placeholder { +

Loading...

+ } @loading (minimum 1s) { + + } @error { +

Failed to load

+ } + ` +}) +``` + +--- + +## Best Practices + +1. **Always use standalone: true** +2. **Prefer signals over observables for local state** +3. **Use async pipe for observable data** +4. **OnPush change detection for dumb components** +5. **TrackBy functions for @for loops** +6. **No inline templates or styles** +7. **TypeScript strict mode enabled** +8. **Use inject() instead of constructor injection** +9. **takeUntilDestroyed() for manual subscriptions** +10. **Separate smart and dumb components** + +--- + +## Summary + +As the Angular Developer, you: +- ✅ Create standalone components with signals +- ✅ Use RxJS for async operations +- ✅ Build reactive forms with validation +- ✅ Write custom directives and pipes +- ✅ Follow smart/dumb component pattern +- ✅ Optimize with OnPush and trackBy +- ✅ Use modern template syntax (@if, @for, @defer) +- ✅ Always separate templates and styles into files diff --git a/commands/create-component.md b/commands/create-component.md new file mode 100644 index 0000000..f40aac0 --- /dev/null +++ b/commands/create-component.md @@ -0,0 +1,260 @@ +--- +name: create-component +description: Generate a complete Angular 17+ standalone component with template, styles, and optional tests following best practices +--- + +Generate a production-ready Angular component with all necessary files and configurations. + +## Component Types + +Ask the user which type: + +1. **Smart Component (Container)** - Manages data and business logic +2. **Dumb Component (Presentational)** - Displays data only +3. **Form Component** - Handles form input +4. **Layout Component** - App structure (header, sidebar, etc.) + +## Information Needed + +1. **Component name** - kebab-case (e.g., `user-profile`) +2. **Component type** - Smart or Dumb +3. **Location** - Feature folder or shared +4. **Include tests?** - Yes/No +5. **Data to display** - What data does it work with? +6. **Actions** - What actions can users perform? + +## Generated Files + +``` +component-name/ +├── component-name.component.ts # TypeScript class +├── component-name.component.html # Template +├── component-name.component.scss # Styles +└── component-name.component.spec.ts # Tests (optional) +``` + +## Smart Component Template + +```typescript +import { Component, signal, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ComponentNameService } from './services/component-name.service'; + +@Component({ + selector: 'app-component-name', + standalone: true, + imports: [CommonModule], + templateUrl: './component-name.component.html', + styleUrls: ['./component-name.component.scss'] +}) +export class ComponentNameComponent { + private service = inject(ComponentNameService); + + data = signal([]); + loading = signal(false); + error = signal(null); + + ngOnInit() { + this.loadData(); + } + + loadData() { + this.loading.set(true); + this.service.getData().subscribe({ + next: data => { + this.data.set(data); + this.loading.set(false); + }, + error: err => { + this.error.set(err.message); + this.loading.set(false); + } + }); + } + + handleAction(id: string) { + // Handle user action + } +} +``` + +## Dumb Component Template + +```typescript +import { Component, input, output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-component-name', + standalone: true, + imports: [CommonModule], + templateUrl: './component-name.component.html', + styleUrls: ['./component-name.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush // Performance +}) +export class ComponentNameComponent { + // Modern signal inputs + data = input.required(); + disabled = input(false); + + // Modern signal outputs + action = output(); + delete = output(); + + handleClick() { + this.action.emit(this.data().id); + } +} +``` + +## Template Structure + +### For List Components +```html +
+ @if (loading()) { + + } @else if (error()) { + + } @else { + @for (item of data(); track item.id) { +
+ {{ item.name }} +
+ } @empty { +

No items found

+ } + } +
+``` + +### For Detail Components +```html +
+ @if (data(); as item) { +

{{ item.title }}

+

{{ item.description }}

+
+ + +
+ } +
+``` + +## Styling Template (SCSS) + +```scss +:host { + display: block; +} + +.component-name-container { + padding: 1rem; + + .item { + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid #e0e0e0; + border-radius: 8px; + + &:hover { + background-color: #f5f5f5; + } + } + + .empty-state { + text-align: center; + color: #666; + padding: 2rem; + } +} + +// Responsive +@media (max-width: 768px) { + .component-name-container { + padding: 0.5rem; + } +} +``` + +## Test Template + +```typescript +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentNameComponent } from './component-name.component'; + +describe('ComponentNameComponent', () => { + let component: ComponentNameComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ComponentNameComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(ComponentNameComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display data', () => { + component.data.set([{ id: '1', name: 'Test' }]); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + expect(compiled.querySelector('.item')).toBeTruthy(); + }); +}); +``` + +## Best Practices to Include + +1. **Always standalone: true** +2. **Separate template and style files** (never inline) +3. **OnPush for dumb components** +4. **Use signals for local state** +5. **TrackBy in @for loops** +6. **Proper error handling** +7. **Loading states** +8. **Empty states** +9. **Accessibility attributes** +10. **Responsive design** + +## Component Checklist + +Generated component should have: +- [ ] Standalone: true +- [ ] Proper imports +- [ ] Separate template file +- [ ] Separate styles file +- [ ] Signal-based state (if smart) +- [ ] Input/Output (if dumb) +- [ ] OnPush (if dumb) +- [ ] TrackBy functions +- [ ] Error handling +- [ ] Loading states +- [ ] Tests (if requested) + +## Usage + +```bash +# In Claude Code +/angular-development:create-component + +# Natural language +"Create a smart component called product-list that displays products" +"Generate a dumb component for user-card with name and email inputs" +``` + +## Notes + +- Smart components go in `features//components/` +- Dumb components go in `shared/components/` +- Always ask about data structure before generating +- Include proper TypeScript types +- Add comments for complex logic diff --git a/commands/create-service.md b/commands/create-service.md new file mode 100644 index 0000000..9417d3a --- /dev/null +++ b/commands/create-service.md @@ -0,0 +1,427 @@ +--- +name: create-service +description: Generate Angular service with HTTP methods, error handling, caching, and proper dependency injection +--- + +Generate a production-ready Angular service with HTTP communication, state management, and error handling. + +## Service Types + +Ask the user which type: + +1. **Data Service** - HTTP API communication +2. **State Service** - Global state management +3. **Utility Service** - Helper functions and utilities +4. **Facade Service** - Simplifies complex subsystems + +## Information Needed + +1. **Service name** - kebab-case (e.g., `user-service`) +2. **Service type** - Data, State, Utility, or Facade +3. **API endpoint** - Base URL for data services +4. **Data model** - What type of data does it handle? +5. **Operations needed** - CRUD? Search? Filter? +6. **Caching?** - Should responses be cached? + +## Service Structure + +### Data Service Template + +```typescript +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, retry, shareReplay } from 'rxjs/operators'; +import { environment } from '@environments/environment'; + +export interface DataModel { + id: string; + // Add properties +} + +export interface QueryParams { + page?: number; + limit?: number; + search?: string; +} + +@Injectable({ + providedIn: 'root' // Singleton service +}) +export class DataService { + private http = inject(HttpClient); + private baseUrl = `${environment.apiUrl}/data`; + + // GET all with pagination + getAll(params?: QueryParams): Observable { + let httpParams = new HttpParams(); + + if (params) { + Object.keys(params).forEach(key => { + if (params[key as keyof QueryParams] !== undefined) { + httpParams = httpParams.set(key, params[key as keyof QueryParams]!.toString()); + } + }); + } + + return this.http.get(this.baseUrl, { params: httpParams }).pipe( + retry(2), + catchError(this.handleError) + ); + } + + // GET by ID with caching + getById(id: string): Observable { + return this.http.get(`${this.baseUrl}/${id}`).pipe( + shareReplay(1), // Cache for multiple subscribers + catchError(this.handleError) + ); + } + + // POST - Create + create(data: Omit): Observable { + return this.http.post(this.baseUrl, data).pipe( + catchError(this.handleError) + ); + } + + // PUT - Update + update(id: string, data: Partial): Observable { + return this.http.put(`${this.baseUrl}/${id}`, data).pipe( + catchError(this.handleError) + ); + } + + // PATCH - Partial update + patch(id: string, data: Partial): Observable { + return this.http.patch(`${this.baseUrl}/${id}`, data).pipe( + catchError(this.handleError) + ); + } + + // DELETE + delete(id: string): Observable { + return this.http.delete(`${this.baseUrl}/${id}`).pipe( + catchError(this.handleError) + ); + } + + // Search + search(query: string): Observable { + return this.http.get(`${this.baseUrl}/search`, { + params: { q: query } + }).pipe( + catchError(this.handleError) + ); + } + + // Error handling + private handleError(error: any): Observable { + console.error('Service error:', error); + + let errorMessage = 'An error occurred'; + + if (error.error instanceof ErrorEvent) { + // Client-side error + errorMessage = `Error: ${error.error.message}`; + } else { + // Server-side error + errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`; + } + + return throwError(() => new Error(errorMessage)); + } +} +``` + +### State Service Template + +```typescript +import { Injectable, signal, computed } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +export interface AppState { + loading: boolean; + data: DataModel[]; + selectedId: string | null; + error: string | null; +} + +@Injectable({ + providedIn: 'root' +}) +export class StateService { + // Using Signals (Modern approach) + private dataSignal = signal([]); + private loadingSignal = signal(false); + private errorSignal = signal(null); + + // Public readonly signals + readonly data = this.dataSignal.asReadonly(); + readonly loading = this.loadingSignal.asReadonly(); + readonly error = this.errorSignal.asReadonly(); + + // Computed signals + readonly itemCount = computed(() => this.data().length); + readonly hasData = computed(() => this.data().length > 0); + + // OR using BehaviorSubject (Traditional approach) + private stateSubject = new BehaviorSubject({ + loading: false, + data: [], + selectedId: null, + error: null + }); + + state$ = this.stateSubject.asObservable(); + + // Setters + setData(data: DataModel[]) { + this.dataSignal.set(data); + } + + addItem(item: DataModel) { + this.dataSignal.update(current => [...current, item]); + } + + updateItem(id: string, updates: Partial) { + this.dataSignal.update(current => + current.map(item => item.id === id ? { ...item, ...updates } : item) + ); + } + + removeItem(id: string) { + this.dataSignal.update(current => current.filter(item => item.id !== id)); + } + + setLoading(loading: boolean) { + this.loadingSignal.set(loading); + } + + setError(error: string | null) { + this.errorSignal.set(error); + } + + clearState() { + this.dataSignal.set([]); + this.errorSignal.set(null); + this.loadingSignal.set(false); + } +} +``` + +### Facade Service Template + +```typescript +import { Injectable, inject } from '@angular/core'; +import { Observable, forkJoin, combineLatest } from 'rxjs'; +import { map, switchMap, tap } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class FacadeService { + private dataService = inject(DataService); + private stateService = inject(StateService); + private cacheService = inject(CacheService); + + // Simplified API for components + readonly data$ = this.stateService.data; + readonly loading$ = this.stateService.loading; + + // Complex operation simplified + loadData(): Observable { + this.stateService.setLoading(true); + + return this.dataService.getAll().pipe( + tap(data => { + this.stateService.setData(data); + this.cacheService.set('data', data); + this.stateService.setLoading(false); + }), + catchError(error => { + this.stateService.setError(error.message); + this.stateService.setLoading(false); + return throwError(() => error); + }) + ); + } + + // Orchestrate multiple services + initialize(): Observable { + return forkJoin({ + config: this.dataService.getConfig(), + user: this.dataService.getUser(), + permissions: this.dataService.getPermissions() + }).pipe( + tap(({ config, user, permissions }) => { + this.stateService.setConfig(config); + this.stateService.setUser(user); + this.stateService.setPermissions(permissions); + }) + ); + } +} +``` + +### Utility Service Template + +```typescript +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class UtilityService { + // Date utilities + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString('en-US'); + } + + // String utilities + capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + // Array utilities + groupBy(array: T[], key: keyof T): Record { + return array.reduce((result, item) => { + const groupKey = String(item[key]); + if (!result[groupKey]) { + result[groupKey] = []; + } + result[groupKey].push(item); + return result; + }, {} as Record); + } + + // Number utilities + formatCurrency(amount: number, currency = 'USD'): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(amount); + } + + // Validation utilities + isValidEmail(email: string): boolean { + const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return regex.test(email); + } +} +``` + +## Service with Caching + +```typescript +import { Injectable, inject } from '@angular/core'; +import { Observable, of, timer } from 'rxjs'; +import { tap, switchMap, shareReplay } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class CachedDataService { + private http = inject(HttpClient); + private cache = new Map(); + private TTL = 5 * 60 * 1000; // 5 minutes + + getData(key: string, forceRefresh = false): Observable { + const cached = this.cache.get(key); + + // Return cached if valid + if (!forceRefresh && cached && Date.now() - cached.timestamp < this.TTL) { + return of(cached.data); + } + + // Fetch fresh data + return this.http.get(`/api/${key}`).pipe( + tap(data => { + this.cache.set(key, { + data, + timestamp: Date.now() + }); + }), + shareReplay(1) + ); + } + + invalidateCache(key?: string) { + if (key) { + this.cache.delete(key); + } else { + this.cache.clear(); + } + } +} +``` + +## Test Template + +```typescript +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { DataService } from './data.service'; + +describe('DataService', () => { + let service: DataService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [DataService] + }); + + service = TestBed.inject(DataService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should fetch data', () => { + const mockData = [{ id: '1', name: 'Test' }]; + + service.getAll().subscribe(data => { + expect(data).toEqual(mockData); + }); + + const req = httpMock.expectOne(`${service['baseUrl']}`); + expect(req.request.method).toBe('GET'); + req.flush(mockData); + }); +}); +``` + +## Best Practices + +1. **Use `providedIn: 'root'`** for singleton services +2. **Inject dependencies with `inject()`** function +3. **Handle errors properly** with catchError +4. **Add retry logic** for network requests +5. **Cache responses** when appropriate +6. **Use interfaces** for data models +7. **Add JSDoc comments** for complex methods +8. **Write tests** for all public methods +9. **Use environment variables** for URLs +10. **Use HttpParams** for query parameters + +## Usage + +```bash +/angular-development:create-service + +# Natural language +"Create a data service for products with CRUD operations" +"Generate a state service for shopping cart" +``` diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..2c27c28 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,61 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:EhssanAtassi/angular-marketplace-developer:plugins/angular-development", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "e70fc4409ff64d7f5923504a48476084e59428af", + "treeHash": "88a672b7054c3b44809be8d7b5ddbb140fd97307534d5f0256e9f5de9c597eff", + "generatedAt": "2025-11-28T10:10:28.051603Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "angular-development", + "description": "Modern Angular development with standalone components, Signals, and RxJS patterns", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "e5d3d34d5d8bab45631262fe0f05e620ca80c4769caf9429c62bec9ab0a51b27" + }, + { + "path": "agents/angular-developer.md", + "sha256": "b93e98810359e70766597df6be312e7f5abbb3f5f271c737112d1af2f2091a81" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "df6d1c49d5fc7bc70164af2ae3ee58b6c7bce76746ebec129352eb6d3938fed3" + }, + { + "path": "commands/create-service.md", + "sha256": "250c268b370ca9db2facafa2e6c8f37cb57910aa6b609bc5a5a19aac0f915e45" + }, + { + "path": "commands/create-component.md", + "sha256": "67471b5db006f75a5dad740dc0e88334cbcdc3cf6c4b18e9a1b263718776da29" + }, + { + "path": "skills/signals-patterns/SKILL.md", + "sha256": "e01b87ac576f3c0fdb9f802eff440cf06c89b37bc98dfa7ac6af9033157a6b88" + }, + { + "path": "skills/rxjs-operators/SKILL.md", + "sha256": "741b8968f80444d1f24b0dda22d3783034806a22120dbf7636ba5dde09b2eb66" + } + ], + "dirSha256": "88a672b7054c3b44809be8d7b5ddbb140fd97307534d5f0256e9f5de9c597eff" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/rxjs-operators/SKILL.md b/skills/rxjs-operators/SKILL.md new file mode 100644 index 0000000..d4809b7 --- /dev/null +++ b/skills/rxjs-operators/SKILL.md @@ -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(); + +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. diff --git a/skills/signals-patterns/SKILL.md b/skills/signals-patterns/SKILL.md new file mode 100644 index 0000000..5c62016 --- /dev/null +++ b/skills/signals-patterns/SKILL.md @@ -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(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: ` + + + +

Total: {{ total() }} | Active: {{ active() }} | Completed: {{ completed() }}

+ + @for (todo of todos(); track todo.id) { +
+ + + {{ todo.text }} + + +
+ } + ` +}) +export class TodoListComponent { + // State + todos = signal([]); + 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([]); + 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([]); + + // 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) { + 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({ + 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 { + loading: boolean; + data: T | null; + error: string | null; +} + +@Component({...}) +export class DataComponent { + private http = inject(HttpClient); + + state = signal>({ + 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('/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: ` +
+ + @if (emailError()) { + {{ emailError() }} + } + + + + +
+ ` +}) +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(`/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({ + 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.