Back to blog

Angular Core Concepts: The Complete Guide

angulartypescriptfrontendweb-developmentframework
Angular Core Concepts: The Complete Guide

Angular is one of the most powerful and opinionated frontend frameworks out there. Unlike React, which gives you freedom to pick your own tools, Angular comes with batteries included — routing, forms, HTTP client, dependency injection, and more — all built-in and working together. If you've been working with backend frameworks like Spring Boot or .NET, Angular's architecture will feel familiar.

In this post, we'll deep-dive into Angular's core concepts that every developer must understand to build scalable, maintainable applications.

What You'll Learn

✅ Understand Angular's component-based architecture
✅ Master dependency injection — Angular's most powerful feature
✅ Navigate component lifecycle hooks with confidence
✅ Build custom directives and pipes
✅ Work with templates, data binding, and change detection
✅ Get started with RxJS and reactive patterns in Angular
✅ Navigate between pages with Angular Router
✅ Build type-safe forms with Reactive Forms

Prerequisites: TypeScript fundamentals, basic HTML/CSS knowledge
Time commitment: 3–5 days, 1–2 hours daily

Angular Architecture Overview

Before diving into individual concepts, let's understand how Angular applications are structured:

Angular organizes code into modules that contain components, services, directives, and pipes. Each component has a template, styles, and logic. Services are shared via dependency injection.

Note: Starting with Angular 17, standalone components are the default. You no longer need NgModule for most use cases. We'll use standalone components throughout this guide.

1. Components — The Building Blocks

Components are the fundamental building blocks of Angular applications. Every piece of UI is a component — from the entire page down to a single button.

Anatomy of a Component

import { Component } from '@angular/core';
 
@Component({
  selector: 'app-greeting',
  standalone: true,
  template: `
    <div class="greeting">
      <h2>Hello, {{ name }}!</h2>
      <p>Welcome to Angular</p>
    </div>
  `,
  styles: [`
    .greeting {
      padding: 1rem;
      border: 1px solid #ccc;
      border-radius: 8px;
    }
  `]
})
export class GreetingComponent {
  name = 'Developer';
}

A component has four parts:

PartPurpose
@Component decoratorMetadata — selector, template, styles
selectorThe HTML tag used to render this component (<app-greeting>)
template / templateUrlThe HTML structure (inline or external file)
Class bodyComponent logic, properties, and methods

Component Communication

Components communicate through inputs and outputs:

import { Component, input, output } from '@angular/core';
 
@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div class="card" (click)="cardClicked.emit(userId())">
      <h3>{{ userName() }}</h3>
      <span>{{ role() }}</span>
    </div>
  `
})
export class UserCardComponent {
  // Signal-based inputs (Angular 17+)
  userName = input.required<string>();
  role = input<string>('member');
  userId = input.required<number>();
 
  // Signal-based output
  cardClicked = output<number>();
}

Usage in the parent component:

<app-user-card
  [userName]="'Alice'"
  [role]="'admin'"
  [userId]="42"
  (cardClicked)="onCardClick($event)"
/>

Content Projection

Angular supports content projection (similar to React's children):

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <div class="card-header">
        <ng-content select="[card-title]" />
      </div>
      <div class="card-body">
        <ng-content />
      </div>
      <div class="card-footer">
        <ng-content select="[card-actions]" />
      </div>
    </div>
  `
})
export class CardComponent {}
<app-card>
  <h2 card-title>My Title</h2>
  <p>This goes in the body (default slot)</p>
  <button card-actions>Save</button>
</app-card>

2. Templates and Data Binding

Angular's template syntax provides powerful ways to connect your component logic to the DOM.

The Four Types of Data Binding

@Component({
  selector: 'app-binding-demo',
  standalone: true,
  template: `
    <!-- 1. Interpolation: Component → View -->
    <h1>{{ title }}</h1>
    <p>{{ getFullName() }}</p>
 
    <!-- 2. Property Binding: Component → View -->
    <img [src]="imageUrl" [alt]="imageAlt">
    <button [disabled]="isLoading">Submit</button>
 
    <!-- 3. Event Binding: View → Component -->
    <button (click)="onSubmit()">Submit</button>
    <input (keyup.enter)="onSearch($event)">
 
    <!-- 4. Two-Way Binding: Component ↔ View -->
    <input [(ngModel)]="searchQuery">
    <p>Searching for: {{ searchQuery }}</p>
  `
})
export class BindingDemoComponent {
  title = 'Data Binding Demo';
  imageUrl = '/assets/logo.png';
  imageAlt = 'Logo';
  isLoading = false;
  searchQuery = '';
 
