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: "...", /* ... */ };
}
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';
}
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>
}
}
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>
}
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) {
// ...
}
}
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 {}
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>
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 viainput()
, 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 { /* ... */ }
}
3.5 Template Property Order
Rule: In templates, element properties should be ordered as follows:
- Any id property
- Normal HTML attributes (e.g.,
src
,alt
,type
,placeholder
) - Property bindings (
[property]
) - Event bindings (
(event)
) - 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"
>
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
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();
}
}
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);
});
}
}
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
});
}
}
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()));
});
}
}
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.`);
}
});
}
}
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();
}
}
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 {}
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 oreffect
. -
ngOnChanges: Replaceable with
computed signals
oreffects
reacting toinput
changes. -
ngAfterViewInit: Replaceable with
effect
orviewChild
. -
ngAfterContentInit: Replaceable with
effect
orcontentChild
. -
ngAfterContentChecked and ngAfterViewChecked: Replaceable with
afterRenderEffect
orafterNextRenderEffect
. -
ngOnDestroy: Replaceable by injecting
DestroyRef
and usingtakeUntilDestroyed
for subscriptions, orDestroyRef.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 ...
}
}
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() { /* ... */ }
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"
}
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());
}
}
9.2 Import Order
Rule: Imports should be sorted in this order:
- External libraries (e.g.,
@angular/*
,rxjs
,lodash
) - Internal libraries (e.g.,
@app/shared
,@app/core
) - 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
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)