Modern Angular (v18-v21+) - Beginner to Intermediate Handbook
V1
Back to handbooks index

Modern Angular (v18-v21+) - Beginner to Intermediate Handbook

A production-minded, signal-first field guide for modern Angular teams. This handbook is intentionally strict: standalone APIs, signal-based reactivity, and the new template control flow only.

Angular v18-v21+ Standalone by default Signals and SignalStore Updated April 2026
i
Acknowledgement: This handbook follows your strict modern Angular constraints. All architecture and snippets use standalone components, signals, signal-based inputs and outputs, and block syntax such as @if, @for, and @switch.

Table of Contents

  1. Module 1: The Modern Angular Paradigm (What is New)
  2. Module 2: Reactivity with Signals (The Core Update)
  3. Module 3: Component Architecture and Deferrable Views
  4. Module 4: Routing and Lazy Loading
  5. Module 5: State Management and NgRx SignalStore
  6. Module 6: Common Pitfalls and Anti-Patterns

Module 1: The Modern Angular Paradigm (What is New)

This module is your foundation. If you are new to Angular, focus on understanding why the framework changed. If you are already shipping apps, focus on how these changes reduce complexity, improve runtime performance, and simplify team onboarding.

i
Beginner goal: understand modern Angular terminology. Enterprise goal: adopt conventions that scale across teams and repositories.

1.1 The Death of NgModules

Think of old Angular like a mall where every store had to route through one giant central lobby (NgModule). Modern Angular is more like direct street access: each component declares what it needs and can be loaded directly. The result is less indirection, easier onboarding, and better tree-shaking because unused islands are easier for the build system to remove.

tip
Why this is better: standalone components reduce conceptual overhead for beginners and make feature-level lazy loading cleaner for teams shipping large production apps.

1.2 Bootstrapping with bootstrapApplication and app.config.ts

Modern bootstrapping is explicit and composable. App providers live in one place and can be tested or evolved independently.

In enterprise systems, this pattern helps isolate cross-cutting concerns such as authentication, telemetry, HTTP policies, and routing defaults. You can treat app.config.ts as your application's composition root.

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig)
  .catch((error: unknown) => {
    // Keep bootstrap failures visible in development and telemetry in production.
    console.error('Application bootstrap failed', error);
  });
// src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

1.3 New Control Flow: @if, @for, @switch

Old structural directives were attribute-level syntax bolted onto HTML. New block syntax reads like direct logic flow, similar to regular TypeScript control flow in templates.

When templates get large, block syntax remains easier to reason about in code reviews. That matters in enterprise codebases where multiple engineers touch the same feature over time.

<!-- user-list.component.html -->
@if (isLoading()) {
  <app-spinner />
} @else if (errorMessage()) {
  <p class="error">{{ errorMessage() }}</p>
} @else {
  <section class="list">
    @for (user of users(); track user.id) {
      <app-user-card [user]="user" />
    } @empty {
      <p>No users found.</p>
    }
  </section>
}

@switch (selectedRole()) {
  @case ('admin') {
    <app-admin-tools />
  }
  @case ('editor') {
    <app-editor-tools />
  }
  @default {
    <app-viewer-tools />
  }
}
!
Mandatory rule for @for: always provide a stable track expression such as track item.id. This prevents avoidable DOM teardown and re-creation during updates.
tip
Migration strategy: convert one feature at a time to block syntax, and add lint checks to prevent reintroducing legacy template directives.

Module 2: Reactivity with Signals (The Core Update)

Signals are the center of modern Angular programming. Learn this module deeply. Most day-to-day component work, local state handling, and template reactivity depends on these primitives.

2.1 What Are Signals?

A signal is a reactive value container that Angular can track with precision. Instead of broad, implicit change detection sweeps, Angular knows exactly what changed and where it is read. Real-world analogy: replacing a building-wide fire alarm with room-level smoke sensors. You respond only where needed.

2.2 Basic Signals: create, read, and update

