Angular takes an opinionated, batteries-included approach to frontend development. Where React gives you a view library and asks you to assemble the rest, Angular provides a complete framework: a component system, built-in DI (dependency injection), HTTP client, router, form handling, and test utilities. That structure makes Angular particularly well-suited for large teams and enterprise applications where consistency across hundreds of components matters.
The tradeoff is a steeper learning curve — you need to understand several concepts before the pieces click. This article walks through those core concepts in order: components, services and DI, routing, RxJS observables, and reactive forms.
Project Setup
Angular CLI scaffolds and manages the project structure. It handles compilation, testing, linting, and code generation:
npm install -g @angular/cli@latest
ng new my-app --routing --style=css
cd my-app
ng serve
The --routing flag generates the routing module. The resulting project structure separates concerns by feature:
src/
├── app/
│ ├── core/ # Singleton services, interceptors, guards
│ ├── shared/ # Reusable components and utilities
│ ├── features/ # Feature modules (each is a subdirectory)
│ │ └── users/
│ │ ├── user-list/
│ │ ├── user-detail/
│ │ └── users.module.ts
│ ├── app.component.ts
│ ├── app.module.ts
│ └── app-routing.module.ts
└── main.ts
This structure scales. As the application grows, each feature becomes its own lazy-loaded module — the initial bundle stays small.
Components
A component in Angular is a TypeScript class decorated with @Component, paired with an HTML template and optional styles. The decorator wires them together:
// users/user-card/user-card.component.ts
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { User } from '../../models/user.model';
@Component({
selector: 'app-user-card',
templateUrl: './user-card.component.html',
styleUrls: ['./user-card.component.css'],
// OnPush tells Angular to only re-render when @Input references change,
// not on every cycle — a significant performance win for lists
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user!: User;
@Output() delete = new EventEmitter<number>();
onDelete(): void {
this.delete.emit(this.user.id);
}
}
<!-- user-card.component.html -->
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span class="role">{{ user.role | titlecase }}</span>
<button (click)="onDelete()">Remove</button>
</div>
@Input() passes data down from a parent component; @Output() with EventEmitter sends events up. This one-way data flow keeps component interactions predictable.
Lifecycle Hooks
Angular components go through a defined lifecycle. The three you’ll use most:
ngOnInit— runs once after the component is initialized and@Input()values are set. Use this for data loading, not the constructor.ngOnChanges— fires whenever@Input()values change. Receives aSimpleChangesobject showing previous and current values.ngOnDestroy— runs just before Angular destroys the component. Use this to unsubscribe from observables and cancel timers.
import { Component, Input, OnInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UserService } from '../services/user.service';
@Component({ selector: 'app-user-profile', templateUrl: './user-profile.component.html' })
export class UserProfileComponent implements OnInit, OnDestroy, OnChanges {
@Input() userId!: number;
user: User | null = null;
// A Subject used as a teardown trigger — complete it to cancel all subscriptions
private destroy$ = new Subject<void>();
constructor(private userService: UserService) {}
ngOnInit(): void {
this.loadUser();
}
ngOnChanges(changes: SimpleChanges): void {
// Reload if userId changes (e.g., user navigates to a different profile)
if (changes['userId'] && !changes['userId'].firstChange) {
this.loadUser();
}
}
private loadUser(): void {
this.userService.getUser(this.userId)
.pipe(takeUntil(this.destroy$))
.subscribe(user => this.user = user);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
The destroy$ + takeUntil pattern is the standard way to prevent memory leaks from subscriptions that outlive the component.
Services and Dependency Injection
Services hold shared business logic and data access. The DI system injects them into any component or other service that declares them as constructor parameters — you never call new UserService() yourself.
Angular’s DI works through the providedIn metadata. Setting providedIn: 'root' makes the service a singleton across the entire application, which is what you want for HTTP-based data services:
// core/services/user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { User, CreateUserDto } from '../models/user.model';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly apiUrl = '/api/users';
constructor(private http: HttpClient) {}
getUsers(filters: { role?: string; page?: number; limit?: number } = {}): Observable<User[]> {
const params = new HttpParams({ fromObject: filters as Record<string, string> });
return this.http.get<{ data: User[] }>(this.apiUrl, { params }).pipe(
map(response => response.data),
catchError(err => {
console.error('Failed to load users', err);
return throwError(() => err);
})
);
}
getUser(id: number): Observable<User> {
return this.http.get<{ data: User }>(`${this.apiUrl}/${id}`).pipe(
map(response => response.data)
);
}
createUser(dto: CreateUserDto): Observable<User> {
return this.http.post<{ data: User }>(this.apiUrl, dto).pipe(
map(response => response.data)
);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
Scoped providers — using providedIn: 'any' or module-level providers: [] — create a new instance per lazy-loaded module, which is useful for feature-specific services that shouldn’t be shared globally.
RxJS: The Observable Pattern in Angular
Angular is built around RxJS observables. The HttpClient returns observables, the Router exposes route changes as observables, and reactive forms expose value changes as observables. Understanding a handful of operators unlocks the power of this model.
The most common operators in Angular applications:
import { Component, OnInit } from '@angular/core';
import { combineLatest, Subject } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
switchMap,
startWith,
takeUntil,
catchError,
} from 'rxjs/operators';
import { of } from 'rxjs';
import { UserService } from '../services/user.service';
@Component({
selector: 'app-user-search',
templateUrl: './user-search.component.html',
})
export class UserSearchComponent implements OnInit, OnDestroy {
searchControl = new FormControl('');
users: User[] = [];
error: string | null = null;
private destroy$ = new Subject<void>();
constructor(private userService: UserService) {}
ngOnInit(): void {
this.searchControl.valueChanges.pipe(
startWith(''), // Emit initial value so list loads immediately
debounceTime(300), // Wait 300ms after user stops typing
distinctUntilChanged(), // Skip if search term hasn't changed
switchMap(query => // Cancel previous request, start new one
this.userService.getUsers({ search: query ?? '' }).pipe(
catchError(err => { // Handle per-search errors without killing the stream
this.error = 'Failed to load users';
return of([]);
})
)
),
takeUntil(this.destroy$)
).subscribe(users => {
this.users = users;
this.error = null;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
switchMap is the key operator for search: when the user types a new character, it cancels the in-flight HTTP request and starts a new one. Without it, out-of-order responses can display stale results.
The Async Pipe
For display-only subscriptions, async pipe in the template is cleaner than subscribing manually. Angular handles subscribe and unsubscribe automatically:
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="users$ | async as users; else loading">
<app-user-card
*ngFor="let user of users"
[user]="user"
(delete)="onDelete($event)">
</app-user-card>
</div>
<ng-template #loading><p>Loading...</p></ng-template>
`
})
export class UserListComponent {
users$ = this.userService.getUsers();
constructor(private userService: UserService) {}
onDelete(id: number): void {
// Trigger refetch after delete by reassigning the observable
this.userService.deleteUser(id).subscribe(() => {
this.users$ = this.userService.getUsers();
});
}
}
Routing
Angular’s router maps URL paths to components and supports lazy loading, guards, and route parameters.
Define routes in the routing module. Lazy loading with loadChildren means the feature module’s code only downloads when a user navigates to that route:
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './core/guards/auth.guard';
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'login', loadChildren: () => import('./features/auth/auth.module').then(m => m.AuthModule) },
{
path: 'users',
canActivate: [AuthGuard],
loadChildren: () => import('./features/users/users.module').then(m => m.UsersModule)
},
{ path: '**', redirectTo: '/dashboard' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
Reading route parameters in a component uses ActivatedRoute, which exposes params as observables — so navigation to a new ID rerenders the component correctly:
// users/user-detail/user-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { switchMap } from 'rxjs/operators';
import { UserService } from '../services/user.service';
@Component({ selector: 'app-user-detail', templateUrl: './user-detail.component.html' })
export class UserDetailComponent implements OnInit {
user$ = this.route.paramMap.pipe(
switchMap(params => this.userService.getUser(Number(params.get('id'))))
);
constructor(
private route: ActivatedRoute,
private userService: UserService
) {}
}
<!-- user-detail.component.html -->
<div *ngIf="user$ | async as user">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
Reactive Forms
Angular has two form approaches: template-driven (using ngModel, simpler for basic forms) and reactive (using FormBuilder, better for complex validation and dynamic forms). Reactive forms are testable, explicitly typed, and easier to update programmatically.
Build a user creation form with validation:
// users/create-user/create-user.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, AbstractControl } from '@angular/forms';
import { Router } from '@angular/router';
import { UserService } from '../services/user.service';
@Component({ selector: 'app-create-user', templateUrl: './create-user.component.html' })
export class CreateUserComponent {
form: FormGroup;
submitting = false;
serverError: string | null = null;
constructor(
private fb: FormBuilder,
private userService: UserService,
private router: Router
) {
this.form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
role: ['user', Validators.required],
});
}
// Convenience accessors for the template
get name() { return this.form.get('name')!; }
get email() { return this.form.get('email')!; }
get password() { return this.form.get('password')!; }
onSubmit(): void {
if (this.form.invalid || this.submitting) return;
this.submitting = true;
this.serverError = null;
this.userService.createUser(this.form.value).subscribe({
next: (user) => this.router.navigate(['/users', user.id]),
error: (err) => {
this.serverError = err.error?.message ?? 'Failed to create user';
this.submitting = false;
}
});
}
}
<!-- create-user.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label for="name">Name</label>
<input id="name" formControlName="name" />
<span *ngIf="name.invalid && name.touched">
Name must be at least 2 characters
</span>
</div>
<div>
<label for="email">Email</label>
<input id="email" formControlName="email" type="email" />
<span *ngIf="email.invalid && email.touched">
Enter a valid email address
</span>
</div>
<div>
<label for="password">Password</label>
<input id="password" formControlName="password" type="password" />
<span *ngIf="password.invalid && password.touched">
Password must be at least 8 characters
</span>
</div>
<p *ngIf="serverError" class="error">{{ serverError }}</p>
<button type="submit" [disabled]="form.invalid || submitting">
{{ submitting ? 'Creating...' : 'Create User' }}
</button>
</form>
The *ngIf checks invalid && touched to avoid showing errors before the user has interacted with a field.
HTTP Interceptors
Interceptors transform every outgoing request or incoming response — the right place to add auth headers, handle 401 redirects, or log errors globally:
// core/interceptors/auth.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService, private router: Router) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getToken();
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next.handle(authReq).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
this.authService.clearSession();
this.router.navigate(['/login']);
}
return throwError(() => err);
})
);
}
}
Register the interceptor in your CoreModule or AppModule:
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
Summary
Angular’s strength is its consistency and completeness. The concepts reinforce each other: components use services via DI, services call the HttpClient which returns observables, observables flow through the router and reactive forms. Once these patterns click, the framework makes large codebases navigable.
The key principles to internalize:
- Use
ChangeDetectionStrategy.OnPushon all components — it significantly improves performance in large component trees - Always use
takeUntil(destroy$)for subscriptions in components that use manual subscribe - Prefer the
asyncpipe for display-only data — Angular handles the subscription lifecycle - Use reactive forms over template-driven for any form with validation or programmatic control
- Structure your app into feature modules from the start and lazy-load them in the router
Resources
- Angular documentation
- RxJS documentation
- Angular Style Guide
- Angular HTTP Client
- Reactive Forms deep dive
Comments