DEV Community

Mauricio Arce Torrez
Mauricio Arce Torrez

Posted on

My Angular Style Guide & Coding Conventions

This document outlines the coding style and best practices for Angular development within my projects. It is inspired by the Angular Style Guide, and my experience working with Angular, so it is heavily opinionated. Adhering to these guidelines ensures consistency, readability, maintainability, and optimal performance across my codebase.

1. General Principles & Best Practices

1.1 Prefer Declarative Code

Rule: Strive for declarative code over imperative code.

Explanation: Declarative code describes what should be done, rather than how it should be done. This leads to more readable, predictable, and maintainable code. This principle encourages immutability, small components, and leveraging Angular's reactive features.

Examples:

  • Good: Using signals or RxJS operators to transform data.
  • Bad: Manually manipulating DOM elements or updating state in multiple places.

1.2 One Concept Per File

Rule: Each file should generally contain a single Angular concept (e.g., one component, one directive, one pipe, one service, one model/interface).

Explanation: This promotes modularity, easier navigation, and better reusability. This rule can be relaxed if the additional element is used only within that file (not exported) and does not significantly increase the file size or complexity.

Example:

// Good: app.component.ts
@Component({...})
export class AppComponent {}

// Good: user.model.ts
export interface User { /* ... */ }

// Acceptable exception: If User is only used in MyComponent and is small.
// my-component.ts
interface User { name: string, /* ... */ }

@Component({
  standalone: true,
  template: `{{ user.name }}`,
})
export class MyComponent {
  protected readonly user: User = { name: "...", /* ... */ };
}
Enter fullscreen mode Exit fullscreen mode

1.3 Default to readonly Properties

Rule: Component properties should be declared readonly by default where possible.

Explanation: Using readonly encourages immutability and prevents accidental imperative changes to properties, making state management more predictable and debugging easier.

Example:

// Good
export class MyComponent {
  readonly title = 'My App';
  readonly items = signal<string[]>([]); // Signal is already immutable by nature
}

// Bad (if 'title' is not intended to be changed imperatively)
export class MyComponent {
  title = 'My App';
}
Enter fullscreen mode Exit fullscreen mode

2. Angular Core Features

2.1 Use Built-in Control Flow

Rule: Discontinue the use of structural directives like *ngIf, *ngFor, and *ngSwitch. Always use Angular's new built-in control flow syntax.

Explanation: The new control flow offers improved performance, better type checking, and a more intuitive syntax.

Examples:

<!-- Good: @if -->
@if (user) {
  <p>Welcome, {{ user.name }}!</p>
} @else {
  <p>Please log in.</p>
}

<!-- Good: @for -->
@for (item of items; track item.id) {
  <li>{{ item.name }}</li>
} @empty {
  <p>No items found.</p>
}

<!-- Good: @switch -->
@switch (status) {
  @case ('loading') {
    <p>Loading...</p>
  }
  @case ('success') {
    <p>Data loaded successfully!</p>
  }
  @default {
    <p>Unknown status.</p>
  }
}
Enter fullscreen mode Exit fullscreen mode

2.2 Always Use track Key in for Control Flow

Rule: Always provide a track key in for loops. If iterating over primitive values, use the value directly. If iterating over objects, use a unique, primitive property of the object (e.g., id). Avoid using the object reference itself, as it can cause unnecessary re-rendering.

Explanation: The track key helps Angular identify unique items in a list, optimizing rendering performance by only re-rendering elements that have actually changed, rather than the entire list.

Examples:

<!-- Good: Primitive values -->
@for (name of names; track name) {
  <li>{{ name }}</li>
}

<!-- Good: Objects with a unique ID -->
@for (user of users; track user.id) {
  <li>{{ user.name }}</li>
}

<!-- Bad: Using the object directly for tracking -->
@for (user of users; track user) {
  <li>{{ user.name }}</li>
}
Enter fullscreen mode Exit fullscreen mode

2.3 Prefer inject Function for Dependency Injection

Rule: Prefer using the inject function over constructor parameter injection for dependencies.

Explanation: The inject function provides a more flexible and tree-shakeable way to inject dependencies, especially outside of constructors (e.g., in field initializers or functions).

Examples:

// Good
import { inject } from '@angular/core';
import { MyService } from './my.service';

@Component({...})
export class MyComponent {
  readonly #myService = inject(MyService);

  // ...
}

// Bad (less preferred)
import { MyService } from './my.service';

