Skip to main content

Angular Modernization Guide

Bitwarden desires to update our Angular codebase to leverage modern Angular best practices, described under Angular. This guide provides a step-by-step approach on how to migrate existing Angular components and directives to align with these practices. New code should strive to follow these guidelines from the start.

warning

As usual when refactoring existing code, it's generally advisable to first put the code under tests. This provides a basic safety net against regressions during the migration. If the component has significant amount of business logic extracting it to a separate service first might make it easier to test.

After migrating we recommend performing a full regression sweep of the components to catch anything missed any unit tests.

info

We provide a Bitwarden specific Angular Modernization Claude Skill that performs most of these migrations automatically. It's strongly recommended to migrate a couple components yourself first to get familiar with the changes before using the automated tool.

Overview

Modern Angular emphasizes five core changes:

  1. Standalone Components — Self-contained components without NgModules
  2. Built-in Control Flow — New @if, @for, and @switch syntax replacing structural directives
  3. Signals — A new reactivity model replacing many RxJS patterns in components
  4. OnPush Change Detection — Performance-optimized change detection
  5. Updated Style Conventionsinject() over constructor injection, host over decorators

Migration Order

Some of the changes depend on each other. It's strongly recommended to adhere to this order while migrating:

  1. Standalone components
  2. Built-in control flow
  3. Migrate @Input() / @Output()
  4. Migrate queries (@ViewChild, @ContentChild, etc.)
  5. Convert component properties to signal() / computed()
  6. Replace template-bound functions with computed() signals
  7. Enable OnPush change detection
warning

Enabling OnPush before fully migrating to signals or reactive patterns may cause UI update issues.

Standalone Components

Use standalone components, directives and pipes. NgModules can still be used for grouping multiple components but the inner components should be standalone. Use Angular's Standalone Migration.

npx ng generate @angular/core:standalone
  1. Remove standalone: false in the @Component decorator
  2. Move imports from the NgModule directly to the component
  3. Import only what the component needs (tree-shakeable)
  4. Remove the component from NgModule declarations
// Before
@Component({
selector: "app-example",
standalone: false,
templateUrl: "./example.component.html",
})
export class ExampleComponent {}

// In module
@NgModule({
declarations: [ExampleComponent],
imports: [CommonModule, FormsModule],
})
export class ExampleModule {}

// After
@Component({
selector: "app-example",
templateUrl: "./example.component.html",
imports: [CommonModule, FormsModule],
})
export class ExampleComponent {}

Control Flow Syntax

Use Angular's built-in control flow over structural directives for better performance and type safety. Use Angular's Control Flow Migration.

Reference: Built-in control flow

<!-- Before: structural directives -->
<div *ngIf="isVisible()">Content</div>
<div *ngFor="let item of items(); trackBy: trackById">{{ item.name }}</div>

<!-- After: built-in control flow -->
@if (isVisible()) {
<div>Content</div>
} @for (item of items(); track item.id) {
<div>{{ item.name }}</div>
}

Signals

Signals provide a simpler reactivity model for Angular components. Use signals for all component-local state or purely presentational services. For a more in-depth guide to signals, see the Angular Signals Guide.

Signal Inputs

Replace @Input() with input() for reactive inputs. Use Angular's Signal Input Migration.

npx ng generate @angular/core:signal-input-migration
// Before
@Input() name: string = "";
@Input({ required: true }) id!: string;

// After
name = input<string>("");
id = input.required<string>();

Access via this.name() in code and name() in templates.

Signal Outputs

Replace @Output() with output(). Use Angular's Output Migration.

npx ng generate @angular/core:output-migration
// Before
@Output() save = new EventEmitter<string>();

// After
save = output<string>();

Emit via this.save.emit(value).

Signal Queries

Replace decorator-based queries with signal equivalents. Use Angular's Signal Queries Migration.

npx ng generate @angular/core:signal-queries-migration
// Before
@ViewChild("input") inputEl!: ElementRef;
@ViewChildren(ItemComponent) items!: QueryList<ItemComponent>;

// After
inputEl = viewChild<ElementRef>("input");
items = viewChildren(ItemComponent);

