Angular - The Enterprise Web Framework
Czym jest Angular?
Angular to kompletny, opinionated framework JavaScript/TypeScript rozwijany przez Google. Jest przeznaczony do budowania skalowalnych aplikacji enterprise z wbudowanymi rozwiązaniami dla routingu, formularzy, HTTP, testowania i dependency injection. Angular używa TypeScript jako domyślnego języka i oferuje "batteries-included" podejście, gdzie wszystkie niezbędne narzędzia są częścią frameworka.
Od wersji Angular 17 (2023), framework przeszedł znaczącą modernizację wprowadzając Signals dla reaktywności, standalone components jako domyślne, oraz nową składnię control flow. Te zmiany znacznie uprościły development i zbliżyły Angular do nowoczesnych frameworków jak React czy Vue.
Dlaczego Angular?
Kluczowe zalety
- Kompletny ekosystem - Routing, forms, HTTP, i18n, animations wbudowane
- TypeScript first - Pełne typowanie od początku do końca
- Dependency Injection - Potężny system DI dla testowalności i modularności
- Enterprise-ready - Sprawdzony w dużych aplikacjach korporacyjnych
- Angular CLI - Generatory, build, test, deploy w jednym narzędziu
- Long-term support - Google zapewnia wsparcie i regularne aktualizacje
Angular vs React vs Vue
| Cecha | Angular | React | Vue |
|---|---|---|---|
| Typ | Framework | Library | Framework |
| Język | TypeScript | JS/TS | JS/TS |
| Architektura | Opinionated | Flexible | Flexible |
| Reaktywność | Signals/RxJS | Hooks | Composition API |
| Routing | Wbudowany | React Router | Vue Router |
| State management | NgRx/Signals | Redux/Zustand | Pinia |
| Forms | Reactive Forms | React Hook Form | VeeValidate |
| Learning curve | Wysoka | Średnia | Niska |
| Bundle size | Większy | Mniejszy | Mały |
| Enterprise adoption | Bardzo wysokie | Wysokie | Średnie |
Instalacja i konfiguracja
Angular CLI
# Instalacja Angular CLI globalnie
npm install -g @angular/cli
# Tworzenie nowego projektu
ng new my-app
# Interaktywny wizard zapyta o:
# - Stylesheet format (CSS/SCSS/SASS/Less)
# - SSR/SSG
# - Routing
cd my-app
ng serve
# Aplikacja dostępna na http://localhost:4200Struktura projektu Angular 17+
my-angular-app/
├── src/
│ ├── app/
│ │ ├── app.component.ts # Root component
│ │ ├── app.component.html # Template
│ │ ├── app.component.scss # Styles
│ │ ├── app.component.spec.ts # Tests
│ │ ├── app.config.ts # App configuration
│ │ ├── app.routes.ts # Routing
│ │ ├── components/ # Feature components
│ │ │ ├── header/
│ │ │ └── footer/
│ │ ├── pages/ # Page components
│ │ │ ├── home/
│ │ │ └── about/
│ │ ├── services/ # Services
│ │ │ └── api.service.ts
│ │ └── models/ # Interfaces/Types
│ │ └── user.model.ts
│ ├── assets/ # Static assets
│ ├── environments/ # Environment configs
│ ├── index.html # Entry HTML
│ ├── main.ts # Bootstrap
│ └── styles.scss # Global styles
├── angular.json # Angular CLI config
├── tsconfig.json # TypeScript config
└── package.jsonKonfiguracja aplikacji
// src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'
import { provideRouter } from '@angular/router'
import { provideHttpClient, withFetch } from '@angular/common/http'
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'
import { routes } from './app.routes'
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withFetch()),
provideAnimationsAsync(),
],
}// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import { appConfig } from './app/app.config'
import { AppComponent } from './app/app.component'
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err))Komponenty - Podstawy
Standalone Components (domyślne od Angular 17)
// src/app/components/counter/counter.component.ts
import { Component, signal, computed } from '@angular/core'
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter">
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`,
styles: [`
.counter {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
}
button {
margin: 0 4px;
padding: 8px 16px;
}
`]
})
export class CounterComponent {
// Signals - nowy reaktywny prymityw w Angular 17+
count = signal(0)
// Computed signal
double = computed(() => this.count() * 2)
increment() {
this.count.update(c => c + 1)
}
decrement() {
this.count.update(c => c - 1)
}
reset() {
this.count.set(0)
}
}Input i Output
// src/app/components/user-card/user-card.component.ts
import { Component, input, output, computed } from '@angular/core'
interface User {
id: number
name: string
email: string
avatar?: string
}
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<img [src]="user().avatar || defaultAvatar" [alt]="user().name" />
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<p>Initials: {{ initials() }}</p>
<button (click)="edit.emit(user())">Edit</button>
<button (click)="delete.emit(user().id)">Delete</button>
</div>
`,
})
export class UserCardComponent {
// Signal inputs (nowa składnia Angular 17+)
user = input.required<User>()
defaultAvatar = input('/assets/default-avatar.png')
// Outputs
edit = output<User>()
delete = output<number>()
// Computed z input
initials = computed(() => {
const name = this.user().name
return name.split(' ').map(n => n[0]).join('').toUpperCase()
})
}
// Użycie
@Component({
template: `
<app-user-card
[user]="selectedUser"
(edit)="onEdit($event)"
(delete)="onDelete($event)"
/>
`,
})
export class ParentComponent {
selectedUser: User = { id: 1, name: 'John Doe', email: 'john@example.com' }
onEdit(user: User) {
console.log('Edit:', user)
}
onDelete(id: number) {
console.log('Delete:', id)
}
}Content Projection (ng-content)
// src/app/components/card/card.component.ts
import { Component, input } from '@angular/core'
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<div class="card-header">
<ng-content select="[card-header]"></ng-content>
</div>
<div class="card-body">
<ng-content></ng-content>
</div>
<div class="card-footer">
<ng-content select="[card-footer]"></ng-content>
</div>
</div>
`,
styles: [`
.card { border: 1px solid #ddd; border-radius: 8px; }
.card-header { padding: 1rem; border-bottom: 1px solid #ddd; }
.card-body { padding: 1rem; }
.card-footer { padding: 1rem; border-top: 1px solid #ddd; }
`]
})
export class CardComponent {}
// Użycie
@Component({
template: `
<app-card>
<h2 card-header>User Profile</h2>
<p>This is the card content.</p>
<p>More content here.</p>
<button card-footer>Save</button>
</app-card>
`,
})
export class ProfileComponent {}Nowa składnia Control Flow (Angular 17+)
@if - warunkowe renderowanie
@Component({
template: `
<!-- Nowa składnia -->
@if (isLoggedIn()) {
<app-dashboard />
} @else if (isLoading()) {
<app-spinner />
} @else {
<app-login />
}
<!-- Z let dla destructuring -->
@if (user(); as user) {
<p>Welcome, {{ user.name }}</p>
}
`,
})
export class AppComponent {
isLoggedIn = signal(false)
isLoading = signal(true)
user = signal<User | null>(null)
}@for - iteracja
@Component({
template: `
<!-- Podstawowa iteracja -->
@for (item of items(); track item.id) {
<div class="item">
{{ item.name }}
</div>
} @empty {
<p>No items found</p>
}
<!-- Z index i innymi zmiennymi -->
@for (item of items(); track item.id; let i = $index; let first = $first; let last = $last) {
<div
[class.first]="first"
[class.last]="last"
>
{{ i + 1 }}. {{ item.name }}
</div>
}
`,
})
export class ListComponent {
items = signal<Item[]>([])
}@switch - pattern matching
@Component({
template: `
@switch (status()) {
@case ('loading') {
<app-spinner />
}
@case ('error') {
<app-error [message]="errorMessage()" />
}
@case ('success') {
<app-data [data]="data()" />
}
@default {
<p>Unknown status</p>
}
}
`,
})
export class StatusComponent {
status = signal<'idle' | 'loading' | 'success' | 'error'>('idle')
data = signal<any>(null)
errorMessage = signal('')
}@defer - lazy loading
@Component({
template: `
<!-- Lazy load gdy widoczny -->
@defer (on viewport) {
<app-heavy-component />
} @loading {
<p>Loading component...</p>
} @error {
<p>Failed to load</p>
} @placeholder {
<p>Scroll down to load</p>
}
<!-- Inne triggery -->
@defer (on idle) {
<app-analytics />
}
@defer (on interaction) {
<app-comments />
}
@defer (on hover) {
<app-tooltip />
}
@defer (on timer(2000)) {
<app-delayed />
}
@defer (when showDetails()) {
<app-details />
}
`,
})
export class DeferExampleComponent {
showDetails = signal(false)
}Signals - Reaktywność
Podstawowe Signals
import { Component, signal, computed, effect } from '@angular/core'
@Component({
selector: 'app-signals-demo',
standalone: true,
template: `
<div>
<p>First: {{ firstName() }}</p>
<p>Last: {{ lastName() }}</p>
<p>Full: {{ fullName() }}</p>
<input
[value]="firstName()"
(input)="firstName.set($any($event.target).value)"
/>
</div>
`,
})
export class SignalsDemoComponent {
// Writable signals
firstName = signal('John')
lastName = signal('Doe')
// Computed signal - automatycznie aktualizowany
fullName = computed(() => `${this.firstName()} ${this.lastName()}`)
constructor() {
// Effect - side effects przy zmianie signals
effect(() => {
console.log('Full name changed:', this.fullName())
})
}
// Metody aktualizacji
updateFirstName(name: string) {
this.firstName.set(name)
}
incrementAge() {
// update() dla transformacji
this.age.update(age => age + 1)
}
private age = signal(25)
}Signal-based Inputs
import { Component, input, model, output } from '@angular/core'
@Component({
selector: 'app-form-field',
standalone: true,
template: `
<div class="field">
<label>{{ label() }}</label>
<input
[type]="type()"
[value]="value()"
[placeholder]="placeholder()"
(input)="onInput($event)"
/>
@if (error()) {
<span class="error">{{ error() }}</span>
}
</div>
`,
})
export class FormFieldComponent {
// Required input
label = input.required<string>()
// Optional inputs z defaults
type = input<'text' | 'email' | 'password'>('text')
placeholder = input('')
error = input<string | null>(null)
// Two-way binding z model()
value = model<string>('')
onInput(event: Event) {
const target = event.target as HTMLInputElement
this.value.set(target.value)
}
}
// Użycie z two-way binding
@Component({
template: `
<app-form-field
label="Email"
type="email"
[(value)]="email"
[error]="emailError()"
/>
`,
})
export class FormComponent {
email = signal('')
emailError = computed(() => {
const email = this.email()
if (!email) return 'Email is required'
if (!email.includes('@')) return 'Invalid email'
return null
})
}toSignal i toObservable
import { Component, inject } from '@angular/core'
import { toSignal, toObservable } from '@angular/core/rxjs-interop'
import { HttpClient } from '@angular/common/http'
import { switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators'
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (users()) {
@for (user of users(); track user.id) {
<div>{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`,
})
export class UsersComponent {
private http = inject(HttpClient)
// Observable → Signal
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: null }
)
}
@Component({
template: `
<input
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value)"
/>
@for (result of searchResults(); track result.id) {
<div>{{ result.name }}</div>
}
`,
})
export class SearchComponent {
private http = inject(HttpClient)
searchQuery = signal('')
// Signal → Observable → Signal (dla debounce)
searchResults = toSignal(
toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query =>
query.length >= 3
? this.http.get<Item[]>(`/api/search?q=${query}`)
: of([])
)
),
{ initialValue: [] }
)
}Services i Dependency Injection
Tworzenie Service
// src/app/services/user.service.ts
import { Injectable, inject, signal, computed } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { toSignal } from '@angular/core/rxjs-interop'
import { Observable, BehaviorSubject } from 'rxjs'
import { map, tap, catchError } from 'rxjs/operators'
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
@Injectable({
providedIn: 'root' // Singleton w całej aplikacji
})
export class UserService {
private http = inject(HttpClient)
private apiUrl = '/api/users'
// State management z signals
private usersSignal = signal<User[]>([])
private loadingSignal = signal(false)
private errorSignal = signal<string | null>(null)
// Public readonly accessors
readonly users = this.usersSignal.asReadonly()
readonly loading = this.loadingSignal.asReadonly()
readonly error = this.errorSignal.asReadonly()
// Computed
readonly userCount = computed(() => this.usersSignal().length)
readonly admins = computed(() =>
this.usersSignal().filter(u => u.role === 'admin')
)
async loadUsers() {
this.loadingSignal.set(true)
this.errorSignal.set(null)
try {
const users = await this.http.get<User[]>(this.apiUrl).toPromise()
this.usersSignal.set(users || [])
} catch (err) {
this.errorSignal.set('Failed to load users')
} finally {
this.loadingSignal.set(false)
}
}
async createUser(user: Omit<User, 'id'>): Promise<User | null> {
try {
const newUser = await this.http.post<User>(this.apiUrl, user).toPromise()
if (newUser) {
this.usersSignal.update(users => [...users, newUser])
}
return newUser || null
} catch {
return null
}
}
async updateUser(id: number, updates: Partial<User>): Promise<boolean> {
try {
await this.http.patch(`${this.apiUrl}/${id}`, updates).toPromise()
this.usersSignal.update(users =>
users.map(u => u.id === id ? { ...u, ...updates } : u)
)
return true
} catch {
return false
}
}
async deleteUser(id: number): Promise<boolean> {
try {
await this.http.delete(`${this.apiUrl}/${id}`).toPromise()
this.usersSignal.update(users => users.filter(u => u.id !== id))
return true
} catch {
return false
}
}
}Użycie Service w komponencie
import { Component, inject, OnInit } from '@angular/core'
import { UserService } from './services/user.service'
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (userService.loading()) {
<app-spinner />
} @else if (userService.error()) {
<app-error [message]="userService.error()" />
} @else {
<p>Total users: {{ userService.userCount() }}</p>
@for (user of userService.users(); track user.id) {
<app-user-card
[user]="user"
(delete)="deleteUser($event)"
/>
}
}
`,
})
export class UsersComponent implements OnInit {
// inject() zamiast constructor injection
protected userService = inject(UserService)
ngOnInit() {
this.userService.loadUsers()
}
async deleteUser(id: number) {
const success = await this.userService.deleteUser(id)
if (!success) {
alert('Failed to delete user')
}
}
}Hierarchical DI
// Service z różnymi scope'ami
// Global singleton
@Injectable({ providedIn: 'root' })
export class GlobalService {}
// Per-component instance
@Component({
providers: [LocalService], // Nowa instancja dla tego komponentu i dzieci
})
export class ParentComponent {}
// Factory provider
@Component({
providers: [
{
provide: ApiService,
useFactory: (http: HttpClient, config: ConfigService) => {
return new ApiService(http, config.apiUrl)
},
deps: [HttpClient, ConfigService],
},
],
})
export class AppComponent {}
// Value provider
@Component({
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' },
],
})
export class ConfiguredComponent {}
// InjectionToken
export const API_URL = new InjectionToken<string>('API URL')Routing
Konfiguracja Routes
// src/app/app.routes.ts
import { Routes } from '@angular/router'
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./pages/home/home.component')
.then(m => m.HomeComponent),
title: 'Home',
},
{
path: 'about',
loadComponent: () => import('./pages/about/about.component')
.then(m => m.AboutComponent),
title: 'About Us',
},
{
path: 'users',
loadComponent: () => import('./pages/users/users.component')
.then(m => m.UsersComponent),
children: [
{
path: '',
loadComponent: () => import('./pages/users/user-list/user-list.component')
.then(m => m.UserListComponent),
},
{
path: ':id',
loadComponent: () => import('./pages/users/user-detail/user-detail.component')
.then(m => m.UserDetailComponent),
},
],
},
{
path: 'admin',
loadComponent: () => import('./pages/admin/admin.component')
.then(m => m.AdminComponent),
canActivate: [authGuard],
canActivateChild: [adminGuard],
},
{
path: '**',
loadComponent: () => import('./pages/not-found/not-found.component')
.then(m => m.NotFoundComponent),
},
]Route Guards
// src/app/guards/auth.guard.ts
import { inject } from '@angular/core'
import { Router, CanActivateFn } from '@angular/router'
import { AuthService } from '../services/auth.service'
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService)
const router = inject(Router)
if (authService.isAuthenticated()) {
return true
}
// Redirect to login with return URL
router.navigate(['/login'], {
queryParams: { returnUrl: state.url },
})
return false
}
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService)
if (authService.isAdmin()) {
return true
}
return false
}Route Parameters
// src/app/pages/users/user-detail/user-detail.component.ts
import { Component, inject, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { toSignal } from '@angular/core/rxjs-interop'
import { switchMap, map } from 'rxjs/operators'
import { UserService } from '../../../services/user.service'
@Component({
selector: 'app-user-detail',
standalone: true,
template: `
@if (user()) {
<div class="user-detail">
<h1>{{ user()!.name }}</h1>
<p>{{ user()!.email }}</p>
<button (click)="goBack()">Back</button>
</div>
} @else {
<p>Loading...</p>
}
`,
})
export class UserDetailComponent {
private route = inject(ActivatedRoute)
private router = inject(Router)
private userService = inject(UserService)
// Reactive route params → user
user = toSignal(
this.route.paramMap.pipe(
map(params => params.get('id')),
switchMap(id => this.userService.getUser(Number(id)))
)
)
goBack() {
this.router.navigate(['/users'])
}
}Navigation
@Component({
template: `
<nav>
<!-- routerLink directive -->
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
Home
</a>
<a routerLink="/users" routerLinkActive="active">Users</a>
<a [routerLink]="['/users', user().id]">{{ user().name }}</a>
</nav>
<!-- Router outlet -->
<router-outlet />
`,
imports: [RouterLink, RouterLinkActive, RouterOutlet],
})
export class AppComponent {
private router = inject(Router)
// Programmatic navigation
navigateToUser(id: number) {
this.router.navigate(['/users', id])
}
navigateWithParams() {
this.router.navigate(['/search'], {
queryParams: { q: 'angular', page: 1 },
})
}
}Formularze
Reactive Forms
// src/app/components/user-form/user-form.component.ts
import { Component, inject, output } from '@angular/core'
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'
@Component({
selector: 'app-user-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="field">
<label for="name">Name</label>
<input id="name" formControlName="name" />
@if (form.get('name')?.invalid && form.get('name')?.touched) {
<span class="error">Name is required (min 2 characters)</span>
}
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
@if (form.get('email')?.errors?.['email']) {
<span class="error">Invalid email format</span>
}
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password" />
@if (form.get('password')?.errors?.['minlength']) {
<span class="error">Password must be at least 8 characters</span>
}
</div>
<div formGroupName="address">
<h3>Address</h3>
<input formControlName="street" placeholder="Street" />
<input formControlName="city" placeholder="City" />
<input formControlName="zip" placeholder="ZIP" />
</div>
<button type="submit" [disabled]="form.invalid">
Submit
</button>
</form>
`,
})
export class UserFormComponent {
private fb = inject(FormBuilder)
submitted = output<UserFormData>()
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
address: this.fb.group({
street: [''],
city: [''],
zip: ['', Validators.pattern(/^\d{5}$/)],
}),
})
onSubmit() {
if (this.form.valid) {
this.submitted.emit(this.form.value as UserFormData)
}
}
}Custom Validators
// src/app/validators/custom.validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'
// Sync validator
export function forbiddenName(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value)
return forbidden ? { forbiddenName: { value: control.value } } : null
}
}
// Password match validator
export function passwordMatchValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const password = control.get('password')
const confirm = control.get('confirmPassword')
if (password && confirm && password.value !== confirm.value) {
return { passwordMismatch: true }
}
return null
}
}
// Async validator
export function uniqueEmailValidator(
userService: UserService
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return userService.checkEmailExists(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
catchError(() => of(null))
)
}
}
// Użycie
form = this.fb.group({
name: ['', [Validators.required, forbiddenName(/admin/i)]],
email: ['', [Validators.required, Validators.email],
[uniqueEmailValidator(this.userService)]
],
password: ['', Validators.required],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator() })HTTP Client
Podstawowe requesty
// src/app/services/api.service.ts
import { Injectable, inject } from '@angular/core'
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http'
import { Observable, throwError } from 'rxjs'
import { map, catchError, retry } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient)
private baseUrl = '/api'
// GET request
getUsers(page = 1, limit = 10): Observable<User[]> {
const params = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString())
return this.http.get<User[]>(`${this.baseUrl}/users`, { params })
.pipe(
retry(2),
catchError(this.handleError)
)
}
// GET with headers
getUserById(id: number): Observable<User> {
const headers = new HttpHeaders()
.set('Accept', 'application/json')
return this.http.get<User>(`${this.baseUrl}/users/${id}`, { headers })
}
// POST request
createUser(user: CreateUserDto): Observable<User> {
return this.http.post<User>(`${this.baseUrl}/users`, user)
}
// PUT request
updateUser(id: number, user: UpdateUserDto): Observable<User> {
return this.http.put<User>(`${this.baseUrl}/users/${id}`, user)
}
// PATCH request
patchUser(id: number, updates: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/users/${id}`, updates)
}
// DELETE request
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/users/${id}`)
}
private handleError(error: any) {
console.error('API Error:', error)
return throwError(() => new Error(error.message || 'Server error'))
}
}HTTP Interceptors
// src/app/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http'
import { inject } from '@angular/core'
import { AuthService } from '../services/auth.service'
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService)
const token = authService.getToken()
if (token) {
const cloned = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
})
return next(cloned)
}
return next(req)
}
// Error interceptor
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError(error => {
if (error.status === 401) {
// Handle unauthorized
inject(Router).navigate(['/login'])
}
return throwError(() => error)
})
)
}
// Konfiguracja w app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, errorInterceptor])
),
],
}Angular Material
Instalacja
ng add @angular/materialUżycie komponentów
import { Component } from '@angular/core'
import { MatButtonModule } from '@angular/material/button'
import { MatInputModule } from '@angular/material/input'
import { MatFormFieldModule } from '@angular/material/form-field'
import { MatCardModule } from '@angular/material/card'
import { MatIconModule } from '@angular/material/icon'
import { MatTableModule } from '@angular/material/table'
import { MatPaginatorModule } from '@angular/material/paginator'
import { MatDialogModule, MatDialog } from '@angular/material/dialog'
@Component({
selector: 'app-users-table',
standalone: true,
imports: [
MatButtonModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
],
template: `
<mat-card>
<mat-card-header>
<mat-card-title>Users</mat-card-title>
</mat-card-header>
<mat-card-content>
<table mat-table [dataSource]="users()">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let user">{{ user.name }}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>Email</th>
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let user">
<button mat-icon-button (click)="edit(user)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(user)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-paginator
[length]="totalUsers()"
[pageSize]="10"
[pageSizeOptions]="[5, 10, 25]"
(page)="onPageChange($event)"
/>
</mat-card-content>
</mat-card>
`,
})
export class UsersTableComponent {
private dialog = inject(MatDialog)
users = signal<User[]>([])
totalUsers = signal(0)
displayedColumns = ['name', 'email', 'actions']
edit(user: User) {
const dialogRef = this.dialog.open(UserDialogComponent, {
data: user,
width: '400px',
})
dialogRef.afterClosed().subscribe(result => {
if (result) {
// Handle update
}
})
}
}Testing
Component Testing
// src/app/components/counter/counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CounterComponent } from './counter.component'
describe('CounterComponent', () => {
let component: CounterComponent
let fixture: ComponentFixture<CounterComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent], // Standalone component
}).compileComponents()
fixture = TestBed.createComponent(CounterComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should display initial count of 0', () => {
const compiled = fixture.nativeElement as HTMLElement
expect(compiled.textContent).toContain('Count: 0')
})
it('should increment count', () => {
component.increment()
fixture.detectChanges()
expect(component.count()).toBe(1)
})
it('should decrement count', () => {
component.count.set(5)
component.decrement()
fixture.detectChanges()
expect(component.count()).toBe(4)
})
})Service Testing
// src/app/services/user.service.spec.ts
import { TestBed } from '@angular/core/testing'
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'
import { UserService } from './user.service'
describe('UserService', () => {
let service: UserService
let httpMock: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService],
})
service = TestBed.inject(UserService)
httpMock = TestBed.inject(HttpTestingController)
})
afterEach(() => {
httpMock.verify()
})
it('should fetch users', async () => {
const mockUsers = [
{ id: 1, name: 'John', email: 'john@example.com', role: 'user' },
]
const promise = service.loadUsers()
const req = httpMock.expectOne('/api/users')
expect(req.request.method).toBe('GET')
req.flush(mockUsers)
await promise
expect(service.users()).toEqual(mockUsers)
})
it('should handle error', async () => {
const promise = service.loadUsers()
const req = httpMock.expectOne('/api/users')
req.error(new ErrorEvent('Network error'))
await promise
expect(service.error()).toBe('Failed to load users')
})
})Deployment
Build produkcyjny
# Standard build
ng build --configuration production
# Z SSR
ng build --configuration production --ssrVercel
// vercel.json
{
"buildCommand": "ng build --configuration production",
"outputDirectory": "dist/my-app/browser",
"framework": null
}Docker
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production
FROM nginx:alpine
COPY /app/dist/my-app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Cennik
- 100% darmowy - MIT License
- Angular Enterprise Support: Dostępne od Google (ceny na zapytanie)
FAQ - Często zadawane pytania
Czy Angular jest nadal aktualny w 2025?
Tak. Angular 17+ przeszedł znaczącą modernizację z Signals, standalone components i nową składnią control flow. Framework jest aktywnie rozwijany przez Google i używany przez wiele dużych firm.
Jaka jest różnica między Angular a AngularJS?
AngularJS (1.x) to stary framework z 2010 roku, całkowicie inny od Angular (2+). Angular to całkowicie przepisany framework z TypeScript, componentami i nowoczesną architekturą. AngularJS nie jest już wspierany.
Kiedy wybrać Angular zamiast React/Vue?
Angular jest najlepszy dla dużych aplikacji enterprise, gdzie potrzebujesz kompletnego frameworka z wbudowanymi rozwiązaniami. React/Vue są lepsze dla mniejszych projektów lub gdy potrzebujesz większej elastyczności.
Czy muszę znać RxJS?
Coraz mniej. Z wprowadzeniem Signals w Angular 17+, RxJS jest opcjonalne dla wielu przypadków użycia. Jednak dla złożonych operacji asynchronicznych RxJS nadal jest potężnym narzędziem.
Jak migrować z NgModules do Standalone Components?
Angular CLI oferuje automatyczną migrację: ng generate @angular/core:standalone. Można też migrować stopniowo - standalone components mogą współistnieć z NgModules.
Angular - The Enterprise Web Framework
What is Angular?
Angular is a complete, opinionated JavaScript/TypeScript framework developed by Google. It is designed for building scalable enterprise applications with built-in solutions for routing, forms, HTTP, testing, and dependency injection. Angular uses TypeScript as its default language and offers a "batteries-included" approach where all essential tools are part of the framework.
Since Angular 17 (2023), the framework has undergone a significant modernization by introducing Signals for reactivity, standalone components as the default, and a new control flow syntax. These changes have greatly simplified development and brought Angular closer to modern frameworks like React and Vue.
Why Angular?
Key advantages
- Complete ecosystem - Routing, forms, HTTP, i18n, animations built-in
- TypeScript first - Full type safety from start to finish
- Dependency Injection - Powerful DI system for testability and modularity
- Enterprise-ready - Proven in large corporate applications
- Angular CLI - Generators, build, test, deploy in one tool
- Long-term support - Google provides support and regular updates
Angular vs React vs Vue
| Feature | Angular | React | Vue |
|---|---|---|---|
| Type | Framework | Library | Framework |
| Language | TypeScript | JS/TS | JS/TS |
| Architecture | Opinionated | Flexible | Flexible |
| Reactivity | Signals/RxJS | Hooks | Composition API |
| Routing | Built-in | React Router | Vue Router |
| State management | NgRx/Signals | Redux/Zustand | Pinia |
| Forms | Reactive Forms | React Hook Form | VeeValidate |
| Learning curve | High | Medium | Low |
| Bundle size | Larger | Smaller | Small |
| Enterprise adoption | Very high | High | Medium |
Installation and configuration
Angular CLI
npm install -g @angular/cli
ng new my-app
# The interactive wizard will ask about:
# - Stylesheet format (CSS/SCSS/SASS/Less)
# - SSR/SSG
# - Routing
cd my-app
ng serve
# Application available at http://localhost:4200Angular 17+ project structure
my-angular-app/
├── src/
│ ├── app/
│ │ ├── app.component.ts # Root component
│ │ ├── app.component.html # Template
│ │ ├── app.component.scss # Styles
│ │ ├── app.component.spec.ts # Tests
│ │ ├── app.config.ts # App configuration
│ │ ├── app.routes.ts # Routing
│ │ ├── components/ # Feature components
│ │ │ ├── header/
│ │ │ └── footer/
│ │ ├── pages/ # Page components
│ │ │ ├── home/
│ │ │ └── about/
│ │ ├── services/ # Services
│ │ │ └── api.service.ts
│ │ └── models/ # Interfaces/Types
│ │ └── user.model.ts
│ ├── assets/ # Static assets
│ ├── environments/ # Environment configs
│ ├── index.html # Entry HTML
│ ├── main.ts # Bootstrap
│ └── styles.scss # Global styles
├── angular.json # Angular CLI config
├── tsconfig.json # TypeScript config
└── package.jsonApplication configuration
// src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'
import { provideRouter } from '@angular/router'
import { provideHttpClient, withFetch } from '@angular/common/http'
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'
import { routes } from './app.routes'
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withFetch()),
provideAnimationsAsync(),
],
}// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import { appConfig } from './app/app.config'
import { AppComponent } from './app/app.component'
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err))Components - basics
Standalone Components (default since Angular 17)
// src/app/components/counter/counter.component.ts
import { Component, signal, computed } from '@angular/core'
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter">
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`,
styles: [`
.counter {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
}
button {
margin: 0 4px;
padding: 8px 16px;
}
`]
})
export class CounterComponent {
count = signal(0)
double = computed(() => this.count() * 2)
increment() {
this.count.update(c => c + 1)
}
decrement() {
this.count.update(c => c - 1)
}
reset() {
this.count.set(0)
}
}Input and Output
// src/app/components/user-card/user-card.component.ts
import { Component, input, output, computed } from '@angular/core'
interface User {
id: number
name: string
email: string
avatar?: string
}
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<img [src]="user().avatar || defaultAvatar" [alt]="user().name" />
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<p>Initials: {{ initials() }}</p>
<button (click)="edit.emit(user())">Edit</button>
<button (click)="delete.emit(user().id)">Delete</button>
</div>
`,
})
export class UserCardComponent {
user = input.required<User>()
defaultAvatar = input('/assets/default-avatar.png')
edit = output<User>()
delete = output<number>()
initials = computed(() => {
const name = this.user().name
return name.split(' ').map(n => n[0]).join('').toUpperCase()
})
}
@Component({
template: `
<app-user-card
[user]="selectedUser"
(edit)="onEdit($event)"
(delete)="onDelete($event)"
/>
`,
})
export class ParentComponent {
selectedUser: User = { id: 1, name: 'John Doe', email: 'john@example.com' }
onEdit(user: User) {
console.log('Edit:', user)
}
onDelete(id: number) {
console.log('Delete:', id)
}
}Content Projection (ng-content)
// src/app/components/card/card.component.ts
import { Component, input } from '@angular/core'
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<div class="card-header">
<ng-content select="[card-header]"></ng-content>
</div>
<div class="card-body">
<ng-content></ng-content>
</div>
<div class="card-footer">
<ng-content select="[card-footer]"></ng-content>
</div>
</div>
`,
styles: [`
.card { border: 1px solid #ddd; border-radius: 8px; }
.card-header { padding: 1rem; border-bottom: 1px solid #ddd; }
.card-body { padding: 1rem; }
.card-footer { padding: 1rem; border-top: 1px solid #ddd; }
`]
})
export class CardComponent {}
@Component({
template: `
<app-card>
<h2 card-header>User Profile</h2>
<p>This is the card content.</p>
<p>More content here.</p>
<button card-footer>Save</button>
</app-card>
`,
})
export class ProfileComponent {}New Control Flow syntax (Angular 17+)
@if - conditional rendering
@Component({
template: `
@if (isLoggedIn()) {
<app-dashboard />
} @else if (isLoading()) {
<app-spinner />
} @else {
<app-login />
}
@if (user(); as user) {
<p>Welcome, {{ user.name }}</p>
}
`,
})
export class AppComponent {
isLoggedIn = signal(false)
isLoading = signal(true)
user = signal<User | null>(null)
}@for - iteration
@Component({
template: `
@for (item of items(); track item.id) {
<div class="item">
{{ item.name }}
</div>
} @empty {
<p>No items found</p>
}
@for (item of items(); track item.id; let i = $index; let first = $first; let last = $last) {
<div
[class.first]="first"
[class.last]="last"
>
{{ i + 1 }}. {{ item.name }}
</div>
}
`,
})
export class ListComponent {
items = signal<Item[]>([])
}@switch - pattern matching
@Component({
template: `
@switch (status()) {
@case ('loading') {
<app-spinner />
}
@case ('error') {
<app-error [message]="errorMessage()" />
}
@case ('success') {
<app-data [data]="data()" />
}
@default {
<p>Unknown status</p>
}
}
`,
})
export class StatusComponent {
status = signal<'idle' | 'loading' | 'success' | 'error'>('idle')
data = signal<any>(null)
errorMessage = signal('')
}@defer - lazy loading
@Component({
template: `
@defer (on viewport) {
<app-heavy-component />
} @loading {
<p>Loading component...</p>
} @error {
<p>Failed to load</p>
} @placeholder {
<p>Scroll down to load</p>
}
@defer (on idle) {
<app-analytics />
}
@defer (on interaction) {
<app-comments />
}
@defer (on hover) {
<app-tooltip />
}
@defer (on timer(2000)) {
<app-delayed />
}
@defer (when showDetails()) {
<app-details />
}
`,
})
export class DeferExampleComponent {
showDetails = signal(false)
}Signals - reactivity
Basic Signals
import { Component, signal, computed, effect } from '@angular/core'
@Component({
selector: 'app-signals-demo',
standalone: true,
template: `
<div>
<p>First: {{ firstName() }}</p>
<p>Last: {{ lastName() }}</p>
<p>Full: {{ fullName() }}</p>
<input
[value]="firstName()"
(input)="firstName.set($any($event.target).value)"
/>
</div>
`,
})
export class SignalsDemoComponent {
firstName = signal('John')
lastName = signal('Doe')
fullName = computed(() => `${this.firstName()} ${this.lastName()}`)
constructor() {
effect(() => {
console.log('Full name changed:', this.fullName())
})
}
updateFirstName(name: string) {
this.firstName.set(name)
}
incrementAge() {
this.age.update(age => age + 1)
}
private age = signal(25)
}Signal-based Inputs
import { Component, input, model, output } from '@angular/core'
@Component({
selector: 'app-form-field',
standalone: true,
template: `
<div class="field">
<label>{{ label() }}</label>
<input
[type]="type()"
[value]="value()"
[placeholder]="placeholder()"
(input)="onInput($event)"
/>
@if (error()) {
<span class="error">{{ error() }}</span>
}
</div>
`,
})
export class FormFieldComponent {
label = input.required<string>()
type = input<'text' | 'email' | 'password'>('text')
placeholder = input('')
error = input<string | null>(null)
value = model<string>('')
onInput(event: Event) {
const target = event.target as HTMLInputElement
this.value.set(target.value)
}
}
@Component({
template: `
<app-form-field
label="Email"
type="email"
[(value)]="email"
[error]="emailError()"
/>
`,
})
export class FormComponent {
email = signal('')
emailError = computed(() => {
const email = this.email()
if (!email) return 'Email is required'
if (!email.includes('@')) return 'Invalid email'
return null
})
}toSignal and toObservable
import { Component, inject } from '@angular/core'
import { toSignal, toObservable } from '@angular/core/rxjs-interop'
import { HttpClient } from '@angular/common/http'
import { switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators'
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (users()) {
@for (user of users(); track user.id) {
<div>{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`,
})
export class UsersComponent {
private http = inject(HttpClient)
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: null }
)
}
@Component({
template: `
<input
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value)"
/>
@for (result of searchResults(); track result.id) {
<div>{{ result.name }}</div>
}
`,
})
export class SearchComponent {
private http = inject(HttpClient)
searchQuery = signal('')
searchResults = toSignal(
toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query =>
query.length >= 3
? this.http.get<Item[]>(`/api/search?q=${query}`)
: of([])
)
),
{ initialValue: [] }
)
}Services and Dependency Injection
Creating a service
// src/app/services/user.service.ts
import { Injectable, inject, signal, computed } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { toSignal } from '@angular/core/rxjs-interop'
import { Observable, BehaviorSubject } from 'rxjs'
import { map, tap, catchError } from 'rxjs/operators'
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private http = inject(HttpClient)
private apiUrl = '/api/users'
private usersSignal = signal<User[]>([])
private loadingSignal = signal(false)
private errorSignal = signal<string | null>(null)
readonly users = this.usersSignal.asReadonly()
readonly loading = this.loadingSignal.asReadonly()
readonly error = this.errorSignal.asReadonly()
readonly userCount = computed(() => this.usersSignal().length)
readonly admins = computed(() =>
this.usersSignal().filter(u => u.role === 'admin')
)
async loadUsers() {
this.loadingSignal.set(true)
this.errorSignal.set(null)
try {
const users = await this.http.get<User[]>(this.apiUrl).toPromise()
this.usersSignal.set(users || [])
} catch (err) {
this.errorSignal.set('Failed to load users')
} finally {
this.loadingSignal.set(false)
}
}
async createUser(user: Omit<User, 'id'>): Promise<User | null> {
try {
const newUser = await this.http.post<User>(this.apiUrl, user).toPromise()
if (newUser) {
this.usersSignal.update(users => [...users, newUser])
}
return newUser || null
} catch {
return null
}
}
async updateUser(id: number, updates: Partial<User>): Promise<boolean> {
try {
await this.http.patch(`${this.apiUrl}/${id}`, updates).toPromise()
this.usersSignal.update(users =>
users.map(u => u.id === id ? { ...u, ...updates } : u)
)
return true
} catch {
return false
}
}
async deleteUser(id: number): Promise<boolean> {
try {
await this.http.delete(`${this.apiUrl}/${id}`).toPromise()
this.usersSignal.update(users => users.filter(u => u.id !== id))
return true
} catch {
return false
}
}
}Using a service in a component
import { Component, inject, OnInit } from '@angular/core'
import { UserService } from './services/user.service'
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (userService.loading()) {
<app-spinner />
} @else if (userService.error()) {
<app-error [message]="userService.error()" />
} @else {
<p>Total users: {{ userService.userCount() }}</p>
@for (user of userService.users(); track user.id) {
<app-user-card
[user]="user"
(delete)="deleteUser($event)"
/>
}
}
`,
})
export class UsersComponent implements OnInit {
protected userService = inject(UserService)
ngOnInit() {
this.userService.loadUsers()
}
async deleteUser(id: number) {
const success = await this.userService.deleteUser(id)
if (!success) {
alert('Failed to delete user')
}
}
}Hierarchical DI
@Injectable({ providedIn: 'root' })
export class GlobalService {}
@Component({
providers: [LocalService],
})
export class ParentComponent {}
@Component({
providers: [
{
provide: ApiService,
useFactory: (http: HttpClient, config: ConfigService) => {
return new ApiService(http, config.apiUrl)
},
deps: [HttpClient, ConfigService],
},
],
})
export class AppComponent {}
@Component({
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' },
],
})
export class ConfiguredComponent {}
export const API_URL = new InjectionToken<string>('API URL')Routing
Route configuration
// src/app/app.routes.ts
import { Routes } from '@angular/router'
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./pages/home/home.component')
.then(m => m.HomeComponent),
title: 'Home',
},
{
path: 'about',
loadComponent: () => import('./pages/about/about.component')
.then(m => m.AboutComponent),
title: 'About Us',
},
{
path: 'users',
loadComponent: () => import('./pages/users/users.component')
.then(m => m.UsersComponent),
children: [
{
path: '',
loadComponent: () => import('./pages/users/user-list/user-list.component')
.then(m => m.UserListComponent),
},
{
path: ':id',
loadComponent: () => import('./pages/users/user-detail/user-detail.component')
.then(m => m.UserDetailComponent),
},
],
},
{
path: 'admin',
loadComponent: () => import('./pages/admin/admin.component')
.then(m => m.AdminComponent),
canActivate: [authGuard],
canActivateChild: [adminGuard],
},
{
path: '**',
loadComponent: () => import('./pages/not-found/not-found.component')
.then(m => m.NotFoundComponent),
},
]Route Guards
// src/app/guards/auth.guard.ts
import { inject } from '@angular/core'
import { Router, CanActivateFn } from '@angular/router'
import { AuthService } from '../services/auth.service'
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService)
const router = inject(Router)
if (authService.isAuthenticated()) {
return true
}
router.navigate(['/login'], {
queryParams: { returnUrl: state.url },
})
return false
}
export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService)
if (authService.isAdmin()) {
return true
}
return false
}Route parameters
// src/app/pages/users/user-detail/user-detail.component.ts
import { Component, inject, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { toSignal } from '@angular/core/rxjs-interop'
import { switchMap, map } from 'rxjs/operators'
import { UserService } from '../../../services/user.service'
@Component({
selector: 'app-user-detail',
standalone: true,
template: `
@if (user()) {
<div class="user-detail">
<h1>{{ user()!.name }}</h1>
<p>{{ user()!.email }}</p>
<button (click)="goBack()">Back</button>
</div>
} @else {
<p>Loading...</p>
}
`,
})
export class UserDetailComponent {
private route = inject(ActivatedRoute)
private router = inject(Router)
private userService = inject(UserService)
user = toSignal(
this.route.paramMap.pipe(
map(params => params.get('id')),
switchMap(id => this.userService.getUser(Number(id)))
)
)
goBack() {
this.router.navigate(['/users'])
}
}Navigation
@Component({
template: `
<nav>
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
Home
</a>
<a routerLink="/users" routerLinkActive="active">Users</a>
<a [routerLink]="['/users', user().id]">{{ user().name }}</a>
</nav>
<router-outlet />
`,
imports: [RouterLink, RouterLinkActive, RouterOutlet],
})
export class AppComponent {
private router = inject(Router)
navigateToUser(id: number) {
this.router.navigate(['/users', id])
}
navigateWithParams() {
this.router.navigate(['/search'], {
queryParams: { q: 'angular', page: 1 },
})
}
}Forms
Reactive Forms
// src/app/components/user-form/user-form.component.ts
import { Component, inject, output } from '@angular/core'
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'
@Component({
selector: 'app-user-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="field">
<label for="name">Name</label>
<input id="name" formControlName="name" />
@if (form.get('name')?.invalid && form.get('name')?.touched) {
<span class="error">Name is required (min 2 characters)</span>
}
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
@if (form.get('email')?.errors?.['email']) {
<span class="error">Invalid email format</span>
}
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password" />
@if (form.get('password')?.errors?.['minlength']) {
<span class="error">Password must be at least 8 characters</span>
}
</div>
<div formGroupName="address">
<h3>Address</h3>
<input formControlName="street" placeholder="Street" />
<input formControlName="city" placeholder="City" />
<input formControlName="zip" placeholder="ZIP" />
</div>
<button type="submit" [disabled]="form.invalid">
Submit
</button>
</form>
`,
})
export class UserFormComponent {
private fb = inject(FormBuilder)
submitted = output<UserFormData>()
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
address: this.fb.group({
street: [''],
city: [''],
zip: ['', Validators.pattern(/^\d{5}$/)],
}),
})
onSubmit() {
if (this.form.valid) {
this.submitted.emit(this.form.value as UserFormData)
}
}
}Custom validators
// src/app/validators/custom.validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'
export function forbiddenName(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value)
return forbidden ? { forbiddenName: { value: control.value } } : null
}
}
export function passwordMatchValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const password = control.get('password')
const confirm = control.get('confirmPassword')
if (password && confirm && password.value !== confirm.value) {
return { passwordMismatch: true }
}
return null
}
}
export function uniqueEmailValidator(
userService: UserService
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
return userService.checkEmailExists(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
catchError(() => of(null))
)
}
}
form = this.fb.group({
name: ['', [Validators.required, forbiddenName(/admin/i)]],
email: ['', [Validators.required, Validators.email],
[uniqueEmailValidator(this.userService)]
],
password: ['', Validators.required],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator() })HTTP Client
Basic requests
// src/app/services/api.service.ts
import { Injectable, inject } from '@angular/core'
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http'
import { Observable, throwError } from 'rxjs'
import { map, catchError, retry } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient)
private baseUrl = '/api'
getUsers(page = 1, limit = 10): Observable<User[]> {
const params = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString())
return this.http.get<User[]>(`${this.baseUrl}/users`, { params })
.pipe(
retry(2),
catchError(this.handleError)
)
}
getUserById(id: number): Observable<User> {
const headers = new HttpHeaders()
.set('Accept', 'application/json')
return this.http.get<User>(`${this.baseUrl}/users/${id}`, { headers })
}
createUser(user: CreateUserDto): Observable<User> {
return this.http.post<User>(`${this.baseUrl}/users`, user)
}
updateUser(id: number, user: UpdateUserDto): Observable<User> {
return this.http.put<User>(`${this.baseUrl}/users/${id}`, user)
}
patchUser(id: number, updates: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/users/${id}`, updates)
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/users/${id}`)
}
private handleError(error: any) {
console.error('API Error:', error)
return throwError(() => new Error(error.message || 'Server error'))
}
}HTTP Interceptors
// src/app/interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http'
import { inject } from '@angular/core'
import { AuthService } from '../services/auth.service'
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService)
const token = authService.getToken()
if (token) {
const cloned = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
})
return next(cloned)
}
return next(req)
}
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError(error => {
if (error.status === 401) {
inject(Router).navigate(['/login'])
}
return throwError(() => error)
})
)
}
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, errorInterceptor])
),
],
}Angular Material
Installation
ng add @angular/materialUsing components
import { Component } from '@angular/core'
import { MatButtonModule } from '@angular/material/button'
import { MatInputModule } from '@angular/material/input'
import { MatFormFieldModule } from '@angular/material/form-field'
import { MatCardModule } from '@angular/material/card'
import { MatIconModule } from '@angular/material/icon'
import { MatTableModule } from '@angular/material/table'
import { MatPaginatorModule } from '@angular/material/paginator'
import { MatDialogModule, MatDialog } from '@angular/material/dialog'
@Component({
selector: 'app-users-table',
standalone: true,
imports: [
MatButtonModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
],
template: `
<mat-card>
<mat-card-header>
<mat-card-title>Users</mat-card-title>
</mat-card-header>
<mat-card-content>
<table mat-table [dataSource]="users()">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let user">{{ user.name }}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>Email</th>
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let user">
<button mat-icon-button (click)="edit(user)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(user)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-paginator
[length]="totalUsers()"
[pageSize]="10"
[pageSizeOptions]="[5, 10, 25]"
(page)="onPageChange($event)"
/>
</mat-card-content>
</mat-card>
`,
})
export class UsersTableComponent {
private dialog = inject(MatDialog)
users = signal<User[]>([])
totalUsers = signal(0)
displayedColumns = ['name', 'email', 'actions']
edit(user: User) {
const dialogRef = this.dialog.open(UserDialogComponent, {
data: user,
width: '400px',
})
dialogRef.afterClosed().subscribe(result => {
if (result) {
// Handle update
}
})
}
}Testing
Component testing
// src/app/components/counter/counter.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CounterComponent } from './counter.component'
describe('CounterComponent', () => {
let component: CounterComponent
let fixture: ComponentFixture<CounterComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
}).compileComponents()
fixture = TestBed.createComponent(CounterComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should display initial count of 0', () => {
const compiled = fixture.nativeElement as HTMLElement
expect(compiled.textContent).toContain('Count: 0')
})
it('should increment count', () => {
component.increment()
fixture.detectChanges()
expect(component.count()).toBe(1)
})
it('should decrement count', () => {
component.count.set(5)
component.decrement()
fixture.detectChanges()
expect(component.count()).toBe(4)
})
})Service testing
// src/app/services/user.service.spec.ts
import { TestBed } from '@angular/core/testing'
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'
import { UserService } from './user.service'
describe('UserService', () => {
let service: UserService
let httpMock: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService],
})
service = TestBed.inject(UserService)
httpMock = TestBed.inject(HttpTestingController)
})
afterEach(() => {
httpMock.verify()
})
it('should fetch users', async () => {
const mockUsers = [
{ id: 1, name: 'John', email: 'john@example.com', role: 'user' },
]
const promise = service.loadUsers()
const req = httpMock.expectOne('/api/users')
expect(req.request.method).toBe('GET')
req.flush(mockUsers)
await promise
expect(service.users()).toEqual(mockUsers)
})
it('should handle error', async () => {
const promise = service.loadUsers()
const req = httpMock.expectOne('/api/users')
req.error(new ErrorEvent('Network error'))
await promise
expect(service.error()).toBe('Failed to load users')
})
})Deployment
Production build
ng build --configuration production
ng build --configuration production --ssrVercel
// vercel.json
{
"buildCommand": "ng build --configuration production",
"outputDirectory": "dist/my-app/browser",
"framework": null
}Docker
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production
FROM nginx:alpine
COPY /app/dist/my-app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Pricing
- 100% free - MIT License
- Angular Enterprise Support: Available from Google (pricing on request)
FAQ - frequently asked questions
Is Angular still relevant in 2025?
Yes. Angular 17+ has undergone a significant modernization with Signals, standalone components, and the new control flow syntax. The framework is actively developed by Google and used by many large companies.
What is the difference between Angular and AngularJS?
AngularJS (1.x) is an old framework from 2010, completely different from Angular (2+). Angular is a fully rewritten framework with TypeScript, components, and a modern architecture. AngularJS is no longer supported.
When should you choose Angular over React/Vue?
Angular is best for large enterprise applications where you need a complete framework with built-in solutions. React/Vue are better for smaller projects or when you need more flexibility.
Do I need to know RxJS?
Less and less. With the introduction of Signals in Angular 17+, RxJS is optional for many use cases. However, for complex asynchronous operations, RxJS is still a powerful tool.
How to migrate from NgModules to Standalone Components?
Angular CLI offers automatic migration: ng generate @angular/core:standalone. You can also migrate gradually - standalone components can coexist with NgModules.