  getFullName() {
    return 'John Doe';
  }
 
  onSubmit() {
    this.isLoading = true;
  }
 
  onSearch(event: KeyboardEvent) {
    console.log('Searching:', this.searchQuery);
  }
}

Summary:

SyntaxDirectionExample
{{ value }}Component → View{{ title }}
[property]="value"Component → View[disabled]="isLoading"
(event)="handler()"View → Component(click)="onSave()"
[(ngModel)]="value"Both directions[(ngModel)]="name"

Control Flow (Angular 17+ Built-in Syntax)

Angular 17 introduced a new, cleaner control flow syntax:

<!-- Conditional rendering -->
@if (user) {
  <h2>Welcome, {{ user.name }}!</h2>
} @else if (isLoading) {
  <p>Loading...</p>
} @else {
  <p>Please sign in</p>
}
 
<!-- Looping -->
@for (item of items; track item.id) {
  <app-item-card [item]="item" />
} @empty {
  <p>No items found</p>
}
 
<!-- Switch -->
@switch (status) {
  @case ('active') {
    <span class="badge green">Active</span>
  }
  @case ('inactive') {
    <span class="badge red">Inactive</span>
  }
  @default {
    <span class="badge gray">Unknown</span>
  }
}

Why track? Angular uses track to identify each item in a list. This allows Angular to efficiently update the DOM when items change, add, or remove — similar to React's key prop.

3. Dependency Injection — Angular's Superpower

Dependency Injection (DI) is arguably Angular's most powerful feature. If you've worked with Spring Boot or .NET, you'll feel right at home. If not, think of it as a system that automatically provides your components with the services they need.

Why DI Matters

Without DI:

// ❌ Tight coupling — hard to test, hard to swap implementations
export class UserComponent {
  private userService = new UserService(new HttpClient(), new AuthService());
  private logger = new LoggerService();
}

With DI:

// ✅ Loose coupling — easy to test, easy to swap
export class UserComponent {
  constructor(
    private userService: UserService,
    private logger: LoggerService
  ) {}
}

Creating a Service

import { Injectable } from '@angular/core';
 
@Injectable({
  providedIn: 'root' // Singleton — one instance for the entire app
})
export class UserService {
  private apiUrl = '/api/users';
 
  constructor(private http: HttpClient) {}
 
  getUsers() {
    return this.http.get<User[]>(this.apiUrl);
  }
 
  getUserById(id: number) {
    return this.http.get<User>(`${this.apiUrl}/${id}`);
  }
 
  createUser(user: CreateUserDto) {
    return this.http.post<User>(this.apiUrl, user);
  }
}

The inject() Function (Modern Approach)

Angular 14+ introduced the inject() function as an alternative to constructor injection:

import { Component, inject } from '@angular/core';
 
