AngularJSBest Practices

Angular Best Practices

Great Angular code is readable, maintainable, testable, and performant. This guide distills the community's collective wisdom and Angular team recommendations into actionable best practices.

Project Structure

Organize your code by feature, not by type. Feature-based organization scales much better as the app grows:

Bash
src/
├── app/
│   ├── core/                    # Singleton services, guards, interceptors
│   │   ├── auth/
│   │   │   ├── auth.service.ts
│   │   │   └── auth.guard.ts
│   │   └── http/
│   │       └── error.interceptor.ts
│   ├── shared/                  # Reusable components, pipes, directives
│   │   ├── components/
│   │   │   └── button/
│   │   └── pipes/
│   ├── features/                # Feature modules/components
│   │   ├── dashboard/
│   │   │   ├── dashboard.component.ts
│   │   │   ├── dashboard.routes.ts
│   │   │   └── services/
│   │   └── products/
│   │       ├── product-list/
│   │       ├── product-detail/
│   │       └── services/
│   ├── app.routes.ts
│   └── app.config.ts
Standalone Components First

Angular 17+ defaults to standalone components. Prefer them over NgModules for all new code:

TS
// ✅ Standalone — preferred for new code
@Component({
  standalone: true,
  imports: [CommonModule, RouterLink, UserCardComponent],
  selector: 'app-user-list',
  template: `...`,
})
export class UserListComponent {}

// ❌ Module-based — legacy pattern
@NgModule({
  declarations: [UserListComponent],
  imports: [CommonModule],
})
export class UsersModule {}
Use inject() Over Constructor Injection

The inject() function (Angular 14+) is cleaner, more tree-shakeable, and works in injection contexts outside constructors:

TS
// ❌ Old pattern — constructor injection
export class ProductService {
  constructor(
    private http: HttpClient,
    private router: Router,
    private authService: AuthService,
  ) {}
}

// ✅ New pattern — inject() function
export class ProductService {
  private http = inject(HttpClient);
  private router = inject(Router);
  private authService = inject(AuthService);
}

// inject() also works in standalone functions (guards, resolvers, etc.)
export const authGuard = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};
Signals for State Management

Use Signals for component state. They're more performant than properties and easier to reason about:

TS
// ✅ Signals — reactive, performant, explicit
@Component({ standalone: true, template: `{{ count() }}` })
export class CounterComponent {
  count = signal(0);
  doubled = computed(() => this.count() * 2);

  increment() { this.count.update((n) => n + 1); }
}

// For service state — use signals in services
@Injectable({ providedIn: 'root' })
export class CartService {
  private items = signal<CartItem[]>([]);
  readonly itemCount = computed(() => this.items().length);
  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price, 0)
  );

  addItem(item: CartItem) {
    this.items.update((items) => [...items, item]);
  }

  removeItem(id: number) {
    this.items.update((items) => items.filter((i) => i.id !== id));
  }
}
Smart vs. Dumb Components

Separate smart components (connected to services/state) from dumb/presentational components (receive data as inputs, emit events as outputs):

TS
// ✅ Smart component — handles data fetching and state
@Component({
  standalone: true,
  imports: [UserCardComponent],
  template: `
    @for (user of users(); track user.id) {
      <app-user-card [user]="user" (delete)="deleteUser($event)" />
    }
  `,
})
export class UsersPageComponent {
  private userService = inject(UserService);
  users = this.userService.users; // signal

  deleteUser(id: number) {
    this.userService.delete(id);
  }
}

