We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide37 min read

Angular

Angular is an enterprise framework from Google with TypeScript, RxJS, dependency injection, and complete tooling ecosystem.

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

  1. Kompletny ekosystem - Routing, forms, HTTP, i18n, animations wbudowane
  2. TypeScript first - Pełne typowanie od początku do końca
  3. Dependency Injection - Potężny system DI dla testowalności i modularności
  4. Enterprise-ready - Sprawdzony w dużych aplikacjach korporacyjnych
  5. Angular CLI - Generatory, build, test, deploy w jednym narzędziu
  6. Long-term support - Google zapewnia wsparcie i regularne aktualizacje

Angular vs React vs Vue

CechaAngularReactVue
TypFrameworkLibraryFramework
JęzykTypeScriptJS/TSJS/TS
ArchitekturaOpinionatedFlexibleFlexible
ReaktywnośćSignals/RxJSHooksComposition API
RoutingWbudowanyReact RouterVue Router
State managementNgRx/SignalsRedux/ZustandPinia
FormsReactive FormsReact Hook FormVeeValidate
Learning curveWysokaŚredniaNiska
Bundle sizeWiększyMniejszyMały
Enterprise adoptionBardzo wysokieWysokieŚrednie

Instalacja i konfiguracja

Angular CLI

Code
Bash
# 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:4200

Struktura projektu Angular 17+

Code
TEXT
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.json

Konfiguracja aplikacji

TSsrc/app/app.config.ts
TypeScript
// 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(),
  ],
}
TSsrc/main.ts
TypeScript
// 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)

TSsrc/app/components/counter/counter.component.ts
TypeScript
// 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

TSsrc/app/components/user-card/user-card.component.ts
TypeScript
// 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)

TSsrc/app/components/card/card.component.ts
TypeScript
// 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

Code
TypeScript
@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

Code
TypeScript
@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

Code
TypeScript
@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

Code
TypeScript
@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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

TSsrc/app/services/user.service.ts
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
// 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

TSsrc/app/app.routes.ts
TypeScript
// 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

TSsrc/app/guards/auth.guard.ts
TypeScript
// 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

TSsrc/app/pages/users/user-detail/user-detail.component.ts
TypeScript
// 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

Code
TypeScript
@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

TSsrc/app/components/user-form/user-form.component.ts
TypeScript
// 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

TSsrc/app/validators/custom.validators.ts
TypeScript
// 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

TSsrc/app/services/api.service.ts
TypeScript
// 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

TSsrc/app/interceptors/auth.interceptor.ts
TypeScript
// 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

Code
Bash
ng add @angular/material

Użycie komponentów

Code
TypeScript
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

TSsrc/app/components/counter/counter.component.spec.ts
TypeScript
// 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

TSsrc/app/services/user.service.spec.ts
TypeScript
// 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

Code
Bash
# Standard build
ng build --configuration production

# Z SSR
ng build --configuration production --ssr

Vercel

vercel.json
JSON
// vercel.json
{
  "buildCommand": "ng build --configuration production",
  "outputDirectory": "dist/my-app/browser",
  "framework": null
}

Docker

Code
DOCKERFILE
# 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 --from=builder /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

  1. Complete ecosystem - Routing, forms, HTTP, i18n, animations built-in
  2. TypeScript first - Full type safety from start to finish
  3. Dependency Injection - Powerful DI system for testability and modularity
  4. Enterprise-ready - Proven in large corporate applications
  5. Angular CLI - Generators, build, test, deploy in one tool
  6. Long-term support - Google provides support and regular updates

Angular vs React vs Vue

FeatureAngularReactVue
TypeFrameworkLibraryFramework
LanguageTypeScriptJS/TSJS/TS
ArchitectureOpinionatedFlexibleFlexible
ReactivitySignals/RxJSHooksComposition API
RoutingBuilt-inReact RouterVue Router
State managementNgRx/SignalsRedux/ZustandPinia
FormsReactive FormsReact Hook FormVeeValidate
Learning curveHighMediumLow
Bundle sizeLargerSmallerSmall
Enterprise adoptionVery highHighMedium

Installation and configuration

Angular CLI

Code
Bash
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:4200

Angular 17+ project structure

Code
TEXT
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.json

Application configuration

TSsrc/app/app.config.ts
TypeScript
// 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(),
  ],
}
TSsrc/main.ts
TypeScript
// 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)

TSsrc/app/components/counter/counter.component.ts
TypeScript
// 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

TSsrc/app/components/user-card/user-card.component.ts
TypeScript
// 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)

TSsrc/app/components/card/card.component.ts
TypeScript
// 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

Code
TypeScript
@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

Code
TypeScript
@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

Code
TypeScript
@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

Code
TypeScript
@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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

TSsrc/app/services/user.service.ts
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
@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

TSsrc/app/app.routes.ts
TypeScript
// 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

TSsrc/app/guards/auth.guard.ts
TypeScript
// 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

TSsrc/app/pages/users/user-detail/user-detail.component.ts
TypeScript
// 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

Code
TypeScript
@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

TSsrc/app/components/user-form/user-form.component.ts
TypeScript
// 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

TSsrc/app/validators/custom.validators.ts
TypeScript
// 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

TSsrc/app/services/api.service.ts
TypeScript
// 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

TSsrc/app/interceptors/auth.interceptor.ts
TypeScript
// 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

Code
Bash
ng add @angular/material

Using components

Code
TypeScript
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

TSsrc/app/components/counter/counter.component.spec.ts
TypeScript
// 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

TSsrc/app/services/user.service.spec.ts
TypeScript
// 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

Code
Bash
ng build --configuration production

ng build --configuration production --ssr

Vercel

vercel.json
JSON
// vercel.json
{
  "buildCommand": "ng build --configuration production",
  "outputDirectory": "dist/my-app/browser",
  "framework": null
}

Docker

Code
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 --from=builder /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.