@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    @for (user of users; track user.id) {
      <div>{{ user.name }}</div>
    }
  `
})
export class UserListComponent {
  private userService = inject(UserService);
  private router = inject(Router);
 
  users: User[] = [];
 
  ngOnInit() {
    this.userService.getUsers().subscribe(users => {
      this.users = users;
    });
  }
}

inject() vs constructor injection: Both work, but inject() is preferred in modern Angular. It's more concise, works in functional guards/resolvers, and doesn't require boilerplate constructor parameters.

Provider Scopes

DI scope controls how many instances of a service exist:

// Root scope — Singleton (most common)
@Injectable({ providedIn: 'root' })
export class AuthService {}
 
// Component scope — New instance per component
@Component({
  selector: 'app-editor',
  providers: [EditorStateService], // Each <app-editor> gets its own instance
  template: `...`
})
export class EditorComponent {}

Injection Tokens and Interfaces

Since TypeScript interfaces don't exist at runtime, use InjectionToken for non-class dependencies:

import { InjectionToken } from '@angular/core';
 
// Define the token
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
 
// Provide the value
// In app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: API_BASE_URL, useValue: 'https://api.example.com' },
    { provide: APP_CONFIG, useFactory: () => loadConfig() },
  ]
};
 
// Inject the value
@Injectable({ providedIn: 'root' })
export class ApiService {
  private baseUrl = inject(API_BASE_URL);
}

Provider Types

Angular supports several ways to provide dependencies:

providers: [
  // useClass — Provide a class (default behavior)
  { provide: LoggerService, useClass: ConsoleLoggerService },
 
  // useValue — Provide a static value
  { provide: API_URL, useValue: 'https://api.example.com' },
 
  // useFactory — Provide via a factory function
  {
    provide: DataService,
    useFactory: (http: HttpClient, config: AppConfig) => {
      return config.useMock
        ? new MockDataService()
        : new RealDataService(http);
    },
    deps: [HttpClient, APP_CONFIG]
  },
 
  // useExisting — Alias to another provider
  { provide: AbstractLogger, useExisting: ConsoleLoggerService },
]

4. Lifecycle Hooks

Every component in Angular goes through a lifecycle — from creation to destruction. Angular provides hooks that let you tap into key moments of this lifecycle.

The Complete Lifecycle

Hook-by-Hook Breakdown

import {
  Component,
  OnInit,
  OnDestroy,
  OnChanges,
  AfterViewInit,
  SimpleChanges,
  input
} from '@angular/core';
 
@Component({
  selector: 'app-lifecycle-demo',
  standalone: true,
  template: `<p>{{ message() }}</p>`
})
export class LifecycleDemoComponent
  implements OnInit, OnDestroy, OnChanges, AfterViewInit {
 
  message = input<string>('');
  private subscription?: Subscription;
 
  // Called BEFORE ngOnInit, whenever input properties change
  ngOnChanges(changes: SimpleChanges) {
    if (changes['message']) {
      console.log(
        'Previous:', changes['message'].previousValue,
        'Current:', changes['message'].currentValue
      );
    }
  }
 
  // Called ONCE after the first ngOnChanges
  // Best place for: API calls, initial setup
  ngOnInit() {
    console.log('Component initialized');
    this.subscription = this.dataService.getData().subscribe(/* ... */);
  }
 
  // Called after the component's view (and child views) are initialized
  // Best place for: DOM manipulation, ViewChild access
  ngAfterViewInit() {
    console.log('View is ready');
  }
 
  // Called right before Angular destroys the component
  // Best place for: Cleanup — unsubscribe, clear timers, detach listeners
  ngOnDestroy() {
    console.log('Cleaning up');
    this.subscription?.unsubscribe();
  }
}

When to Use Each Hook

HookRunsUse For
ngOnChangesOn every input changeReacting to input property changes
ngOnInitOnce, after first ngOnChangesAPI calls, initial data fetch, setup logic
ngDoCheckEvery change detection cycleCustom change detection logic (use sparingly!)
ngAfterContentInitOnce, after content projectionAccessing projected content (<ng-content>)
ngAfterViewInitOnce, after view rendersDOM access, @ViewChild access
ngAfterViewCheckedAfter every view checkResponding to view changes (use sparingly!)
ngOnDestroyOnce, before component is removedCleanup: unsubscribe, clear timers, detach listeners

The Golden Rule: Always Clean Up

export class DashboardComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
 
  ngOnInit() {
    // Using takeUntil to auto-unsubscribe
    this.dataService.getUpdates()
      .pipe(takeUntil(this.destroy$))
      .subscribe(data => this.updateDashboard(data));
 
    this.notificationService.getNotifications()
      .pipe(takeUntil(this.destroy$))
      .subscribe(notification => this.showNotification(notification));
  }
 
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Modern alternative: Angular 16+ introduced the DestroyRef and takeUntilDestroyed helper to simplify cleanup:

export class DashboardComponent {
  private destroyRef = inject(DestroyRef);
 
  ngOnInit() {
    this.dataService.getUpdates()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(data => this.updateDashboard(data));
  }
}

5. Directives

Directives let you extend HTML with custom behavior. Think of them as "instructions" that Angular applies to DOM elements.

Types of Directives

Built-in Attribute Directives

<!-- ngClass — Dynamically add/remove CSS classes -->
<div [ngClass]="{ 'active': isActive, 'error': hasError }">
  Conditional classes
</div>
 
<!-- ngStyle — Dynamically set inline styles -->
<div [ngStyle]="{ 'font-size': fontSize + 'px', 'color': textColor }">
  Dynamic styles
</div>

Custom Attribute Directive

Let's build a highlight directive that changes an element's background on hover:

import { Directive, ElementRef, HostListener, input } from '@angular/core';
 
@Directive({
  selector: '[appHighlight]',
  standalone: true
})
export class HighlightDirective {
  appHighlight = input<string>('#ffffcc');
 
  constructor(private el: ElementRef) {}
 
  @HostListener('mouseenter')
  onMouseEnter() {
    this.setBackground(this.appHighlight() || '#ffffcc');
  }
 
  @HostListener('mouseleave')
  onMouseLeave() {
    this.setBackground('');
  }
 
  private setBackground(color: string) {
    this.el.nativeElement.style.backgroundColor = color;
  }
}

Usage:

<p appHighlight>Hover me — default yellow highlight</p>
<p [appHighlight]="'lightblue'">Hover me — blue highlight</p>

Custom Structural Directive

A directive that renders content only for specific user roles:

import { Directive, TemplateRef, ViewContainerRef, input, effect } from '@angular/core';
 
@Directive({
  selector: '[appIfRole]',
  standalone: true
})
export class IfRoleDirective {
  appIfRole = input.required<string>();
  private authService = inject(AuthService);
  private hasView = false;
 
  constructor(
    private templateRef: TemplateRef<unknown>,
    private viewContainer: ViewContainerRef
  ) {
    effect(() => {
      const requiredRole = this.appIfRole();
      const hasRole = this.authService.hasRole(requiredRole);
 
      if (hasRole && !this.hasView) {
        this.viewContainer.createEmbeddedView(this.templateRef);
        this.hasView = true;
      } else if (!hasRole && this.hasView) {
        this.viewContainer.clear();
        this.hasView = false;
      }
    });
  }
}

Usage:

<button appIfRole="admin">Delete User</button>
<div appIfRole="editor">
  <app-content-editor />
</div>

6. Pipes — Transform Display Data

Pipes transform data in templates. They take a value, process it, and return a formatted output — all without changing the original data.

Built-in Pipes

<!-- Date formatting -->
<p>{{ today | date:'fullDate' }}</p>
<!-- Output: Monday, March 2, 2026 -->
 
<p>{{ today | date:'shortTime' }}</p>
<!-- Output: 10:30 AM -->
 
<!-- Currency -->
<p>{{ price | currency:'USD' }}</p>
<!-- Output: $1,299.99 -->
 
<!-- Text transforms -->
<p>{{ 'hello world' | uppercase }}</p>
<!-- Output: HELLO WORLD -->
 
<p>{{ 'Hello World' | lowercase }}</p>
<!-- Output: hello world -->
 
<p>{{ 'hello world' | titlecase }}</p>
<!-- Output: Hello World -->
 
<!-- JSON display (great for debugging) -->
<pre>{{ user | json }}</pre>
 
<!-- Async pipe — auto-subscribes to Observables -->
<div>{{ userData$ | async }}</div>
 
<!-- Slice (array or string) -->
<p>{{ items | slice:0:5 }}</p>

The async Pipe — Your Best Friend

The async pipe automatically subscribes and unsubscribes from Observables:

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (users$ | async; as users) {
      @for (user of users; track user.id) {
        <div>{{ user.name }}</div>
      }
    } @else {
      <p>Loading users...</p>
    }
  `
})
export class UserListComponent {
  users$ = inject(UserService).getUsers();
}

