Angular patterns — standalone components, signals, inject(), reactive forms, HTTP interceptors, and new control flow
95
94%
Does it follow best practices?
Impact
99%
2.75xAverage score across 4 eval scenarios
Passed
No known issues
Always apply these patterns when writing Angular code. Every rule below is mandatory for all new Angular code.
Always set standalone: true. Never create NgModule classes for new code.
RIGHT:
@Component({
selector: 'app-product-card',
standalone: true,
imports: [CurrencyPipe],
template: `...`,
})
export class ProductCardComponent { }WRONG:
// NEVER use NgModules for new components
@NgModule({
declarations: [ProductCardComponent],
imports: [CommonModule],
})
export class ProductModule { }Always use input() / input.required() and output() from @angular/core. Never use @Input() decorator or @Output() with EventEmitter.
RIGHT:
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<h3>{{ product().name }}</h3>
<span>{{ product().price / 100 | currency }}</span>
<button (click)="addToWishlist.emit(product())">Add to Wishlist</button>
`,
})
export class ProductCardComponent {
product = input.required<Product>();
addToWishlist = output<Product>();
}WRONG:
// NEVER use @Input/@Output decorators — these are legacy
export class ProductCardComponent {
@Input({ required: true }) product!: Product;
@Output() addToWishlist = new EventEmitter<Product>();
}Always use the inject() function for dependency injection. Never use constructor parameter injection.
RIGHT:
export class ProductService {
private http = inject(HttpClient);
}
export class ProductListComponent {
private productService = inject(ProductService);
}WRONG:
// NEVER use constructor injection
export class ProductService {
constructor(private http: HttpClient) { }
}
export class ProductListComponent {
constructor(private productService: ProductService) { }
}Always decorate services with @Injectable({ providedIn: 'root' }). Always use catchError in HTTP pipes to handle errors gracefully and return a fallback value. Always use map() to transform API responses to the expected shape.
RIGHT:
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
getProducts(): Observable<Product[]> {
return this.http.get<{ data: Product[] }>('/api/products').pipe(
map(res => res.data),
catchError(err => {
console.error('Product fetch failed:', err);
return of([]);
}),
);
}
}WRONG:
// NEVER omit catchError — unhandled HTTP errors break the app silently
// NEVER skip map() — always transform the raw API response
@Injectable({ providedIn: 'root' })
export class ProductService {
constructor(private http: HttpClient) { }
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products');
}
}Always use toSignal() to convert service Observables into signals in components. Never manually subscribe in ngOnInit or use imperative state assignment.
RIGHT:
@Component({
selector: 'app-product-list',
standalone: true,
imports: [ProductCardComponent],
template: `
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
}
`,
})
export class ProductListComponent {
private productService = inject(ProductService);
products = toSignal(this.productService.getProducts(), { initialValue: [] });
}WRONG:
// NEVER manually subscribe and assign to a property
export class ProductListComponent implements OnInit {
products: Product[] = [];
constructor(private productService: ProductService) { }
ngOnInit() {
this.productService.getProducts().subscribe(data => {
this.products = data;
});
}
}Always use the built-in control flow syntax (@if, @for, @switch). Never use *ngIf, *ngFor, or *ngSwitch directives. Always use track with @for.
RIGHT:
template: `
@if (loading()) {
<p>Loading...</p>
} @else {
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<p>No items found.</p>
}
}
@switch (status()) {
@case ('active') { <span class="badge-active">Active</span> }
@case ('inactive') { <span class="badge-inactive">Inactive</span> }
@default { <span>Unknown</span> }
}
`WRONG:
// NEVER use structural directives — these are legacy
template: `
<p *ngIf="loading">Loading...</p>
<div *ngFor="let item of items">{{ item.name }}</div>
<div [ngSwitch]="status">
<span *ngSwitchCase="'active'">Active</span>
</div>
`Always define interceptors as HttpInterceptorFn (functional form). Always distinguish between network errors (status === 0) and API errors (non-zero status). Always re-throw with throwError(() => error) after logging. Register via provideHttpClient(withInterceptors([...])) in app.config.ts.
RIGHT:
// error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 0) {
console.error('Network error:', error);
} else {
console.error(`API error ${error.status}:`, error.error);
}
return throwError(() => error);
}),
);
};
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([errorInterceptor])),
],
};WRONG:
// NEVER use class-based interceptors or HTTP_INTERCEPTORS token
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(catchError(err => throwError(() => err)));
}
}
// NEVER register with the legacy token
providers: [{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }]Always use ReactiveFormsModule with FormGroup and FormControl. Always import ReactiveFormsModule in the component's imports array (not in an NgModule). Always add validators. Always guard onSubmit() with a form.valid check.
RIGHT:
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
<label for="name">Name</label>
<input id="name" formControlName="name"
[attr.aria-invalid]="registrationForm.get('name')?.invalid && registrationForm.get('name')?.touched"
aria-required="true" />
@if (registrationForm.get('name')?.hasError('required') && registrationForm.get('name')?.touched) {
<span role="alert">Name is required</span>
}
<button type="submit" [disabled]="registrationForm.invalid">Submit</button>
</form>
`,
})
export class RegistrationFormComponent {
registrationForm = new FormGroup({
name: new FormControl('', [Validators.required]),
email: new FormControl('', [Validators.required, Validators.email]),
});
onSubmit() {
if (this.registrationForm.valid) {
// process form
}
}
}Always add aria-required="true" on required inputs. Always bind [attr.aria-invalid] to control's invalid && touched state. Always use role="alert" on error messages. Always associate <label> elements with inputs via matching for/id pairs. Always gate error message display on the control being both invalid AND touched.
Always use loadComponent for route-level code splitting.
RIGHT:
export const routes: Routes = [
{ path: '', loadComponent: () => import('./home/home.component').then(m => m.HomeComponent) },
{ path: 'products', loadComponent: () => import('./products/products.component').then(m => m.ProductsComponent) },
{ path: '**', loadComponent: () => import('./not-found/not-found.component').then(m => m.NotFoundComponent) },
];WRONG:
// NEVER eagerly import route components
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'products', component: ProductsComponent },
];Always use ChangeDetectionStrategy.OnPush on components. This works naturally with signals and immutable data.
RIGHT:
@Component({
selector: 'app-product-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h3>{{ product().name }}</h3>`,
})
export class ProductCardComponent {
product = input.required<Product>();
}When manual subscriptions are unavoidable (e.g., in effects or complex streams), always use takeUntilDestroyed() from @angular/core/rxjs-interop to auto-unsubscribe. Never manually unsubscribe in ngOnDestroy.
RIGHT:
export class SearchComponent {
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.searchControl.valueChanges.pipe(
debounceTime(300),
takeUntilDestroyed(this.destroyRef),
).subscribe(value => this.search(value));
}
}| Pattern | Always Use | Never Use |
|---|---|---|
| Components | standalone: true | NgModules |
| Inputs | input() / input.required() | @Input() decorator |
| Outputs | output() | @Output() + EventEmitter |
| DI | inject() function | Constructor injection |
| Observables in templates | toSignal() | Manual .subscribe() |
| Control flow | @if / @for / @switch | *ngIf / *ngFor / *ngSwitch |
| Iteration tracking | track expression in @for | trackBy function on *ngFor |
| HTTP interceptors | HttpInterceptorFn | Class-based HttpInterceptor |
| Interceptor registration | provideHttpClient(withInterceptors([...])) | HTTP_INTERCEPTORS token |
| Forms | ReactiveFormsModule | FormsModule / ngModel |
| Routes | loadComponent (lazy) | Eager component property |
| Change detection | OnPush | Default |
| Cleanup | takeUntilDestroyed() | Manual ngOnDestroy |
| Error handling in services | catchError with fallback | No error handling |
| API response transform | map() in pipe | Raw response passthrough |