RxJS Interoperability

Signals and RxJS work together. Use these utilities for conversion.

Reference: RxJS Interop

import { toSignal, toObservable } from "@angular/core/rxjs-interop";

// Observable → Signal
private folders$ = this.folderService.folderViews$;
protected folders = toSignal(this.folders$, { initialValue: [] });

// Signal → Observable
private searchSignal = signal("");
private search$ = toObservable(this.searchSignal);

When to Use Each

Use SignalsUse RxJS
Component-local stateCross-client shared code
Simple derived stateComplex async operations
Template bindingsStreams requiring operators (debounce, merge, etc.)

OnPush Change Detection

OnPush improves performance by limiting when Angular checks a component for changes.

Reference: Skipping Component Subtrees

Enabling OnPush

@Component({
selector: "app-example",
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})

When Change Detection Runs

With OnPush, the component updates only when:

  • An input() signal or @Input() reference changes
  • An event is handled within the component or its children
  • A signal read in the template updates
  • An Observable with | async emits
  • ChangeDetectorRef.markForCheck() is called manually

Common Pitfalls

ProblemSolution
UI doesn't update after async operationUse signals or call markForCheck()
Mutating objects/arrays doesn't trigger updateCreate new references: [...arr], {...obj}
Service data changes aren't reflectedExpose data as signals or observables with async pipe

Dependency Injection

Use inject() instead of constructor injection for Angular primitives.

Reference: Dependency Injection

// Before
constructor(
private folderService: FolderService,
private route: ActivatedRoute,
) {}

// After
private folderService = inject(FolderService);
private route = inject(ActivatedRoute);

Note: Continue using constructor injection for code shared with non-Angular clients (CLI).

Class and Style Bindings

Prefer native [class] and [style] bindings over ngClass and ngStyle directives.

Reference: Class and Style Binding

Class Bindings

<!-- Avoid: ngClass directive -->
<div [ngClass]="{ 'active': isActive(), 'disabled': isDisabled() }"></div>

<!-- Prefer: class binding for single class -->
<div [class.active]="isActive()" [class.disabled]="isDisabled()"></div>

<!-- Prefer: class binding for multiple classes from a signal/computed -->
<div [class]="containerClasses()"></div>

With signals:

protected isActive = signal(false);
protected isDisabled = signal(false);

// For complex class logic, use computed
protected containerClasses = computed(() => {
const classes: string[] = ["base-container"];
if (this.isActive()) classes.push("active");
if (this.isDisabled()) classes.push("disabled");
return classes.join(" ");
});

Style Bindings

<!-- Avoid: ngStyle directive -->
<div [ngStyle]="{ 'width.px': width(), 'color': textColor() }"></div>

<!-- Prefer: style binding for individual properties -->
<div [style.width.px]="width()" [style.color]="textColor()"></div>

<!-- Prefer: style binding for multiple styles from a signal/computed -->
<div [style]="containerStyles()"></div>

With signals:

protected width = signal(100);
protected textColor = signal("blue");

// For complex style logic, use computed
protected containerStyles = computed(() => ({
width: `${this.width()}px`,
color: this.textColor(),
}));

Host Bindings

Use the host property instead of @HostBinding and @HostListener.

Reference: Host Elements

// Before
@HostBinding("class.active") isActive = false;
@HostListener("click") onClick() { /* ... */ }

// After
@Component({
host: {
"[class.active]": "isActive()",
"(click)": "onClick()",
},
})

Component Best Practices

Keep Components Thin

Components handle presentation only. Business logic belongs in services.

Protected Template Members

Use protected for members accessed only in templates:

protected isLoading = signal(false);
protected items = computed(() => this.filterItems());

Prefer computed() Over Functions in Templates

// Avoid: function called every change detection cycle
getDisplayName() {
return `${this.firstName()} ${this.lastName()}`;
}

// Prefer: computed, evaluated only when dependencies change
protected displayName = computed(() => `${this.firstName()} ${this.lastName()}`);

Use readonly for Constants

protected readonly maxItems = 10;

Additional Resources