Why async pipe? It handles subscription/unsubscription automatically — no memory leaks, no ngOnDestroy cleanup needed.

Custom Pipe

A pipe that shows relative time ("2 hours ago", "yesterday"):

import { Pipe, PipeTransform } from '@angular/core';
 
@Pipe({
  name: 'timeAgo',
  standalone: true
})
export class TimeAgoPipe implements PipeTransform {
  transform(value: Date | string): string {
    const date = new Date(value);
    const now = new Date();
    const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
 
    const intervals: [number, string][] = [
      [31536000, 'year'],
      [2592000, 'month'],
      [86400, 'day'],
      [3600, 'hour'],
      [60, 'minute'],
    ];
 
    for (const [secondsInUnit, unit] of intervals) {
      const count = Math.floor(seconds / secondsInUnit);
      if (count >= 1) {
        return count === 1
          ? `1 ${unit} ago`
          : `${count} ${unit}s ago`;
      }
    }
 
    return 'just now';
  }
}

Usage:

<p>Posted {{ post.createdAt | timeAgo }}</p>
<!-- Output: "3 hours ago", "2 days ago", etc. -->

Pure vs Impure Pipes

// Pure pipe (default) — only runs when input reference changes
@Pipe({ name: 'filter', pure: true })
export class FilterPipe implements PipeTransform {
  transform(items: Item[], query: string): Item[] {
    return items.filter(item => item.name.includes(query));
  }
}
 