// ✅ Dumb component — pure presentation, no service dependencies
@Component({
  standalone: true,
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h3>{{ user.name }}</h3>
      <button (click)="delete.emit(user.id)">Delete</button>
    </div>
  `,
})
export class UserCardComponent {
  @Input({ required: true }) user!: User;
  @Output() delete = new EventEmitter<number>();
}
OnPush Change Detection Everywhere

All dumb/presentational components should use OnPush. Apply it as a default:

TS
// Apply OnPush to all presentational components
@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  // ...
})
export class AnyPresentationalComponent {}

// Rule of thumb:
// - Dumb components → always OnPush
// - Smart components with Signals → OnPush (signals handle updates)
// - Smart components with Observables + async pipe → OnPush (async pipe triggers CD)
Async Pipe Over Manual Subscriptions

The async pipe automatically subscribes and unsubscribes when the component is destroyed, preventing memory leaks:

TS
// ❌ Manual subscription — must remember to unsubscribe
export class UserListComponent implements OnInit, OnDestroy {
  users: User[] = [];
  private sub!: Subscription;

  ngOnInit() {
    this.sub = this.userService.getUsers().subscribe(
      (users) => (this.users = users)
    );
  }

  ngOnDestroy() {
    this.sub.unsubscribe(); // Easy to forget!
  }
}

// ✅ Async pipe — automatic subscription management
@Component({
  imports: [AsyncPipe],
  template: `
    @for (user of users$ | async; track user.id) {
      <app-user-card [user]="user" />
    }
  `,
})
export class UserListComponent {
  users$ = this.userService.getUsers();
}
Avoid Memory Leaks

TS
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';

// ✅ Angular 16+ — takeUntilDestroyed() (cleanest)
export class MyComponent {
  constructor() {
    this.dataService.stream$.pipe(
      takeUntilDestroyed() // Automatically completes when component destroys
    ).subscribe(data => console.log(data));
  }
}

// ✅ Angular 16+ — inject(DestroyRef) for services
@Injectable()
export class MyService {
  private destroyRef = inject(DestroyRef);

  startTracking() {
    interval(1000).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe();
  }
}
Strict TypeScript Configuration

JSON
// tsconfig.json — enable strict mode
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictPropertyInitialization": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  },
  "angularCompilerOptions": {
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true  // Type-check template bindings
  }
}
Note
strictTemplates: true enables Angular's template type checker, which catches type errors in your HTML templates at build time — e.g., passing a string where a number is expected.
ESLint Configuration

Bash
ng add @angular-eslint/schematics

JSON
// .eslintrc.json
{
  "overrides": [
    {
      "files": ["*.ts"],
      "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:@angular-eslint/recommended"
      ],
      "rules": {
        "@angular-eslint/directive-selector": ["error", { "type": "attribute", "prefix": "app", "style": "camelCase" }],
        "@angular-eslint/component-selector": ["error", { "type": "element", "prefix": "app", "style": "kebab-case" }],
        "@typescript-eslint/no-explicit-any": "warn",
        "no-console": ["warn", { "allow": ["warn", "error"] }]
      }
    }
  ]
}
Error Handling

TS
// Global error handler
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private router = inject(Router);

  handleError(error: Error) {
    console.error('Global error:', error);
    // Send to monitoring service (e.g., Sentry)
    this.monitoringService.captureException(error);

    if (error.message.includes('ChunkLoadError')) {
      // Reload if a lazy chunk fails to load (e.g., after deployment)
      window.location.reload();
    }
  }
}

// Register in app.config.ts
providers: [
  { provide: ErrorHandler, useClass: GlobalErrorHandler },
]
Security Best Practices
  • Never use [innerHTML] with user-supplied content — Angular sanitizes it but DOMPurify is safer

  • Use Angular's HttpClient — it automatically sends XSRF tokens

  • Never store sensitive data in localStorage — use HttpOnly cookies instead

  • Avoid bypassSecurityTrustHtml/Url unless absolutely necessary — each use is a security risk

  • Use route guards to protect authenticated routes, not CSS visibility

  • Set Content Security Policy headers on your server

  • Regularly run ng audit (npm audit) to check for vulnerable dependencies

Warning
Never use bypassSecurityTrustHtml() with user-generated content. Angular's sanitizer removes dangerous HTML, but bypassing it opens you to XSS attacks.
Summary: The Angular 17+ Best Practices Stack

Concern

Recommended Approach

Components

Standalone, OnPush, inject()

State

Signals + computed() for local; NgRx Signal Store for global

HTTP

HttpClient + async pipe + shareReplay(1)

Forms

Reactive Forms with typed FormGroup

Routing

Lazy loading with loadComponent/loadChildren

Testing

TestBed for components, Jest for speed

Linting

@angular-eslint + Prettier

TypeScript

strict: true + strictTemplates: true

Subscriptions

takeUntilDestroyed() or async pipe

Rendering

@defer for heavy components, NgOptimizedImage for images