@Component({...})
export class MyComponent {
  constructor(private myService: MyService) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

2.4 Use Standalone Components, Directives, and Pipes

Rule: All new components, pipes, and directives should be standalone.

Explanation: Standalone components are the new default in latest versions, and they simplify the Angular module system, reduce boilerplate, and improve tree-shaking, leading to smaller bundle sizes and easier development.

Example:

// Good: Standalone component
import { Component } from '@angular/core';

@Component({
  standalone: true,
  selector: 'app-my-standalone',
  template: `<p>This is a standalone component.</p>`,
})
export class MyStandaloneComponent {}
Enter fullscreen mode Exit fullscreen mode

2.5 Use [class] and [style] Bindings

Rule: Do not use [ngClass] and [ngStyle]. Instead, use the more performant and direct [class] and [style] attribute bindings.

Explanation: The [class] and [style] bindings offer better performance and are often more straightforward for dynamic class and style manipulation.

Examples:

<!-- Good: [class] for single class -->
<div [class.active]="isActive"></div>

<!-- Good: [class] for multiple classes (object syntax) -->
<div [class]="{'active': isActive, 'highlight': isHighlight}"></div>

<!-- Good: [style] for single style property -->
<div [style.color]="textColor"></div>

<!-- Good: [style] for multiple style properties (object syntax) -->
<div [style]="{'background-color': bgColor, 'padding': '10px'}"></div>

<!-- Bad: [ngClass] -->
<div [ngClass]="{'active': isActive}"></div>

<!-- Bad: [ngStyle] -->
<div [ngStyle]="{'color': textColor}"></div>
Enter fullscreen mode Exit fullscreen mode

3. Component Design & Structure

3.1 Container and Presentational Components

Rule: Components should be designed using the container (smart) and presentational (dumb) component approach. Container components handle data retrieval and logic, while presentational components focus solely on displaying data and emitting UI events.

Explanation: This separation of concerns improves reusability, testability, and maintainability.

Examples:

  • Container Component (UserPageContainerComponent): Fetches user data from a service, handles loading states, and passes data to a presentational component.
  • Presentational Component (UserDetailComponent): Receives user data via input(), displays it, and emits events (e.g., (editUser)) when UI interactions occur.

3.2 Multiple Container Components Per Page

Rule: A page can have multiple container components to avoid a single large container component managing data for many presentational components.

Explanation: This prevents monolithic container components, promoting smaller, more focused containers that manage specific sections of a page's data and logic.

3.3 Components Focused on Presentation

Rule: Keep components and directives focused on presentation. The code within them should primarily relate to the UI shown on the page. Logic decoupled from the UI (e.g., complex form validation, data transformations, business logic) should reside in separate files (e.g., services, utility functions).

Explanation: This ensures components remain lean and focused on their primary responsibility: rendering the UI. It improves reusability of business logic and makes components easier to test.

3.4 Inline Templates, Separate Styles

Rule: Components should use inline templates (template: '...'), but keep styles in a separate file (styleUrls: [...]).

Explanation: Inline templates make it easier to view the component's structure alongside its logic. Separating styles into dedicated files allows for better organization, reusability, and tooling support for CSS.

Example:

// Good
@Component({
  selector: 'app-my',
  standalone: true,
  template: `
    <div class="wrapper">
      <h1>{{ title }}</h1>
      <button (click)="sayHello()">Click Me</button>
    </div>
  `,
  styleUrls: ['./my.component.scss'] // Separate style file
})
export class MyComponent {
  protected readonly title = 'Hello';