// counter.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h3>Count: {{ count() }}</h3>
    <button type="button" (click)="increment()">+1</button>
    <button type="button" (click)="reset()">Reset</button>
  `,
})
export class CounterComponent {
  readonly count = signal(0);

  increment(): void {
    this.count.update((current) => current + 1);
  }

  reset(): void {
    this.count.set(0);
  }
}

Use .set() when replacing a value directly. Use .update() when next state depends on current state. This distinction avoids race-like logic and keeps state transitions explicit.

2.3 Computed and Effect with a practical example

// product-search.component.ts
import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core';

interface Product {
  id: string;
  name: string;
  category: 'hardware' | 'software';
}

@Component({
  selector: 'app-product-search',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input
      type="search"
      [value]="query()"
      (input)="setQuery(($any($event.target)).value)"
      placeholder="Search products" />

    <p>Showing {{ filteredProducts().length }} item(s)</p>

    @for (product of filteredProducts(); track product.id) {
      <div>{{ product.name }} - {{ product.category }}</div>
    }
  `,
})
export class ProductSearchComponent {
  readonly query = signal('');
  readonly products = signal([
    { id: 'p1', name: 'Angular Starter', category: 'software' },
    { id: 'p2', name: 'Dev Keyboard', category: 'hardware' },
    { id: 'p3', name: 'Signal Dashboard', category: 'software' },
  ]);

  readonly filteredProducts = computed(() => {
    const search = this.query().trim().toLowerCase();
    if (!search) {
      return this.products();
    }
    return this.products().filter((product) => product.name.toLowerCase().includes(search));
  });

  private readonly persistQuery = effect(() => {
    // Side effect: sync query to browser storage.
    localStorage.setItem('product-search-query', this.query());
  });

  setQuery(value: string): void {
    this.query.set(value);
  }
}

2.4 Signal Inputs, Outputs, and Model

Parent-child communication now feels more like function composition than metadata wiring. Signals keep data flow explicit.

Simple rule: use input() for one-way inbound data, output() for events, and model() for controlled two-way bindings.

// child-rating.component.ts
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';

@Component({
  selector: 'app-child-rating',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h4>{{ title() }}</h4>
    <p>Current rating: {{ rating() }} / 5</p>
    <button type="button" (click)="increase()">Increase</button>
    <button type="button" (click)="saved.emit(rating())">Save Rating</button>
  `,
})
export class ChildRatingComponent {
  readonly title = input.required();
  readonly rating = model(3);
  readonly saved = output();

  increase(): void {
    this.rating.update((value) => Math.min(value + 1, 5));
  }
}
// parent-dashboard.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ChildRatingComponent } from './child-rating.component';

@Component({
  selector: 'app-parent-dashboard',
  standalone: true,
  imports: [ChildRatingComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <app-child-rating
      [title]="'API Reliability'"
      [(rating)]="score"
      (saved)="onSaved($event)" />

    <p>Parent sees score: {{ score() }}</p>
  `,
})
export class ParentDashboardComponent {
  readonly score = signal(4);

  onSaved(value: number): void {
    console.log('Rating persisted:', value);
  }
}
i
Enterprise convention: reserve model() for genuinely shared edit state. Prefer explicit input() plus output() when domain actions should be auditable in logs and tests.

Module 3: Component Architecture and Deferrable Views

This module focuses on packaging and runtime cost. Beginner teams should use it to keep features simple. Enterprise teams should use it to protect first-load performance budgets.

3.1 Standalone Imports

Every component owns its dependencies directly. Analogy: each micro-kitchen has its own tools, instead of asking a distant central kitchen for everything.

For maintainability, keep component imports minimal and intentional. If imports grow too large, split the component into smaller presentation units.

