angular-frontend-patterns · git:20261025.e12e11b · 2026-10-25 · sha256 1b5ddef305b46943

angular-frontend-patterns git:20261025.e12e11bA

Immutable. This exact content never changes and is served at /api/v1/blob/1b5ddef305b46943.

---
name: angular-frontend-patterns
description: Angular component and state patterns built on signals. Use when building Angular components, signal state, RxJS interop, routing or forms. angular-3d-scene-crafter and angular-gsap-animation-crafter are compatible siblings for scene construction and scroll motion, not replacements for this component and state guidance. React component work belongs to composition-patterns and react-best-practices.
---

# Modern Angular Frontend Patterns

Signal-first, push-based architecture patterns for Angular 17+.

## Core Principle: Signals Over Observables

Use signals for synchronous state, observables for async streams.

| Use Case          | Pattern                      |
| ----------------- | ---------------------------- |
| Component state   | `signal()`, `computed()`     |
| API responses     | `toSignal()` from observable |
| Form values       | `signal()` for model         |
| Event streams     | Keep as Observable           |
| WebSocket streams | Keep as Observable           |

## Signal Types Quick Reference

```typescript
import { signal, computed, effect, input, output, model } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';

// Writable signal
const count = signal(0);
count.set(5); // Replace value
count.update((v) => v + 1); // Update based on current

// Computed (derived, read-only)
const doubled = computed(() => count() * 2);

// Effect (side effects)
effect(() => console.log(count()));

// Input signals (component inputs)
name = input<string>(); // Optional
userId = input.required<string>(); // Required
size = input(10); // With default
active = input(false, { transform: booleanAttribute });

// Output
clicked = output<MouseEvent>();

// Model (two-way binding)
value = model(0); // Creates input + output
// Usage: <comp [(value)]="parentVal" />
```

## Smart vs Dumb Components

### Smart (Container) Components

```typescript
// libs/[domain]/feature/src/lib/user-list/user-list.component.ts
@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [UserCardComponent, UserFilterComponent],
  template: `
    <app-user-filter (filterChange)="onFilterChange($event)" />

    @if (loading()) {
    <app-spinner />
    } @else { @for (user of filteredUsers(); track user.id) {
    <app-user-card [user]="user" (select)="onSelectUser($event)" />
    } }
  `,
})
export class UserListComponent {
  private userService = inject(UserService);
  private router = inject(Router);

  // State
  private filter = signal('');
  loading = signal(false);

  // Data from service
  users = toSignal(this.userService.users$, { initialValue: [] });

  // Derived state
  filteredUsers = computed(() => {
    const filter = this.filter().toLowerCase();
    return this.users().filter((u) => u.name.toLowerCase().includes(filter));
  });

  onFilterChange(value: string) {
    this.filter.set(value);
  }

  onSelectUser(userId: string) {
    this.router.navigate(['/users', userId]);
  }
}
```

### Dumb (Presentational) Components

```typescript
// libs/[domain]/ui/src/lib/user-card/user-card.component.ts
@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card" (click)="select.emit(user().id)">
      <img [src]="user().avatar" [alt]="user().name" />
      <h3>{{ user().name }}</h3>
      <p>{{ user().email }}</p>
    </div>
  `,
})
export class UserCardComponent {
  user = input.required<User>();
  select = output<string>();
}
```

## State Management Pattern

```typescript
// libs/[domain]/data-access/src/lib/user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);

  // Private writable state
  private _users = signal<User[]>([]);
  private _selectedUserId = signal<string | null>(null);
  private _loading = signal(false);
  private _error = signal<string | null>(null);

  // Public read-only state
  users = this._users.asReadonly();
  loading = this._loading.asReadonly();
  error = this._error.asReadonly();

  // Computed state
  selectedUser = computed(() => {
    const id = this._selectedUserId();
    return this._users().find((u) => u.id === id) ?? null;
  });

  activeUsers = computed(() => this._users().filter((u) => u.isActive));

  // Observable for HTTP (keeps RxJS benefits)
  users$ = this.http.get<User[]>('/api/users');

  // Actions
  loadUsers() {
    this._loading.set(true);
    this._error.set(null);

    this.http
      .get<User[]>('/api/users')
      .pipe(
        tap((users) => {
          this._users.set(users);
          this._loading.set(false);
        }),
        catchError((err) => {
          this._error.set(err.message);
          this._loading.set(false);
          return EMPTY;
        })
      )
      .subscribe();
  }

  selectUser(id: string | null) {
    this._selectedUserId.set(id);
  }

  addUser(user: User) {
    this._users.update((users) => [...users, user]);
  }

  updateUser(id: string, changes: Partial<User>) {
    this._users.update((users) => users.map((u) => (u.id === id ? { ...u, ...changes } : u)));
  }
}
```

## RxJS Integration

```typescript
import { toSignal, toObservable } from '@angular/core/rxjs-interop';

// Observable → Signal
const users = toSignal(this.http.get<User[]>('/api/users'), {
  initialValue: [],
});

// With loading state
const usersResource = toSignal(
  this.http.get<User[]>('/api/users').pipe(
    map((data) => ({ data, loading: false, error: null })),
    startWith({ data: [], loading: true, error: null }),
    catchError((err) => of({ data: [], loading: false, error: err.message }))
  )
);

// Signal → Observable (for interop)
const searchTerm = signal('');
const searchTerm$ = toObservable(this.searchTerm);

// Debounced search
const searchResults = toSignal(
  toObservable(this.searchTerm).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.searchService.search(term))
  ),
  { initialValue: [] }
);
```

## Decision Matrix

| Need                | Pattern                 | Reference                                                 |
| ------------------- | ----------------------- | --------------------------------------------------------- |
| Component state     | Signals                 | SKILL.md                                                  |
| Component inputs    | `input()` signals       | SKILL.md                                                  |
| Derived/computed    | `computed()`            | SKILL.md                                                  |
| Side effects        | `effect()`              | [effects-patterns.md](references/effects-patterns.md)     |
| Forms               | Signal-based            | [forms-patterns.md](references/forms-patterns.md)         |
| HTTP/async          | RxJS → `toSignal()`     | [rxjs-patterns.md](references/rxjs-patterns.md)           |
| Container component | Smart component pattern | [component-patterns.md](references/component-patterns.md) |
| Reusable UI         | Dumb component pattern  | [component-patterns.md](references/component-patterns.md) |

## References

Load these for detailed implementation guidance:

- [component-patterns.md](references/component-patterns.md) - Smart/dumb patterns, composition
- [rxjs-patterns.md](references/rxjs-patterns.md) - RxJS best practices with signals
- [forms-patterns.md](references/forms-patterns.md) - Reactive forms with signals
- [effects-patterns.md](references/effects-patterns.md) - Effect patterns and pitfalls