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
NgModulefor 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:
| Part | Purpose |
|---|---|
@Component decorator | Metadata — selector, template, styles |
selector | The HTML tag used to render this component (<app-greeting>) |
template / templateUrl | The HTML structure (inline or external file) |
| Class body | Component 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:
| Syntax | Direction | Example |
|---|---|---|
{{ 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 usestrackto identify each item in a list. This allows Angular to efficiently update the DOM when items change, add, or remove — similar to React'skeyprop.
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, butinject()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
| Hook | Runs | Use For |
|---|---|---|
ngOnChanges | On every input change | Reacting to input property changes |
ngOnInit | Once, after first ngOnChanges | API calls, initial data fetch, setup logic |
ngDoCheck | Every change detection cycle | Custom change detection logic (use sparingly!) |
ngAfterContentInit | Once, after content projection | Accessing projected content (<ng-content>) |
ngAfterViewInit | Once, after view renders | DOM access, @ViewChild access |
ngAfterViewChecked | After every view check | Responding to view changes (use sparingly!) |
ngOnDestroy | Once, before component is removed | Cleanup: 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
DestroyRefandtakeUntilDestroyedhelper 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
asyncpipe? It handles subscription/unsubscription automatically — no memory leaks, nongOnDestroycleanup 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:
- An
@Inputreference changes (not just a property mutation) - An event handler in the component fires
- An Observable linked via
asyncpipe emits ChangeDetectorRef.markForCheck()is called manually
Best practice: Use
OnPushfor 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
| Operator | Purpose | Example Use Case |
|---|---|---|
map | Transform emitted values | Convert API response |
filter | Filter values that pass a test | Skip empty values |
tap | Side effects (logging, debugging) | tap(x => console.log(x)) |
switchMap | Cancel previous, subscribe to new | Search, route params |
mergeMap | Subscribe to all (parallel) | Bulk operations |
concatMap | Subscribe sequentially (ordered) | Sequential file uploads |
catchError | Handle errors | API error handling |
debounceTime | Wait for pause in emissions | Search input |
distinctUntilChanged | Skip consecutive duplicates | Search input |
takeUntil | Complete when notifier emits | Component cleanup |
combineLatest | Combine latest from multiple | Multiple 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, customtimeAgo) - Directives (custom
appHighlight) - RxJS patterns (
BehaviorSubject,switchMap) - OnPush change detection
- Modern Angular (standalone,
@if/@forsyntax)
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 forms — form.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 existDynamic 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
@Inputsignal for a discount percentage - Uses
OnPushchange 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+switchMapfor a search input - Handles errors with
catchError - Displays repos with the
asyncpipe
Exercise 3: Custom Directive
Build an appAutoFocus directive that:
- Automatically focuses the element when it renders
- Accepts an
enabledinput to toggle the behavior - Works on any
<input>or<textarea>
Exercise 4: Route Guard + Lazy Loading
Set up a feature module with:
- A
/dashboardroute protected by anauthGuard - The dashboard module lazy-loaded with
loadChildren - A redirect to
/loginwhen 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:
| Concept | Key Insight |
|---|---|
| Components | Building blocks — use signals for inputs, standalone by default |
| Templates | Four types of binding — prefer the new @if/@for syntax |
| DI | Use inject() function, prefer providedIn: 'root' for services |
| Lifecycle | ngOnInit for setup, ngOnDestroy for cleanup, takeUntilDestroyed for modern cleanup |
| Directives | Extend HTML — attribute for behavior, structural for layout |
| Pipes | Transform display data — use async pipe for Observables |
| Change Detection | Use OnPush + signals for best performance |
| RxJS | Essential for async operations — learn map, switchMap, combineLatest |
| Router | Lazy load features, guard protected routes, read params reactively |
| Reactive Forms | Explicit, 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
Related Posts
- TypeScript Complete Guide — TypeScript fundamentals before diving into Angular
- RxJS Deep Dive — Master reactive programming patterns used in Angular
- Clean Architecture — Apply clean architecture principles to Angular apps
- Domain-Driven Design — Structure complex Angular features with DDD
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.