// Impure pipe — runs on EVERY change detection cycle
// ⚠️ Use sparingly — performance impact!
@Pipe({ name: 'filterImpure', pure: false })
export class FilterImpurePipe implements PipeTransform {
  transform(items: Item[], query: string): Item[] {
    return items.filter(item => item.name.includes(query));
  }
}

Rule of thumb: Always use pure pipes. If you think you need an impure pipe, you probably need to rethink your data flow.

7. Change Detection

Change detection is how Angular keeps the view in sync with the component's data. Understanding it is crucial for building performant applications.

How Change Detection Works

By default, Angular checks every component in the tree from top to bottom whenever anything happens. This is called the Default change detection strategy.

OnPush Strategy — Performance Optimization

@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h3>{{ user().name }}</h3>
      <p>{{ user().email }}</p>
    </div>
  `
})
export class UserCardComponent {
  user = input.required<User>();
}

With OnPush, Angular only checks the component when:

  1. An @Input reference changes (not just a property mutation)
  2. An event handler in the component fires
  3. An Observable linked via async pipe emits
  4. ChangeDetectorRef.markForCheck() is called manually

Best practice: Use OnPush for all components. Combined with signals (Angular 17+), this gives you excellent performance by default.

Signals — The Future of Change Detection

Angular 17+ introduces Signals as a reactive primitive:

import { Component, signal, computed, effect } from '@angular/core';
 
@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubleCount() }}</p>
    <button (click)="increment()">+1</button>
    <button (click)="reset()">Reset</button>
  `
})
export class CounterComponent {
  // Writable signal
  count = signal(0);
 
  // Computed signal — derived from other signals
  doubleCount = computed(() => this.count() * 2);
 
  constructor() {
    // Effect — runs side effects when signals change
    effect(() => {
      console.log('Count changed:', this.count());
    });
  }
 
  increment() {
    this.count.update(c => c + 1);
  }
 
  reset() {
    this.count.set(0);
  }
}

Signals provide fine-grained reactivity — Angular knows exactly which parts of the template need updating, without checking the entire component tree.

8. RxJS and Reactive Programming

Angular uses RxJS (Reactive Extensions for JavaScript) extensively for handling asynchronous operations. Understanding the basics is essential.

Core RxJS Concepts

Common Patterns in Angular

HTTP requests:

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private apiUrl = '/api/products';
 
  // Returns an Observable
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }
 
  // Using operators to transform data
  getActiveProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl).pipe(
      map(products => products.filter(p => p.isActive)),
      catchError(error => {
        console.error('Failed to load products:', error);
        return of([]); // Return empty array on error
      })
    );
  }
}

Search with debounce:

@Component({
  selector: 'app-search',
  standalone: true,
  imports: [ReactiveFormsModule, AsyncPipe],
  template: `
    <input [formControl]="searchControl" placeholder="Search...">
    @for (result of results$ | async; track result.id) {
      <div>{{ result.name }}</div>
    }
  `
})
export class SearchComponent {
  private searchService = inject(SearchService);
  searchControl = new FormControl('');
 
  results$ = this.searchControl.valueChanges.pipe(
    debounceTime(300),        // Wait 300ms after last keystroke
    distinctUntilChanged(),   // Skip if same value
    filter(query => !!query && query.length >= 2), // Min 2 chars
    switchMap(query =>        // Cancel previous request
      this.searchService.search(query!)
    )
  );
}

Essential RxJS Operators

OperatorPurposeExample Use Case
mapTransform emitted valuesConvert API response
filterFilter values that pass a testSkip empty values
tapSide effects (logging, debugging)tap(x => console.log(x))
switchMapCancel previous, subscribe to newSearch, route params
mergeMapSubscribe to all (parallel)Bulk operations
concatMapSubscribe sequentially (ordered)Sequential file uploads
catchErrorHandle errorsAPI error handling
debounceTimeWait for pause in emissionsSearch input
distinctUntilChangedSkip consecutive duplicatesSearch input
takeUntilComplete when notifier emitsComponent cleanup
combineLatestCombine latest from multipleMultiple data sources

