CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/angular-best-practices

Angular patterns — standalone components, signals, inject(), reactive forms, HTTP interceptors, and new control flow

95

2.75x
Quality

94%

Does it follow best practices?

Impact

99%

2.75x

Average score across 4 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
angular-best-practices
description:
Angular patterns — standalone components, signals, services, dependency injection, reactive forms, and HTTP client. Use when building or reviewing Angular apps, when migrating to standalone components or signals, or when setting up a new Angular project.
keywords:
angular, angular component, angular service, angular signals, angular standalone, angular reactive forms, angular http client, angular dependency injection, angular router, angular pipes, angular typescript, angular patterns
license:
MIT

Angular Best Practices

Always apply these patterns when writing Angular code. Every rule below is mandatory for all new Angular code.


1. Standalone Components — No NgModules

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 { }

2. Signal Inputs and Outputs — Not Decorators

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>();
}

3. inject() Function — Not Constructor Injection

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) { }
}

4. Services: providedIn root, catchError, map()

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');
  }
}

5. toSignal() — Convert Observables to Signals in Components

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;
    });
  }
}

6. New Control Flow Syntax

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>
`

7. HTTP Interceptors — Functional, Not Class-Based

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 }]

8. Reactive Forms — Not Template-Driven

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
    }
  }
}

9. Accessibility — Always Include

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.


10. Lazy-Loaded Routes

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 },
];

11. OnPush Change Detection

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>();
}

12. Cleanup: takeUntilDestroyed

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));
  }
}

Quick Reference

PatternAlways UseNever Use
Componentsstandalone: trueNgModules
Inputsinput() / input.required()@Input() decorator
Outputsoutput()@Output() + EventEmitter
DIinject() functionConstructor injection
Observables in templatestoSignal()Manual .subscribe()
Control flow@if / @for / @switch*ngIf / *ngFor / *ngSwitch
Iteration trackingtrack expression in @fortrackBy function on *ngFor
HTTP interceptorsHttpInterceptorFnClass-based HttpInterceptor
Interceptor registrationprovideHttpClient(withInterceptors([...]))HTTP_INTERCEPTORS token
FormsReactiveFormsModuleFormsModule / ngModel
RoutesloadComponent (lazy)Eager component property
Change detectionOnPushDefault
CleanuptakeUntilDestroyed()Manual ngOnDestroy
Error handling in servicescatchError with fallbackNo error handling
API response transformmap() in pipeRaw response passthrough

Verifiers

  • angular-standalone — Use standalone components with inject() and signals
  • angular-signals-io — Use signal inputs/outputs, not decorators
  • angular-http-patterns — Services with catchError, map(), functional interceptors
  • angular-reactive-forms — Reactive forms with accessibility
  • angular-control-flow — New control flow syntax, OnPush, lazy routes
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/angular-best-practices badge