irahardianto / angular-idioms
Angular (19+) architecture: signals, standalone components, DI, OnPush change detection, and RxJS integration. Use when developing or refactoring Angular web apps. Pair with typescript-idioms.
Angular Idioms and Patterns
Core Philosophy
Angular (19+) rewards signals, standalone components, and reactive patterns. Idiomatic Angular = typed, modular, RxJS-aware, OnPush by default.
Scope: This file covers Angular-specific coding idioms for components, services, and patterns. For TypeScript type system patterns, see
@.agents/skills/typescript-idioms/SKILL.md. For file and folder layout, seereferences/project-structure.md.Loading guard: Do NOT load this skill for non-Angular projects. Vue →
vue-idioms; React →react-idioms; Next.js →nextjs-idioms. Angular file suffixes (.component.ts,.service.ts, etc.) andangular.jsonare the reliable triggers —tsconfig*.jsonalone is NOT an Angular signal (every TS project has one).
When to Load References
Load these before writing code in the matching context — not after.
| Situation | Reference to Load |
|---|---|
| Starting an Angular project or reviewing file layout | references/project-structure.md |
| TypeScript type system, async, Zod, error types | @.agents/skills/typescript-idioms/SKILL.md (always co-load) |
| Zod schemas / boundary validation | @.agents/skills/typescript-idioms/references/zod-patterns.md |
| Async / I/O / coercion / RxJS pitfalls | @.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md |
Standalone Components (Default)
Standalone components — no NgModules for new components (standalone is the default since Angular 19):
`typescript
@Component({
selector: 'app-task-list',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TaskCardComponent],
template: `
@for (task of filteredTasks(); track task.id) {
<app-task-card [task]="task" />
}
`
})
export class TaskListComponent {
tasks = signal<Task[]>([]);
}`Lazy-load routes with
loadComponent:`typescript
{ path: 'tasks', loadComponent: () => import('./features/task/task-list.component')
.then(m => m.TaskListComponent) }`
Signals (17+)
- Signals for synchronous state — prefer over BehaviorSubject for component state.
computedfor derived state.effectfor side effects.- RxJS for async streams — HTTP, WebSocket, complex event handling.
// ✅ Signal-based component state
export class TaskListComponent {
private readonly taskService = inject(TaskService);
tasks = signal<Task[]>([]);
filter = signal<string>('');
filteredTasks = computed(() => // ✅ Derived state
this.tasks().filter(t => t.title.includes(this.filter()))
);
constructor() {
effect(() => console.debug('Tasks updated:', this.tasks().length)); // ✅ Side effect
}
}Change Detection
OnPushis the default strategy — set it on every component:`typescript
// ✅ Always OnPush
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})`- OnPush works naturally with signals — signal reads in templates automatically trigger change detection when the signal value changes.
- Use
Defaultonly when wrapping third-party components that mutate state imperatively and cannot be refactored. - Never call
ChangeDetectorRef.detectChanges()manually — if you need it, your data flow is wrong. Convert to signals or useasyncpipe.
Component Design
Signal-based inputs and outputs (Angular 17.1+) — prefer over decorators:
`typescript
// ✅ Signal input — reactive, no OnChanges needed
task = input.required<Task>();
variant = input<'compact' | 'full'>('full');// ✅ Signal output
taskCompleted = output<string>();// ❌ Decorator style — legacy
@Input() task!: Task;
@Output() taskCompleted = new EventEmitter<string>();`Content projection — use
<ng-content>for composable UI,selectattribute for named slots:`typescript
@Component({
template: `
<header><ng-content select="[card-header]" /></header>
<main><ng-content /></main>
`
})`Signal-based view queries (Angular 17.2+):
`typescript
canvas = viewChild.required<ElementRef>('canvas'); // ✅ Reactive, no AfterViewInit
items = contentChildren(TabItemComponent); // ✅ Content query`Lifecycle hooks guidance:
OnInit— fetch initial data, set up subscriptions (useinject(DestroyRef)for cleanup)OnDestroy— manual cleanup only iftakeUntilDestroyedorDestroyRefcannot be used- Avoid
OnChanges— use signal inputs withcomputed()oreffect()instead
Template Patterns
Use new control flow syntax (Angular 17+) —
@for,@if,@switch,@defer:`html
<!-- ✅ New control flow with track expression -->
@for (task of tasks(); track task.id) {
<app-task-card [task]="task" />
} @empty {
<p>No tasks found.</p>
}@if (isLoading()) {
<app-spinner />
} @else {
<app-task-list [tasks]="tasks()" />
}@switch (task().priority) {
@case ('high') { <span class="badge-high">High</span> }
@case ('medium') { <span class="badge-med">Medium</span> }
@default { <span class="badge-low">Low</span> }
}`@deferfor lazy-loaded template blocks:`html
<!-- ✅ Lazy-load heavy component -->
@defer (on viewport) {
<app-task-analytics [tasks]="tasks()" />
} @placeholder {
<div class="skeleton" />
}`- Use
ng-containerfor grouping without extra DOM nodes — e.g.,<ng-container *ngTemplateOutlet="tpl" />. Avoid complex expressions in templates — extract to
computed():`typescript
// ❌ template:{{ tasks().filter(t => t.done).length }} / {{ tasks().length }}
// ✅ Extract to computed
summary = computed(() =>${this.doneTasks().length} / ${this.tasks().length});`
Dependency Injection
inject()function over constructor injection in standalone components.- Provide at appropriate level — component, route, or root.
- Abstract services behind interfaces for testability:
// ✅ Abstract class as interface (TypeScript has no runtime interfaces)
export abstract class TaskStorage {
abstract getById(id: string): Observable<Task>;
abstract save(task: Task): Observable<void>;
}
// ✅ Implementation
@Injectable()
export class HttpTaskStorage extends TaskStorage {
private readonly http = inject(HttpClient);
getById(id: string) { return this.http.get<Task>(`/api/tasks/${id}`); }
save(task: Task) { return this.http.post<void>('/api/tasks', task); }
}
// ✅ Wired at route or root level
providers: [{ provide: TaskStorage, useClass: HttpTaskStorage }]Reactive Forms
- Reactive forms over template-driven for complex forms.
- Typed forms (
FormControl<string>) — always. - Custom validators as pure functions:
// ✅ Typed form group with nonNullable controls
export class TaskFormComponent {
form = new FormGroup({
title: new FormControl('', { nonNullable: true,
validators: [Validators.required, Validators.maxLength(200)] }),
priority: new FormControl<'low' | 'medium' | 'high'>('medium', { nonNullable: true }),
});
}Routing Patterns
Functional guards (Angular 15+) — no class-based guards:
`typescript
// ✅ Functional guard
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
return auth.isAuthenticated() || inject(Router).createUrlTree(['/login']);
};// ❌ Class-based guard — deprecated
@Injectable() export class AuthGuard implements CanActivate { ... }`Functional resolvers — same pattern, use
ResolveFn<T>:`typescript
export const taskResolver: ResolveFn<Task> = (route) =>
inject(TaskService).getById(route.paramMap.get('id')!);`Lazy loading with
loadChildrenfor feature routes:`typescript
{
path: 'tasks',
loadChildren: () => import('./features/task/task.routes')
.then(m => m.TASK_ROUTES),
canActivate: [authGuard],
}`Route parameter binding with
input()(Angular 16+):`typescript
// In app.config.ts: withComponentInputBinding()
// ✅ Route params bound as signal inputs — no ActivatedRoute needed
taskId = input.required<string>();// ❌ Manual route param subscription
this.route.paramMap.pipe(...).subscribe(...)`
State Management
- Component signals first — sufficient for most local UI state.
NgRx Signal Store for shared or complex state beyond a single component:
// ✅ NgRx Signal Store export const TaskStore = signalStore( { providedIn: 'root' }, withState<TaskState>({ tasks: [], isLoading: false, error: null }), withComputed(({ tasks }) => ({ completedTasks: computed(() => tasks().filter(t => t.done)), })), withMethods((store, taskService = inject(TaskService)) => ({ async loadTasks(): Promise<void> { patchState(store, { isLoading: true }); const tasks = await firstValueFrom(taskService.getAll()); patchState(store, { tasks, isLoading: false }); }, })), );When to use what:
signal()— local component state, simple parent-child data flow- NgRx Signal Store — shared state across components, entity management (
withEntities()) - RxJS + services — real-time streams, WebSocket data, complex async orchestration
Error Handling
For universal error handling principles, see
.agents/rules/error-handling-principles.md. Below: Angular-specific patterns only.
Global error handler for uncaught exceptions:
`typescript
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
this.logger.error('unhandled_error', { error }); // Log to observability platform
}
}`HTTP interceptor for centralized error handling:
`typescript
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Redirect to login
}
return throwError(() => error);
})
);`- RxJS error handling — never leave Observables unhandled. Always use
catchErrorin.pipe()or handle insubscribe()error callback.
Anti-Patterns
- ❌ NgModules for new components — use standalone components (default since v19)
- ❌ BehaviorSubject for simple component state — use signals
- ❌ Constructor injection in standalone components — use
inject() - ❌ Manual subscriptions without cleanup — use
takeUntilDestroyed()orasyncpipe - ❌
anyin template bindings — type everything - ❌ Direct DOM manipulation — use Angular's renderer or signals
- ❌
subscribe()in components without unsubscribe — preferasyncpipe ortoSignal() - ❌
ChangeDetectionStrategy.Defaultwithout justification — alwaysOnPush - ❌
*ngFor/*ngIfin new code — use@for/@ifcontrol flow - ❌ Class-based guards and resolvers — use functional equivalents
// ❌ Memory leak — subscription never cleaned up
ngOnInit() {
this.taskService.getTasks().subscribe(tasks => this.tasks = tasks);
}
// ✅ Auto-cleanup with takeUntilDestroyed
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.taskService.getTasks().pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(tasks => this.tasks.set(tasks));
}
// ✅ Even better — convert to signal
tasks = toSignal(this.taskService.getTasks(), { initialValue: [] });Naming Conventions
File naming — dot-separated with type suffix:
task-list.component.ts,task.service.ts,task.pipe.ts,task.guard.ts,task.directive.tstask.routes.tsfor feature route definitionstask-list.component.spec.tsfor tests (co-located)
- Selector prefixes — use
app-(or project-specific prefix fromangular.json):selector: 'app-task-card' - Class naming — suffix matches file type:
TaskListComponent,TaskService,HighlightDirective,DateFormatPipe - Route file exports —
UPPER_SNAKE_CASE:export const TASK_ROUTES: Routes = [...]
Testing
For universal testing principles, see
.agents/rules/testing-strategy.md. Below: Angular-specific patterns only.
Test-file naming:*.spec.tsco-located (Angular CLI default —.component.spec.tsbeside.component.ts). For the cross-framework reconciliation rule, see@.agents/skills/typescript-idioms/references/project-structure.md§Test Organization.
Angular Testing Library for component tests (preferred over TestBed):
`typescript
import { render, screen } from '@testing-library/angular';it('should display task title', async () => {
await render(TaskCardComponent, {
componentInputs: { task: mockTask },
});
expect(screen.getByText('Deploy fix')).toBeInTheDocument();
});`Spectator for service tests:
`typescript
const spectator = createServiceFactory({
service: TaskService,
mocks: [TaskStorage],
});`HttpTestingControllerfor HTTP service tests — no live backend.Signal Store testing — test store methods directly, assert signal values:
`typescript
it('should load tasks', async () => {
const store = TestBed.inject(TaskStore);
await store.loadTasks();
expect(store.tasks().length).toBeGreaterThan(0);
});`
Formatting and Static Analysis
| Tool | Purpose | Command |
|---|---|---|
| Prettier | Formatting | npx prettier --write . |
| ESLint + angular-eslint | Linting | npx ng lint |
strict mode | Type checking | "strict": true in tsconfig.json |
| Angular compiler | Template checking | npx ng build (checks templates) |
Related
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- TypeScript Idioms @.agents/skills/typescript-idioms/SKILL.md
- Angular Project Structure @.agents/skills/angular-idioms/references/project-structure.md
- Frontend Layout (framework-neutral, shared with React/Vue) @.agents/skills/frontend-design/references/frontend-layout.md
- Frontend Design @.agents/skills/frontend-design/SKILL.md
- Security Principles @.agents/rules/security-principles.md
- Accessibility Principles @.agents/rules/accessibility-principles.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Logging and Observability @.agents/rules/logging-and-observability-mandate.md
- Architectural Patterns @.agents/rules/architectural-pattern.md