Subjects — Observable + Observer

@Injectable({ providedIn: 'root' })
export class NotificationService {
  // BehaviorSubject — holds current value, emits to new subscribers
  private notificationsSubject = new BehaviorSubject<Notification[]>([]);
  notifications$ = this.notificationsSubject.asObservable();
 
  addNotification(notification: Notification) {
    const current = this.notificationsSubject.value;
    this.notificationsSubject.next([...current, notification]);
  }
 
  clearAll() {
    this.notificationsSubject.next([]);
  }
}

9. Putting It All Together — A Real Example

Let's combine everything into a practical todo list feature:

// todo.model.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: Date;
}
 
// todo.service.ts
@Injectable({ providedIn: 'root' })
export class TodoService {
  private http = inject(HttpClient);
  private todosUrl = '/api/todos';
 
  getTodos(): Observable<Todo[]> {
    return this.http.get<Todo[]>(this.todosUrl);
  }
 
  addTodo(title: string): Observable<Todo> {
    return this.http.post<Todo>(this.todosUrl, { title, completed: false });
  }
 
  toggleTodo(todo: Todo): Observable<Todo> {
    return this.http.patch<Todo>(
      `${this.todosUrl}/${todo.id}`,
      { completed: !todo.completed }
    );
  }
 
  deleteTodo(id: number): Observable<void> {
    return this.http.delete<void>(`${this.todosUrl}/${id}`);
  }
}
 
// todo-list.component.ts
@Component({
  selector: 'app-todo-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [AsyncPipe, TimeAgoPipe, HighlightDirective],
  template: `
    <h2>My Todos</h2>
 
    <form (submit)="addTodo($event)">
      <input
        #todoInput
        placeholder="What needs to be done?"
        appHighlight="'#e8f5e9'"
      >
      <button type="submit">Add</button>
    </form>
 
    @if (todos$ | async; as todos) {
      @for (todo of todos; track todo.id) {
        <div
          class="todo-item"
          [class.completed]="todo.completed"
        >
          <input
            type="checkbox"
            [checked]="todo.completed"
            (change)="toggle(todo)"
          >
          <span>{{ todo.title }}</span>
          <small>{{ todo.createdAt | timeAgo }}</small>
          <button (click)="delete(todo.id)">Delete</button>
        </div>
      } @empty {
        <p>No todos yet. Add one above!</p>
      }
    } @else {
      <p>Loading...</p>
    }
  `
})
export class TodoListComponent {
  private todoService = inject(TodoService);
 
  private refresh$ = new BehaviorSubject<void>(undefined);
 
  todos$ = this.refresh$.pipe(
    switchMap(() => this.todoService.getTodos())
  );
 
  @ViewChild('todoInput') todoInput!: ElementRef<HTMLInputElement>;
 
  addTodo(event: Event) {
    event.preventDefault();
    const title = this.todoInput.nativeElement.value.trim();
    if (!title) return;
 
    this.todoService.addTodo(title).subscribe(() => {
      this.todoInput.nativeElement.value = '';
      this.refresh$.next();
    });
  }
 
  toggle(todo: Todo) {
    this.todoService.toggleTodo(todo).subscribe(() => {
      this.refresh$.next();
    });
  }
 
  delete(id: number) {
    this.todoService.deleteTodo(id).subscribe(() => {
      this.refresh$.next();
    });
  }
}

This example demonstrates:

  • Components with template binding and event handling
  • Services with HTTP calls and DI via inject()
  • Pipes (async, custom timeAgo)
  • Directives (custom appHighlight)
  • RxJS patterns (BehaviorSubject, switchMap)
  • OnPush change detection
  • Modern Angular (standalone, @if/@for syntax)

10. Angular Router — Navigation Between Pages

Angular Router is the official navigation library and is deeply integrated with the framework. Every Angular app beyond a single page needs it.

Basic Setup (Standalone API)

// app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductListComponent } from './products/product-list.component';
import { ProductDetailComponent } from './products/product-detail.component';
import { NotFoundComponent } from './not-found.component';
 