  protected sayHello(): void { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

3.5 Template Property Order

Rule: In templates, element properties should be ordered as follows:

  1. Any id property
  2. Normal HTML attributes (e.g., src, alt, type, placeholder)
  3. Property bindings ([property])
  4. Event bindings ((event))
  5. Two-way data bindings ([(ngModel)] or "banana in a box" properties)

Explanation: This consistent ordering improves template readability and makes it easier to scan for different types of bindings.

Example:

<!-- Good -->
<input
  id="username-input"
  type="text"
  placeholder="Enter username"
  [value]="userName()"
  (input)="logInput($event)"
  [(ngModel)]="userModel"
>
Enter fullscreen mode Exit fullscreen mode

4. State Management & Reactivity

4.1 Prefer Signals or Pipes for Data Transformation

Rule: Only call functions from the template to handle events. If you need to transform data to be displayed or bound, always use signals (specifically computed signals) or pipes.

Explanation: Calling functions directly from the template for data transformation can lead to performance issues due to repeated execution during change detection. Signals and pipes are optimized for reactive data transformations.

Examples:

// Good: Using a computed signal for transformation
export class MyComponent {
  protected readonly firstName = signal('John');
  protected readonly lastName = signal('Doe');
  protected readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
}
// Template: {{ fullName() }}

// Good: Using a pipe for transformation
// my-uppercase.pipe.ts
@Pipe({ name: 'myUppercase', standalone: true })
export class MyUppercasePipe implements PipeTransform {
  transform(value: string): string {
    return value.toUpperCase();
  }
}
// Template: {{ myText | myUppercase }}

// Bad: Calling a function for transformation in template
export class MyComponent {
  protected readonly text = 'hello';

  getUppercaseText(): string {
    return this.text.toUpperCase();
  }
}
// Template: {{ getUppercaseText() }} // Avoid this
Enter fullscreen mode Exit fullscreen mode

4.2 Avoid Manual Subscriptions (Prefer toSignal or async pipe)

Rule: Try to avoid manual subscriptions to RxJS Observables. Prefer using the toSignal utility function to convert Observables into signals, or use the async pipe directly in the template.

Explanation: toSignal and the async pipe automatically manage subscriptions and unsubscriptions, preventing memory leaks and simplifying reactive code.

Examples:

// Good: Using toSignal
import { toSignal } from '@angular/core/rxjs-interop';
import { MyService } from './my.service';

@Component({...})
export class MyComponent {
  readonly #myService = inject(MyService);
  protected readonly data = toSignal(this.#myService.getData(), { initialValue: [] });
}
// Template: @for (item of data(); track item.id) { ... }

// Good: Using async pipe
import { Observable } from 'rxjs';
import { MyService } from './my.service';

@Component({...})
export class MyComponent {
  readonly #myService = inject(MyService);
  protected readonly data$ = this.#myService.getData();
}
// Template: @if (data$ | async; as data) { @for (item of data; track item.id) { ... } }

// Bad: Manual subscription (among other things)
import { MyService } from './my.service';
import { Subscription } from 'rxjs';

@Component({...})
export class MyComponent implements OnInit, OnDestroy {
  private myService = inject(MyService);
  private subscription: Subscription;
  data: any[] = [];

  ngOnInit() {
    this.subscription = this.myService.getData().subscribe(data => this.data = data);
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

4.3 Manual Subscription Cleanup with takeUntilDestroyed

Rule: If you absolutely need to subscribe to a stream manually, ensure you close the subscription using takeUntilDestroyed and by injecting DestroyRef.

Explanation: takeUntilDestroyed is a powerful RxJS operator introduced in Angular that automatically unsubscribes from an observable when the injection context it's used within (such as a component, directive, service, or pipe) is destroyed. This effectively prevents memory leaks by ensuring that subscriptions are cleaned up without needing manual ngOnDestroy logic for each subscription.

Example:

// Good: Manual subscription with takeUntilDestroyed
import { Component, inject, DestroyRef } from '@angular/core';
import { MyService } from './my.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({...})
export class MyComponent {
  readonly #myService = inject(MyService);
  readonly #destroyRef = inject(DestroyRef);
  protected data: unknown[] = [];

  constructor() {
    this.#myService.getData()
      .pipe(takeUntilDestroyed(this.#destroyRef))
      .subscribe(data => {
        this.data = data;
        console.log('Received data:', data);
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

4.4 Avoid effect When computed is Sufficient

Rule: Avoid using effect whenever possible. Prefer using computed properties if you need to propagate state changes.

Explanation: computed signals are pure and memoized, making them ideal for deriving new reactive values from existing ones. effect should be reserved for side effects that do not produce new reactive values. Overusing effect can lead to complex and hard-to-debug reactive graphs.

Examples:

// Good: Using computed for derived state
export class MyComponent {
  protected readonly quantity = signal(1);
  protected readonly price = signal(10);
  protected readonly total = computed(() => this.quantity() * this.price()); // total is derived reactive state
}

// Good: Appropriate use of effect (side effect)
export class MyComponent {
  protected readonly #count = signal(0);

  constructor() {
    effect(() => {
      console.log(`Current count is: ${this.count()}`); // Logging is a side effect
    });
  }
}

// Bad: Using effect to derive state that could be a computed
export class MyComponent {
  protected readonly firstName = signal('John');
  protected readonly lastName = signal('Doe');
  protected readonly fullName = signal(''); // Imperative state

  constructor() {
    effect(() => {
      this.fullName.set(`${this.firstName()} ${this.lastName()}`); // Avoid this, use computed
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

4.5 Appropriate Use of effect

Rule: effect should be used only when you need to rely on a reactive value and the other end isn't reactive. This includes scenarios like:

  • Logging data.
  • Keeping data in sync with storage (e.g., localStorage).
  • Adding custom DOM behavior that cannot be expressed with template syntax.
  • Performing custom rendering to a , charting library, or other third-party UI library.

Explanation: effect is designed for side effects that occur in response to reactive value changes. It's not for deriving new reactive state.

Example:

// Good: Syncing state with localStorage
export class MyComponent {
  protected readonly settings = signal({ theme: 'light' });

  constructor() {
    effect(() => {
      localStorage.setItem('appSettings', JSON.stringify(this.settings()));
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

4.6 Use untracked in effect for Non-Reactive Signal Access

Rule: Use untracked within an effect whenever you need to access a signal's value but do not want that signal to become a dependency of the effect.

Explanation: By default, any signal accessed within an effect becomes a dependency, causing the effect to re-execute whenever that signal changes. untracked allows you to read a signal's value without creating this dependency, preventing unnecessary re-executions of the effect when only the untracked signal changes. This is useful when a signal's value is needed for a side effect, but the side effect itself doesn't need to react to changes in that specific signal.

Examples:

// Good: Using untracked to prevent unnecessary effect re-execution
import { effect, signal, untracked } from '@angular/core';

export class MyComponent {
  protected readonly mainData = signal('Important Data');
  protected readonly loggingPreference = signal(true); // This signal should not re-trigger the effect

  constructor() {
    effect(() => {
      // This effect reacts to changes in `mainData`
      const data = this.mainData();

      // Access `loggingPreference` without making it a dependency
      if (untracked(this.loggingPreference)) {
        console.log(`Effect re-executed. Data: ${data}`);
      } else {
        console.log(`Effect re-executed. Logging disabled.`);
      }
    });
  }
}

// Bad: Effect re-executes when `loggingPreference` changes, which might be unnecessary
export class MyComponent {
  protected readonly mainData = signal('Important Data');
  protected readonly loggingPreference = signal(true);

  constructor() {
    effect(() => {
      const data = this.mainData();
      // This effect will re-execute if `loggingPreference` changes, even if `mainData` is the primary trigger
      if (this.loggingPreference()) {
        console.log(`Effect re-executed. Data: ${data}`);
      } else {
        console.log(`Effect re-executed. Logging disabled.`);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

4.7 State Services for Shared Data

Rule: Use state services to share certain state across components, especially to avoid prop-drilling. These services should use a BehaviorSubject or signal to store the state. The state should be immutable and readonly, with updates only possible via methods within the service itself.

Explanation: State services provide a centralized and controlled way to manage application state, making it easier to reason about data flow and prevent unintended mutations.

Examples:

// Good: user-state.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { User } from './user.model';

@Injectable({ providedIn: 'root' })
export class UserStateService {
  readonly #currentUser = signal<User | null>(null);

  readonly currentUser = this.#currentUser.asReadonly();
  readonly isLoggedIn = computed(() => this.#currentUser() !== null);

  login(user: User): void {
    // Perform login logic, then update state
    this.#currentUser.set(user);
  }

  logout(): void {
    this.#currentUser.set(null);
  }
}

// Good: Component consuming the state service
import { Component, inject } from '@angular/core';
import { UserStateService } from './user-state.service';

@Component({...})
export class UserProfileComponent {
  readonly #userStateService = inject(UserStateService);
  protected readonly currentUser = this.#userStateService.currentUser; // Access readonly signal
  protected readonly isLoggedIn = this.#userStateService.isLoggedIn;

  logout(): void {
    this.#userStateService.logout();
  }
}
Enter fullscreen mode Exit fullscreen mode

4.8 Avoid Prop-Drilling

Rule: For direct component communication, use input() and output(). However, if data needs to be passed through multiple layers of components (prop-drilling), prefer creating a state service to share the data.

Explanation: Prop-drilling makes components tightly coupled and difficult to refactor. State services provide a more scalable solution for sharing data across distant components.

5. Change Detection

5.1 Always Use OnPush Change Detection

Rule: Always use OnPush change detection for components.

Explanation: OnPush change detection significantly improves performance by only checking components for changes when their inputs change, an event is emitted, or an observable they subscribe to emits a new value. This is crucial for large applications.

Example:

// Good
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-my-component',
  standalone: true,
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush // Always use OnPush
})
export class MyComponent {}
Enter fullscreen mode Exit fullscreen mode

6. Lifecycle Hooks

6.1 Avoid Lifecycle Hooks Where Possible

Rule: Try to avoid using traditional lifecycle hooks. Properties should be initialized alongside their declaration.

  • ngOnInit: Mostly replaceable with constructor logic or effect.
  • ngOnChanges: Replaceable with computed signals or effects reacting to input changes.
  • ngAfterViewInit: Replaceable with effect or viewChild.
  • ngAfterContentInit: Replaceable with effect or contentChild.
  • ngAfterContentChecked and ngAfterViewChecked: Replaceable with afterRenderEffect or afterNextRenderEffect.
  • ngOnDestroy: Replaceable by injecting DestroyRef and using takeUntilDestroyed for subscriptions, or DestroyRef.onDestroy(() => ...) for other cleanup.

Explanation: Modern Angular features (signals, inject, takeUntilDestroyed, afterRenderEffect) provide more granular and often more performant ways to manage component lifecycle concerns, reducing the need for traditional hooks.

6.2 Keep Lifecycle Hooks Simple (If Used)

Rule: If you absolutely need to use a lifecycle hook, make sure to keep them simple. Move complex logic to dedicated methods and just call them from the hook instead of placing all the logic directly inside the hook.

Explanation: This improves readability and testability of the logic within the hook.

Example:

// Good: Simple ngOnInit
export class MyComponent implements OnInit {
  ngOnInit(): void {
    this.loadData(); // Call a dedicated method
  }

  private loadData(): void {
    // Complex data loading logic here
  }
}

// Bad: Complex ngOnInit
export class MyComponent implements OnInit {
  ngOnInit(): void {
    // All complex data loading logic directly here
    // ... many lines of code ...
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Naming Conventions

7.1 Event Handler Naming

Rule: Name event handlers for what they do, not for the triggering event. Avoid generic names like handleClick or onButtonClick.

Explanation: Descriptive names make the code's intent clearer and improve maintainability.

Examples:

// Good
saveUser() { /* ... */ }
toggleSidebar() { /* ... */ }
submitForm() { /* ... */ }

// Bad
handleClick() { /* ... */ }
onButtonClick() { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

8. Internationalization (i18n)

8.1 "Key as a Value" for Translations

Rule: New texts should be added to the translation files using the "key as a value" rule. If the text is "Hello world", it should be added to the translations as: "Hello world": "Hello world".

Explanation: This simplifies the initial translation process, as the default language uses the text itself as the key, making it immediately readable. This also helps identify untranslated strings easily, and using libraries to extract translations from our code.

Example:

// en.json
{
  "Hello world": "Hello world",
  "Welcome to our app": "Welcome to our app"
}
Enter fullscreen mode Exit fullscreen mode

9. Code Organization

9.1 Access Modifiers

Rule: Use access modifiers as follows:

  • protected (by default): For properties or methods used inside the component class and its template.
  • private: For properties or methods used only inside the component class. For properties, prefer JavaScript private fields (#propertyName) over the private keyword.
  • public (no keyword): For properties or methods used outside the component (e.g., input(), output(), or methods called from other components). Do not add the public keyword, as properties and methods are public by default.

Explanation: This provides clear visibility boundaries for class members, improving encapsulation and making it easier to understand how components interact. Using JS private fields (#) offers true encapsulation at the language level.

Examples:

export class MyComponent {
  // Public property (no keyword needed) - e.g., an input()
  readonly data = input<Data>();

  // Protected property - accessible in template and class
  protected readonly isLoading = signal(false);

  // Private property - accessible only within the class, using JS private field
  readonly #counterService = inject(CounterService);

  // Protected method - accessible in template and class
  protected incrementCounter(): void {
    this.incrementLocalCounter();
  }

  // Private method - accessible only within the class
  private incrementLocalCounter(): void {
    this.#counterService.incrementCounter();
    console.log('Internal counter:', this.#counterService.count());
  }
}
Enter fullscreen mode Exit fullscreen mode

9.2 Import Order

Rule: Imports should be sorted in this order:

  1. External libraries (e.g., @angular/*, rxjs, lodash)
  2. Internal libraries (e.g., @app/shared, @app/core)
  3. Internal relative imports (e.g., ./my-service, ../models)

Explanation: Consistent import ordering makes files easier to read and navigate, and helps prevent merge conflicts.

Example:

// Good
import { Component, OnInit, inject, DestroyRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

import { SharedModule } from '@app/shared'; // Internal library
import { AuthService } from '@app/core/auth'; // Internal library

import { UserService } from './user.service'; // Relative import
import { User } from '../models/user.model'; // Relative import
Enter fullscreen mode Exit fullscreen mode

A Note on These Guidelines

These guidelines are a distillation of personal experience working on large Angular projects, combined with insights from the official Angular Style Guide and various articles on best practices.

Please be aware that these rules may contain errors and some are heavily opinionated. Your feedback is highly valued! Feel free to leave comments with corrections, suggestions, or to add other rules that you believe would be beneficial.

If you found this article helpful, please consider sharing it with your colleagues and network!

Top comments (0)