// profile-editor.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-profile-editor',
  standalone: true,
  imports: [CommonModule, FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <label>
      Display Name
      <input [(ngModel)]="displayName" />
    </label>

    @if (displayName()) {
      <p>Preview: {{ displayName() }}</p>
    }
  `,
})
export class ProfileEditorComponent {
  readonly displayName = signal('');
}

3.2 Deferrable Views with @defer

Deferrable views delay expensive UI chunks until needed. Real-world analogy: opening only the rooms guests are currently using, instead of lighting the entire building on entry.

In enterprise web apps, this is one of the highest-impact optimizations for dashboards and analytics-heavy screens.

<!-- analytics-page.component.html -->
<h2>Revenue Analytics</h2>

@defer (on viewport) {
  <app-heavy-chart [data]="chartData()" />
} @placeholder {
  <div class="chart-placeholder">Chart will load when visible.</div>
} @loading (minimum 300ms) {
  <div class="chart-skeleton">Loading chart module...</div>
} @error {
  <p>Unable to load analytics chart.</p>
}
// analytics-page.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { HeavyChartComponent } from './heavy-chart.component';

interface ChartPoint {
  label: string;
  value: number;
}

@Component({
  selector: 'app-analytics-page',
  standalone: true,
  imports: [HeavyChartComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './analytics-page.component.html',
})
export class AnalyticsPageComponent {
  readonly chartData = signal([
    { label: 'Jan', value: 110 },
    { label: 'Feb', value: 140 },
    { label: 'Mar', value: 170 },
  ]);
}
tip
Production checklist: pair @defer with placeholder UX, route-level lazy loading, and performance monitoring so gains are measurable release over release.

Module 4: Routing and Lazy Loading

Routing is where architecture and user experience intersect. Good route design improves code ownership, navigation clarity, and deployment velocity.

4.1 Modern Router Setup

Define routes as data. This keeps navigation behavior easy to test and easy to split by feature teams.

// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/auth.guard';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'dashboard',
    pathMatch: 'full',
  },
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
    canActivate: [authGuard],
  },
  {
    path: 'users',
    loadComponent: () =>
      import('./features/users/users.page').then((m) => m.UsersPage),
  },
  {
    path: '**',
    loadComponent: () =>
      import('./shared/not-found.component').then((m) => m.NotFoundComponent),
  },
];
// main.ts (router provided at bootstrap)
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes)],
});

4.2 Lazy Loading a Standalone Component

Load features only when users need them. This cuts initial JavaScript and speeds first meaningful interaction.

For large apps, use lazy loading by default and only eager-load what is required for first paint and immediate user intent.

// features/reports/reports.routes.ts
import { Routes } from '@angular/router';

export const reportsRoutes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./reports-page.component').then((m) => m.ReportsPageComponent),
  },
];
// app.routes.ts (lazy route group)
{
  path: 'reports',
  loadChildren: () =>
    import('./features/reports/reports.routes').then((m) => m.reportsRoutes),
}

Module 5: State Management and NgRx

State strategy should match product complexity. Start simple with service-level signals. Move to SignalStore when cross-feature workflows and state governance become important.

5.1 Local Shared State with a Service and WritableSignal

For simple cross-component state, a service plus writable signals is often enough and keeps complexity low.

This pattern is ideal for carts, filter panels, selection state, and single-domain feature slices.

// cart.service.ts
import { Injectable, WritableSignal, computed, signal } from '@angular/core';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly _items: WritableSignal = signal([]);

  readonly items = this._items.asReadonly();
  readonly totalItems = computed(() => this._items().reduce((sum, item) => sum + item.quantity, 0));
  readonly totalPrice = computed(() => this._items().reduce((sum, item) => sum + item.price * item.quantity, 0));

  addItem(nextItem: Omit): void {
    this._items.update((items) => {
      const existing = items.find((item) => item.id === nextItem.id);
      if (existing) {
        return items.map((item) =>
          item.id === nextItem.id ? { ...item, quantity: item.quantity + 1 } : item,
        );
      }
      return [...items, { ...nextItem, quantity: 1 }];
    });
  }

  clear(): void {
    this._items.set([]);
  }
}
i
When to stay here: one domain, simple updates, low coordination between teams.

5.2 Why many teams prefer NgRx SignalStore now

Traditional Redux-style NgRx Store is powerful, but verbose for many teams. SignalStore keeps the discipline of centralized state while reducing boilerplate and fitting naturally with Angular signals. Think of it as getting enterprise-grade state organization with less ceremony.

5.3 Complete SignalStore snippet with state, computed, and methods

// users.store.ts
import { computed } from '@angular/core';
import {
  patchState,
  signalStore,
  withComputed,
  withMethods,
  withState,
} from '@ngrx/signals';

interface User {
  id: string;
  name: string;
  role: 'admin' | 'editor' | 'viewer';
}

interface UsersState {
  users: User[];
  search: string;
  loading: boolean;
}

const initialState: UsersState = {
  users: [],
  search: '',
  loading: false,
};

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withComputed(({ users, search }) => ({
    filteredUsers: computed(() => {
      const term = search().trim().toLowerCase();
      if (!term) {
        return users();
      }
      return users().filter((user) => user.name.toLowerCase().includes(term));
    }),
    totalUsers: computed(() => users().length),
  })),
  withMethods((store) => ({
    setSearch(value: string): void {
      patchState(store, { search: value });
    },
    setLoading(value: boolean): void {
      patchState(store, { loading: value });
    },
    setUsers(users: User[]): void {
      patchState(store, { users });
    },
    addUser(user: User): void {
      patchState(store, { users: [...store.users(), user] });
    },
    removeUser(userId: string): void {
      patchState(store, {
        users: store.users().filter((user) => user.id !== userId),
      });
    },
  })),
);
tip
Enterprise extension path: add withHooks for lifecycle orchestration and isolate API calls in dedicated data services used by store methods.

Module 6: Common Pitfalls and Anti-Patterns

This module is designed to save months of refactoring. These mistakes are common during migration from older Angular patterns and from RxJS-heavy codebases.

6.1 Pitfall: Confusing RxJS and Signals

Rule of thumb: use RxJS for asynchronous event streams (HTTP, websockets, multi-event composition), and signals for synchronous UI state and derivations.

In enterprise apps, treat RxJS as your async transport layer and signals as your UI state layer. Cross that boundary deliberately and minimally.

// GOOD: HTTP stream stays in Observable world, then adapted once for UI signals.
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { UserApiService } from './user-api.service';

@Component({
  selector: 'app-users-page',
  standalone: true,
  template: `
    @if (users().length) {
      @for (user of users(); track user.id) {
        <p>{{ user.name }}</p>
      }
    } @else {
      <p>No users yet.</p>
    }
  `,
})
export class UsersPage {
  readonly users = toSignal(this.userApiService.getUsers(), { initialValue: [] });

  constructor(private readonly userApiService: UserApiService) {}
}

6.2 Pitfall: Mutating signal objects directly

Signals are immutable by update contract. Mutating nested properties directly bypasses safe update semantics and can create stale UI behavior.

Teach teams to think in immutable updates. That mindset improves predictability, testing quality, and debugging speed.

// BAD: direct mutation of nested state object.
this.profile().preferences.theme = 'dark';

// GOOD: always replace through update.
this.profile.update((current) => ({
  ...current,
  preferences: {
    ...current.preferences,
    theme: 'dark',
  },
}));
!
Code review rule: reject direct object mutation on signal values. Require set or update in every state transition.

6.3 Pitfall: Overusing effect() for derived state

Using effects to push values into other signals causes hidden write chains and debugging pain. If value B is derived from value A, model it with computed().

Keep your reactive graph declarative. Effects should integrate with external systems, not replace derivation logic.

// BAD: effect used to derive state into another writable signal.
readonly firstName = signal('Ada');
readonly lastName = signal('Lovelace');
readonly fullName = signal('');

private readonly syncName = effect(() => {
  this.fullName.set(`${this.firstName()} ${this.lastName()}`);
});

// GOOD: derivation is declarative with computed.
readonly firstName2 = signal('Ada');
readonly lastName2 = signal('Lovelace');
readonly fullName2 = computed(() => `${this.firstName2()} ${this.lastName2()}`);
!
Production guidance: reserve effect() for side effects only, such as logging, analytics, localStorage sync, or imperative bridge code.
i
Team-level maturity checklist: lint rules for template control flow, immutable state updates in reviews, route-level lazy loading by default, and shared store conventions documented per feature.