export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'products', component: ProductListComponent },
  { path: 'products/:id', component: ProductDetailComponent },
  { path: '**', component: NotFoundComponent }, // Wildcard — 404
];
 
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
 
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
  ]
});
<!-- app.component.html — router outlet renders the matched component -->
<nav>
  <a routerLink="/">Home</a>
  <a routerLink="/products">Products</a>
</nav>
 
<router-outlet />

Reading Route Parameters

@Component({
  selector: 'app-product-detail',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (product$ | async; as product) {
      <h1>{{ product.name }}</h1>
      <p>{{ product.description }}</p>
    }
  `
})
export class ProductDetailComponent {
  private route = inject(ActivatedRoute);
  private productService = inject(ProductService);
 
  // Read route param reactively — updates when param changes
  product$ = this.route.paramMap.pipe(
    map(params => params.get('id')!),
    switchMap(id => this.productService.getProduct(id))
  );
}

Lazy Loading — Split Your Bundle

Lazy loading is Angular Router's killer feature: it loads feature modules only when the user navigates to them.

// app.routes.ts — lazy load the admin feature
export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'admin',
    loadChildren: () =>
      import('./admin/admin.routes').then(m => m.adminRoutes),
  },
  {
    path: 'products',
    loadComponent: () =>
      import('./products/product-list.component').then(m => m.ProductListComponent),
  },
];
 
// admin/admin.routes.ts — loaded only when user navigates to /admin
export const adminRoutes: Routes = [
  { path: '', component: AdminDashboardComponent },
  { path: 'users', component: AdminUsersComponent },
  { path: 'settings', component: AdminSettingsComponent },
];

This keeps the initial bundle small — users only download code for the pages they visit.

Route Guards — Protect Routes

Guards decide whether navigation is allowed:

// auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { map } from 'rxjs';
 
export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
 
  return auth.isLoggedIn$.pipe(
    map(isLoggedIn => {
      if (isLoggedIn) return true;
      return router.createUrlTree(['/login']); // Redirect to login
    })
  );
};
 
// Apply to protected routes
export const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
    canActivate: [authGuard], // Guard applied here
  },
];

Programmatic Navigation

@Component({ standalone: true })
export class LoginComponent {
  private router = inject(Router);
  private auth = inject(AuthService);
 
  login(credentials: Credentials) {
    this.auth.login(credentials).subscribe(() => {
      // Navigate after successful login
      this.router.navigate(['/dashboard']);
      // With query params:
      this.router.navigate(['/products'], { queryParams: { sort: 'price' } });
    });
  }
}

11. Reactive Forms — Type-Safe Form Handling

Angular provides two approaches to forms: Template-driven forms (simple, directive-based) and Reactive Forms (explicit, scalable, testable). For anything beyond a simple input, use Reactive Forms.

Building a Form

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { inject } from '@angular/core';
 
@Component({
  selector: 'app-register',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
 
      <label>Name</label>
      <input formControlName="name" />
      @if (form.get('name')?.invalid && form.get('name')?.touched) {
        <span class="error">Name is required (min 2 chars)</span>
      }
 
      <label>Email</label>
      <input formControlName="email" type="email" />
      @if (form.get('email')?.errors?.['email'] && form.get('email')?.touched) {
        <span class="error">Enter a valid email</span>
      }
 
      <label>Password</label>
      <input formControlName="password" type="password" />
 
      <label>Confirm Password</label>
      <input formControlName="confirmPassword" type="password" />
      @if (form.errors?.['passwordMismatch'] && form.touched) {
        <span class="error">Passwords do not match</span>
      }
 
      <button type="submit" [disabled]="form.invalid">Register</button>
    </form>
  `
})
export class RegisterComponent {
  private fb = inject(FormBuilder);
 
  form = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
  }, { validators: this.passwordMatchValidator });
 
  private passwordMatchValidator(group: FormGroup) {
    const password = group.get('password')?.value;
    const confirm = group.get('confirmPassword')?.value;
    return password === confirm ? null : { passwordMismatch: true };
  }
 
  onSubmit() {
    if (this.form.valid) {
      console.log(this.form.value); // Fully typed!
    }
  }
}

Typed Forms (Angular 14+)

Angular 14 introduced strongly typed formsform.value and form.controls are fully typed:

import { FormControl, FormGroup } from '@angular/forms';
 
// Typed form group
const profileForm = new FormGroup({
  name: new FormControl('', { nonNullable: true }),
  email: new FormControl('', { nonNullable: true }),
  age: new FormControl<number | null>(null),
});
 
// TypeScript knows the exact types
profileForm.value.name;   // string
profileForm.value.age;    // number | null
profileForm.value.xyz;    // ❌ TypeScript error — field doesn't exist

Dynamic Forms — FormArray

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <div formArrayName="skills">
        @for (control of skills.controls; track $index) {
          <div>
            <input [formControlName]="$index" placeholder="Skill" />
            <button type="button" (click)="removeSkill($index)">Remove</button>
          </div>
        }
      </div>
      <button type="button" (click)="addSkill()">Add Skill</button>
    </form>
  `
})
export class ProfileFormComponent {
  private fb = inject(FormBuilder);
 
  form = this.fb.group({
    name: ['', Validators.required],
    skills: this.fb.array([this.fb.control('')]),
  });
 
  get skills() {
    return this.form.get('skills') as FormArray;
  }
 
  addSkill() {
    this.skills.push(this.fb.control(''));
  }
 
  removeSkill(index: number) {
    this.skills.removeAt(index);
  }
}

Custom Validators

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
 
// Reusable custom validator
export function noSpacesValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const hasSpaces = /\s/.test(control.value);
    return hasSpaces ? { noSpaces: true } : null;
  };
}
 
export function usernameAvailableValidator(
  userService: UserService
): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    return timer(300).pipe( // Debounce
      switchMap(() => userService.checkUsername(control.value)),
      map(available => available ? null : { usernameTaken: true }),
      catchError(() => of(null))
    );
  };
}
 
// Usage
form = this.fb.group({
  username: [
    '',
    [Validators.required, noSpacesValidator()],        // Sync validators
    [usernameAvailableValidator(this.userService)],    // Async validators
  ],
});

Practice Exercises

Exercise 1: Build a Component with Signals

Create a shopping cart component that:

  • Uses signal() for cart items
  • Uses computed() for total price and item count
  • Has an @Input signal for a discount percentage
  • Uses OnPush change detection

Exercise 2: Service with HTTP and RxJS

Build a GithubService that:

  • Fetches a user's repos from the GitHub API (https://api.github.com/users/:username/repos)
  • Uses debounceTime + switchMap for a search input
  • Handles errors with catchError
  • Displays repos with the async pipe

Exercise 3: Custom Directive

Build an appAutoFocus directive that:

  • Automatically focuses the element when it renders
  • Accepts an enabled input to toggle the behavior
  • Works on any <input> or <textarea>

Exercise 4: Route Guard + Lazy Loading

Set up a feature module with:

  • A /dashboard route protected by an authGuard
  • The dashboard module lazy-loaded with loadChildren
  • A redirect to /login when unauthenticated
  • Programmatic navigation after login

Exercise 5: Reactive Form with Validation

Create a checkout form with:

  • Name, email, credit card number (16 digits), expiry date
  • Custom validator for credit card format
  • Async validator that simulates a network check (use timer(500))
  • Display field errors only after the field is touched

Summary and Key Takeaways

Angular is a comprehensive framework that provides everything you need to build enterprise-grade applications. Here's what we covered:

ConceptKey Insight
ComponentsBuilding blocks — use signals for inputs, standalone by default
TemplatesFour types of binding — prefer the new @if/@for syntax
DIUse inject() function, prefer providedIn: 'root' for services
LifecyclengOnInit for setup, ngOnDestroy for cleanup, takeUntilDestroyed for modern cleanup
DirectivesExtend HTML — attribute for behavior, structural for layout
PipesTransform display data — use async pipe for Observables
Change DetectionUse OnPush + signals for best performance
RxJSEssential for async operations — learn map, switchMap, combineLatest
RouterLazy load features, guard protected routes, read params reactively
Reactive FormsExplicit, type-safe, testable — use for anything beyond a simple input

What's Next?

Once you're comfortable with these core concepts, explore:

  • NgRx or Signal Store — State management for complex apps
  • Angular Material — Pre-built UI component library
  • Server-Side Rendering — Angular Universal / Angular SSR
  • Testing — Unit tests with Jasmine/Karma, E2E with Cypress/Playwright


Next: Angular Router Deep Dive — Guards, resolvers, lazy loading strategies

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.