Co nowego w Angular 21? Signal Forms, Vitest i Angular Aria - kompletny przewodnik na rozmowę w 2026

Sławomir Plamowski 20 min czytania
angular angular-21 frontend pytania-rekrutacyjne rekrutacja signal-forms vitest zoneless

Angular 21 to przełomowa wersja, która zamyka rozdział rozpoczęty w Angular 16. Signals, które wtedy były eksperymentem, teraz stanowią fundament całego frameworka - od zoneless change detection (teraz domyślnego) po zupełnie nowe Signal Forms. Vitest zastąpił Karmę jako domyślny test runner, a nowa biblioteka Angular Aria daje programistom kolejne narzędzie do budowania dostępnych aplikacji. Na rozmowach rekrutacyjnych w 2026 roku te zmiany będą kluczowe - kandydaci nieznający Signal Forms czy nierozumiejący dlaczego zoneless stał się standardem, będą mieli poważny problem.

W tym przewodniku znajdziesz wszystkie nowości Angular 21 z praktycznymi przykładami kodu i gotowymi odpowiedziami na pytania rekrutacyjne.

1. Signal Forms - reaktywne formularze nowej generacji

Wyjaśnienie w 30 sekund

Signal Forms to nowe eksperymentalne API formularzy wykorzystujące Signals zamiast RxJS. Zamiast FormControl i valueChanges Observable, mamy signal-based form models z automatyczną synchronizacją. Walidacja jest znacznie prostsza - zamiast Validators.required używamy funkcji required(), minLength(), maxLength(). W szablonie dyrektywa [field] zastępuje formControlName. Wszystko jest reaktywne bez subscribe/unsubscribe.

Wyjaśnienie w 2 minuty

Reactive Forms w Angular działały świetnie, ale miały swoje problemy. Wymagały importu ReactiveFormsModule, ręcznego zarządzania subskrypcjami i skomplikowanej składni walidacji. Signal Forms to zupełnie nowe podejście, które wykorzystuje to czego Angular nauczył się budując Signals.

import { Component, signal } from '@angular/core';
import { SignalForm, field, required, minLength, maxLength, email } from '@angular/forms';

interface UserRegistration {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
}

@Component({
  selector: 'app-registration',
  template: `
    <form [signalForm]="form" (ngSubmit)="onSubmit()">
      <div class="form-group">
        <label for="username">Nazwa użytkownika</label>
        <input id="username" [field]="form.fields.username" />

        @if (form.fields.username.errors().required) {
          <span class="error">Nazwa użytkownika jest wymagana</span>
        }
        @if (form.fields.username.errors().minLength) {
          <span class="error">Minimum 3 znaki</span>
        }
      </div>

      <div class="form-group">
        <label for="email">Email</label>
        <input id="email" type="email" [field]="form.fields.email" />

        @if (form.fields.email.errors().email) {
          <span class="error">Nieprawidłowy format email</span>
        }
      </div>

      <div class="form-group">
        <label for="password">Hasło</label>
        <input id="password" type="password" [field]="form.fields.password" />

        @if (form.fields.password.errors().minLength) {
          <span class="error">Hasło musi mieć minimum 8 znaków</span>
        }
      </div>

      <div class="form-group">
        <label for="confirmPassword">Potwierdź hasło</label>
        <input id="confirmPassword" type="password" [field]="form.fields.confirmPassword" />

        @if (passwordMismatch()) {
          <span class="error">Hasła nie są identyczne</span>
        }
      </div>

      <button type="submit" [disabled]="!form.valid()">
        Zarejestruj się
      </button>

      <div class="debug">
        <p>Formularz valid: {{ form.valid() }}</p>
        <p>Formularz dirty: {{ form.dirty() }}</p>
        <p>Wartości: {{ formValues() | json }}</p>
      </div>
    </form>
  `,
  standalone: true,
})
export class RegistrationComponent {
  // Definicja formularza z walidatorami
  form = new SignalForm<UserRegistration>({
    username: field('', [required(), minLength(3), maxLength(20)]),
    email: field('', [required(), email()]),
    password: field('', [required(), minLength(8)]),
    confirmPassword: field('', [required()]),
  });

  // Computed signal do walidacji cross-field
  passwordMismatch = computed(() => {
    const password = this.form.fields.password.value();
    const confirm = this.form.fields.confirmPassword.value();
    return confirm.length > 0 && password !== confirm;
  });

  // Computed signal do pobierania wszystkich wartości
  formValues = computed(() => this.form.value());

  onSubmit() {
    if (this.form.valid() && !this.passwordMismatch()) {
      console.log('Rejestracja:', this.form.value());
      // Wywołaj API rejestracji
      this.form.reset();
    }
  }
}

Porównanie z Reactive Forms:

// ❌ Stary sposób - Reactive Forms
@Component({...})
export class OldRegistrationComponent implements OnDestroy {
  form = new FormGroup({
    username: new FormControl('', [Validators.required, Validators.minLength(3)]),
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required, Validators.minLength(8)]),
  });

  private destroy$ = new Subject<void>();

  ngOnInit() {
    // Manualne subskrypcje do śledzenia zmian
    this.form.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(values => console.log(values));
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  // Nieczytelne sprawdzanie błędów w szablonie
  // *ngIf="form.get('username')?.errors?.['required'] && form.get('username')?.touched"
}

// ✅ Nowy sposób - Signal Forms
@Component({...})
export class NewRegistrationComponent {
  form = new SignalForm({
    username: field('', [required(), minLength(3)]),
    email: field('', [required(), email()]),
    password: field('', [required(), minLength(8)]),
  });

  // Brak ngOnDestroy - Signals automatycznie zarządzają lifecycle
  // Czytelne sprawdzanie błędów: form.fields.username.errors().required
}

Diagram architektury Signal Forms:

flowchart TB subgraph "Signal Forms" SF[SignalForm] --> F1[field username] SF --> F2[field email] SF --> F3[field password] F1 --> V1[required] F1 --> V2[minLength] F1 --> |"value()"| S1[Signal wartości] F1 --> |"errors()"| S2[Signal błędów] F1 --> |"valid()"| S3[Signal walidacji] end subgraph "Template" T1["[field]"] --> F1 T2["form.valid()"] --> SF T3["errors().required"] --> S2 end style SF fill:#fff3e0 style S1 fill:#e8f5e9 style S2 fill:#e8f5e9 style S3 fill:#e8f5e9

Klasyczne pytanie rekrutacyjne

Pytanie: Jakie są główne różnice między Signal Forms a Reactive Forms i kiedy wybrać które podejście?

Odpowiedź: Signal Forms są prostsze w użyciu - nie wymagają importu modułu, nie trzeba zarządzać subskrypcjami i mają czytelniejszą składnię walidacji. Walidatory to zwykłe funkcje zamiast klas, błędy są dostępne przez .errors() Signal, a wartości przez .value().

Reactive Forms nadal są lepsze gdy masz bardzo skomplikowaną logikę formularza z dynamicznym dodawaniem pól (FormArray), złożonymi walidatorami asynchronicznymi lub integracją z istniejącym kodem RxJS. Signal Forms są idealne dla większości standardowych formularzy - rejestracja, logowanie, edycja profilu.

2. Vitest jako domyślny test runner

Wyjaśnienie w 30 sekund

Angular 21 zastępuje Karmę przez Vitest jako domyślny test runner. Vitest jest znacznie szybszy dzięki natywnej obsłudze ESM i integracji z Vite, ma lepszy watch mode z HMR, i oferuje API kompatybilne z Jest. Migracja istniejących testów jest prosta dzięki schematowi ng g @schematics/angular:refactor-jasmine-vitest. Karma i Jest nadal są wspierane, ale Vitest jest rekomendowany dla nowych projektów.

Wyjaśnienie w 2 minuty

Karma była z nami od Angular 2, ale w 2025 roku pokazała swój wiek. Wymaga przeglądarki do uruchamiania testów, ma wolny startup i skomplikowaną konfigurację. Vitest rozwiązuje te problemy - testy uruchamiają się w Node.js z jsdom, startup jest niemal natychmiastowy, a konfiguracja minimalna.

// Przykład testu komponentu w Vitest
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessTesting } from '@angular/core/testing';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { signal } from '@angular/core';

describe('UserListComponent', () => {
  let component: UserListComponent;
  let fixture: ComponentFixture<UserListComponent>;
  let mockUserService: Partial<UserService>;

  beforeEach(async () => {
    // Mock serwisu z użyciem Signals
    mockUserService = {
      users: signal([
        { id: 1, name: 'Jan Kowalski' },
        { id: 2, name: 'Anna Nowak' },
      ]),
      loading: signal(false),
      loadUsers: vi.fn(),
    };

    await TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [
        provideZonelessTesting(), // Włącza zoneless w testach
        { provide: UserService, useValue: mockUserService },
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(UserListComponent);
    component = fixture.componentInstance;
  });

  it('powinien wyświetlić listę użytkowników', () => {
    // Zoneless wymaga manualnego wykrywania zmian w testach
    fixture.detectChanges();

    const userElements = fixture.nativeElement.querySelectorAll('.user');
    expect(userElements.length).toBe(2);
    expect(userElements[0].textContent).toContain('Jan Kowalski');
  });

  it('powinien pokazać loading indicator podczas ładowania', () => {
    mockUserService.loading!.set(true);
    fixture.detectChanges();

    const spinner = fixture.nativeElement.querySelector('.spinner');
    expect(spinner).toBeTruthy();
  });

  it('powinien wywołać loadUsers przy kliknięciu przycisku', () => {
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button.refresh');
    button.click();

    expect(mockUserService.loadUsers).toHaveBeenCalled();
  });

  it('powinien filtrować użytkowników po wpisaniu w wyszukiwarkę', async () => {
    component.searchTerm.set('Anna');
    fixture.detectChanges();

    // Computed signal automatycznie przefiltruje listę
    const userElements = fixture.nativeElement.querySelectorAll('.user');
    expect(userElements.length).toBe(1);
    expect(userElements[0].textContent).toContain('Anna Nowak');
  });
});

Migracja z Jasmine/Karma do Vitest:

# Uruchom schemat migracyjny
ng g @schematics/angular:refactor-jasmine-vitest

# Schemat automatycznie:
# 1. Instaluje vitest i jsdom
# 2. Aktualizuje angular.json
# 3. Konwertuje składnię testów (describe, it, expect są kompatybilne)
# 4. Zamienia spyOn na vi.spyOn
# 5. Usuwa karma.conf.js i zależności

Porównanie wydajności:

Metryka Karma + Jasmine Vitest
Cold start ~8-15s ~1-3s
Watch mode restart ~3-5s ~100-500ms
Wymagana przeglądarka Tak Nie (jsdom)
HMR w testach Nie Tak
Raportowanie coverage karma-coverage Wbudowane

Konfiguracja Vitest dla Angular:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';

export default defineConfig({
  plugins: [angular()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['src/test-setup.ts'],
    include: ['src/**/*.spec.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['node_modules/', 'src/test-setup.ts'],
    },
  },
});

Klasyczne pytanie rekrutacyjne

Pytanie: Dlaczego Angular przeszedł z Karma na Vitest i jakie są praktyczne korzyści dla zespołu?

Odpowiedź: Karma wymagała uruchamiania prawdziwej przeglądarki co spowalniało testy i komplikowało CI/CD. Vitest używa jsdom w Node.js - testy startują natychmiast i działają szybciej. Watch mode z HMR oznacza, że po zmianie kodu tylko dotknięte testy się uruchamiają ponownie, nie cały suite. Dla zespołu to oznacza szybszy feedback loop - zamiast czekać 30 sekund na wynik, mamy go w 2-3 sekundy. API jest kompatybilne z Jest, więc programiści znający Jest od razu czują się komfortowo.

3. Angular Aria - dostępność jako first-class citizen

Wyjaśnienie w 30 sekund

Angular Aria to nowa biblioteka UI (developer preview) do budowania dostępnych interfejsów. Uzupełnia Angular Material i CDK, oferując komponenty z wbudowaną obsługą ARIA. Zamiast ręcznie dodawać aria-label, aria-expanded, role i obsługę klawiatury, Angular Aria dostarcza primitives które robią to automatycznie. Pozwala budować aplikacje zgodne z WCAG bez bycia ekspertem od dostępności.

Wyjaśnienie w 2 minuty

Dostępność (accessibility, a11y) była zawsze możliwa w Angular, ale wymagała dużo wiedzy i manualnej pracy. Angular Material pomagał, ale był opinionated co do designu. Angular Aria to warstwa pomiędzy - daje zachowania dostępne bez narzucania stylów.

import { Component } from '@angular/core';
import {
  AriaButton,
  AriaMenu,
  AriaMenuItem,
  AriaDialog,
  AriaListbox,
  AriaOption,
  AriaCombobox
} from '@angular/aria';

@Component({
  selector: 'app-accessible-menu',
  template: `
    <!-- Przycisk z poprawną obsługą ARIA -->
    <button ariaButton
            [ariaPressed]="isActive()"
            (click)="toggle()">
      {{ isActive() ? 'Aktywny' : 'Nieaktywny' }}
    </button>

    <!-- Menu z obsługą klawiatury (strzałki, Enter, Escape) -->
    <div ariaMenu>
      <button ariaMenuTrigger>
        Opcje
      </button>

      <div ariaMenuContent>
        @for (item of menuItems(); track item.id) {
          <button ariaMenuItem
                  [disabled]="item.disabled"
                  (click)="selectItem(item)">
            {{ item.label }}
          </button>
        }
      </div>
    </div>

    <!-- Listbox z wielokrotnym wyborem -->
    <div ariaListbox
         [multiple]="true"
         [(selected)]="selectedOptions">
      <label ariaLabel>Wybierz technologie:</label>

      @for (tech of technologies(); track tech) {
        <div ariaOption [value]="tech">
          {{ tech }}
        </div>
      }
    </div>

    <!-- Combobox z autocomplete -->
    <div ariaCombobox>
      <input ariaComboboxInput
             placeholder="Szukaj użytkownika..."
             [(ngModel)]="searchQuery" />

      <ul ariaComboboxListbox>
        @for (user of filteredUsers(); track user.id) {
          <li ariaComboboxOption [value]="user">
            {{ user.name }}
          </li>
        }
      </ul>
    </div>

    <!-- Dialog modalny z focus trap -->
    @if (showDialog()) {
      <div ariaDialog
           [ariaLabelledBy]="'dialog-title'"
           (close)="closeDialog()">
        <h2 id="dialog-title">Potwierdź akcję</h2>
        <p>Czy na pewno chcesz kontynuować?</p>

        <div class="dialog-actions">
          <button ariaButton (click)="confirm()">Tak</button>
          <button ariaButton (click)="closeDialog()">Anuluj</button>
        </div>
      </div>
    }
  `,
  standalone: true,
  imports: [
    AriaButton,
    AriaMenu,
    AriaMenuItem,
    AriaDialog,
    AriaListbox,
    AriaOption,
    AriaCombobox,
  ],
})
export class AccessibleMenuComponent {
  isActive = signal(false);
  showDialog = signal(false);
  searchQuery = signal('');
  selectedOptions = signal<string[]>([]);

  menuItems = signal([
    { id: 1, label: 'Edytuj', disabled: false },
    { id: 2, label: 'Duplikuj', disabled: false },
    { id: 3, label: 'Usuń', disabled: true },
  ]);

  technologies = signal(['Angular', 'React', 'Vue', 'Svelte']);

  users = signal([
    { id: 1, name: 'Jan Kowalski' },
    { id: 2, name: 'Anna Nowak' },
    { id: 3, name: 'Piotr Wiśniewski' },
  ]);

  filteredUsers = computed(() => {
    const query = this.searchQuery().toLowerCase();
    if (!query) return this.users();
    return this.users().filter(u =>
      u.name.toLowerCase().includes(query)
    );
  });

  toggle() {
    this.isActive.update(v => !v);
  }

  selectItem(item: MenuItem) {
    console.log('Wybrano:', item.label);
  }

  confirm() {
    console.log('Potwierdzono');
    this.closeDialog();
  }

  closeDialog() {
    this.showDialog.set(false);
  }
}

Co Angular Aria robi automatycznie:

<!-- Kod źródłowy -->
<button ariaButton [ariaPressed]="isActive()">
  Toggle
</button>

<!-- Wygenerowany HTML z ARIA -->
<button role="button"
        aria-pressed="false"
        tabindex="0">
  Toggle
</button>

<!-- Kod źródłowy -->
<div ariaMenu>
  <button ariaMenuTrigger>Opcje</button>
  <div ariaMenuContent>
    <button ariaMenuItem>Edytuj</button>
  </div>
</div>

<!-- Wygenerowany HTML z ARIA -->
<div role="menu" aria-orientation="vertical">
  <button aria-haspopup="true"
          aria-expanded="false"
          aria-controls="menu-123">
    Opcje
  </button>
  <div id="menu-123" role="menu" hidden>
    <button role="menuitem" tabindex="-1">Edytuj</button>
  </div>
</div>

Klasyczne pytanie rekrutacyjne

Pytanie: Kiedy użyć Angular Aria zamiast Angular Material?

Odpowiedź: Angular Material to kompletny system designu z predefiniowanymi stylami - świetny gdy chcesz Material Design i nie masz własnego design systemu. Angular Aria to unstyled primitives - daje zachowania dostępne (obsługa klawiatury, ARIA atrybuty, focus management) bez narzucania wyglądu. Używaj Aria gdy masz własny design system, chcesz pełną kontrolę nad stylami lub budujesz bibliotekę komponentów. Material gdy chcesz gotowe, ładne komponenty out of the box.

4. Zoneless jako domyślny - koniec ery Zone.js

Wyjaśnienie w 30 sekund

W Angular 21 nowe aplikacje domyślnie używają zoneless change detection. Zone.js nie jest już automatycznie dodawany do bundla. Angular opiera się całkowicie na Signals do wykrywania zmian - gdy Signal się zmieni, Angular wie dokładnie które komponenty odświeżyć. Dla istniejących projektów dostępne są schematy migracyjne. To kulminacja pracy rozpoczętej w Angular 16.

Wyjaśnienie w 2 minuty

Zoneless w Angular 20.2 stał się stabilny, a w Angular 21 jest domyślny. To fundamentalna zmiana - Zone.js przez lata był "magią" Angulara, ale też źródłem problemów.

// Angular 21 - domyślna konfiguracja (zoneless)
// main.ts - nie ma już Zone.js w polyfills!
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig);

// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(), // Teraz domyślne!
    provideRouter(routes),
    provideHttpClient(), // Teraz też domyślnie dostępny
  ],
};

Jak pisać komponenty w erze zoneless:

import { Component, signal, computed, effect, inject, ChangeDetectionStrategy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

@Component({
  selector: 'app-product-catalog',
  template: `
    <div class="filters">
      <input
        [value]="searchTerm()"
        (input)="updateSearch($event)"
        placeholder="Szukaj produktów..." />

      <select [value]="selectedCategory()" (change)="updateCategory($event)">
        <option value="">Wszystkie kategorie</option>
        @for (cat of categories(); track cat) {
          <option [value]="cat">{{ cat }}</option>
        }
      </select>
    </div>

    @if (loading()) {
      <div class="loading">Ładowanie produktów...</div>
    } @else if (error()) {
      <div class="error">
        Błąd: {{ error() }}
        <button (click)="retry()">Spróbuj ponownie</button>
      </div>
    } @else {
      <div class="products-grid">
        @for (product of filteredProducts(); track product.id) {
          <div class="product-card">
            <h3>{{ product.name }}</h3>
            <p class="price">{{ product.price }} zł</p>
            <span class="category">{{ product.category }}</span>
            <button (click)="addToCart(product)">Dodaj do koszyka</button>
          </div>
        } @empty {
          <p>Nie znaleziono produktów spełniających kryteria</p>
        }
      </div>

      <div class="stats">
        <p>Znaleziono {{ filteredProducts().length }} produktów</p>
        <p>Łączna wartość: {{ totalValue() }} zł</p>
      </div>
    }
  `,
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush, // Zawsze używaj OnPush z zoneless
})
export class ProductCatalogComponent {
  private http = inject(HttpClient);

  // Stan UI jako Signals
  searchTerm = signal('');
  selectedCategory = signal('');
  loading = signal(true);
  error = signal<string | null>(null);

  // Dane z API jako Signal (używając toSignal)
  private productsResponse = toSignal(
    this.http.get<Product[]>('/api/products'),
    { initialValue: [] }
  );

  // Computed: unikalne kategorie
  categories = computed(() => {
    const products = this.productsResponse();
    const cats = [...new Set(products.map(p => p.category))];
    return cats.sort();
  });

  // Computed: filtrowane produkty
  filteredProducts = computed(() => {
    let products = this.productsResponse();

    const search = this.searchTerm().toLowerCase();
    if (search) {
      products = products.filter(p =>
        p.name.toLowerCase().includes(search)
      );
    }

    const category = this.selectedCategory();
    if (category) {
      products = products.filter(p => p.category === category);
    }

    return products;
  });

  // Computed: suma wartości
  totalValue = computed(() =>
    this.filteredProducts().reduce((sum, p) => sum + p.price, 0)
  );

  constructor() {
    // Effect do monitorowania ładowania
    effect(() => {
      const products = this.productsResponse();
      if (products.length > 0) {
        this.loading.set(false);
      }
    });

    // Effect do logowania dla analytics
    effect(() => {
      const term = this.searchTerm();
      if (term.length > 2) {
        console.log('Wyszukiwanie:', term);
      }
    });
  }

  updateSearch(event: Event) {
    const input = event.target as HTMLInputElement;
    this.searchTerm.set(input.value);
  }

  updateCategory(event: Event) {
    const select = event.target as HTMLSelectElement;
    this.selectedCategory.set(select.value);
  }

  addToCart(product: Product) {
    console.log('Dodano do koszyka:', product.name);
  }

  retry() {
    this.loading.set(true);
    this.error.set(null);
    // Logika ponownego ładowania
  }
}

Diagram porównujący Zone.js vs Zoneless:

flowchart TB subgraph "Zone.js (stary model)" Z1[setTimeout] -->|Zone patch| Z2[Zone.js] Z3[Promise] -->|Zone patch| Z2 Z4[addEventListener] -->|Zone patch| Z2 Z2 -->|"każda async operacja"| Z5[Change Detection] Z5 -->|"sprawdź wszystko"| Z6[Wszystkie komponenty] end subgraph "Zoneless (Angular 21)" S1[signal.set] -->|powiadom| S2[Signal Graph] S2 -->|"tylko zmienione"| S3[Dotknięte komponenty] end style Z2 fill:#ffcdd2 style Z5 fill:#ffcdd2 style S2 fill:#e8f5e9 style S3 fill:#e8f5e9

Klasyczne pytanie rekrutacyjne

Pytanie: Masz istniejącą aplikację Angular używającą Zone.js. Jak bezpiecznie zmigrować ją do zoneless?

Odpowiedź: Migracja powinna być stopniowa. Po pierwsze, włącz ChangeDetectionStrategy.OnPush we wszystkich komponentach - to wymusza dobre praktyki i ujawnia miejsca zależne od Zone.js. Po drugie, zamień mutable state na Signals - każde this.data = newValue zamień na this.data.set(newValue). Po trzecie, użyj toSignal() dla Observable z HttpClient zamiast subscribe w komponencie. Po czwarte, włącz zoneless przez provideZonelessChangeDetection() i testuj. Problemy będą w miejscach gdzie modyfikowałeś dane bez jawnego powiadomienia Angulara - to są właśnie te miejsca które Zone.js "magicznie" obsługiwał.

5. SimpleChanges z generics - silniejsze typowanie

Wyjaśnienie w 30 sekund

Angular 21 dodaje wsparcie generics do SimpleChanges, co pozwala na silniejsze typowanie w ngOnChanges. Zamiast changes: SimpleChanges z implicit any, można użyć changes: SimpleChanges<{name: string, age: number}>. TypeScript wymusza poprawne typy i autocomplete działa prawidłowo. To przydatne przy stopniowej migracji z @Input() do signal inputs.

Wyjaśnienie w 2 minuty

Generyczne SimpleChanges rozwiązuje problem który dręczył Angular developerów od lat - brak type safety w ngOnChanges. Wcześniej changes['someInput'] miał typ any, co prowadziło do runtime błędów. Teraz TypeScript zna dokładne typy wszystkich inputów i wymusza poprawność kodu już w compile-time.

import { Component, Input, SimpleChanges, OnChanges } from '@angular/core';

interface UserInputs {
  userId: number;
  userName: string;
  isAdmin: boolean;
}

@Component({
  selector: 'app-user-card',
  template: `
    <div class="user-card">
      <h3>{{ userName }}</h3>
      <p>ID: {{ userId }}</p>
      @if (isAdmin) {
        <span class="badge">Admin</span>
      }
    </div>
  `,
  standalone: true,
})
export class UserCardComponent implements OnChanges {
  @Input() userId!: number;
  @Input() userName!: string;
  @Input() isAdmin = false;

  // ❌ Stary sposób - brak typowania
  // ngOnChanges(changes: SimpleChanges) {
  //   if (changes['userId']) { // string key, any type
  //     const prev = changes['userId'].previousValue; // any
  //     const curr = changes['userId'].currentValue;  // any
  //   }
  // }

  // ✅ Nowy sposób - pełne typowanie
  ngOnChanges(changes: SimpleChanges<UserInputs>) {
    // TypeScript zna typy wszystkich inputów
    if (changes.userId) {
      const prev: number | undefined = changes.userId.previousValue;
      const curr: number = changes.userId.currentValue;
      console.log(`userId zmienił się z ${prev} na ${curr}`);
    }

    if (changes.userName) {
      // Autocomplete działa prawidłowo
      const curr: string = changes.userName.currentValue;
      console.log(`Nowa nazwa: ${curr}`);
    }

    if (changes.isAdmin) {
      const curr: boolean = changes.isAdmin.currentValue;
      if (curr && !changes.isAdmin.previousValue) {
        console.log('Użytkownik został adminem!');
      }
    }
  }
}

Porównanie ze signal inputs:

// Signal inputs - rekomendowane dla nowego kodu
@Component({...})
export class ModernUserCardComponent {
  userId = input.required<number>();
  userName = input.required<string>();
  isAdmin = input(false);

  // Reaktywność wbudowana - computed zamiast ngOnChanges
  displayName = computed(() => {
    const name = this.userName();
    return this.isAdmin() ? `${name} (Admin)` : name;
  });

  constructor() {
    // effect zamiast ngOnChanges dla side effects
    effect(() => {
      console.log('userId changed to:', this.userId());
    });
  }
}

// SimpleChanges<T> - dla migracji istniejącego kodu
@Component({...})
export class LegacyUserCardComponent implements OnChanges {
  @Input() userId!: number;
  @Input() userName!: string;
  @Input() isAdmin = false;

  // Przynajmniej mamy typowanie podczas migracji
  ngOnChanges(changes: SimpleChanges<{userId: number; userName: string; isAdmin: boolean}>) {
    // Type-safe obsługa zmian
  }
}

6. HttpClient domyślnie dostępny

Wyjaśnienie w 30 sekund

W Angular 21 HttpClient jest dostarczany domyślnie - nie trzeba już jawnie dodawać provideHttpClient() w większości przypadków. To upraszcza konfigurację nowych aplikacji. Interceptory nadal wymagają jawnej konfiguracji przez withInterceptors().

Wyjaśnienie w 2 minuty

Ta zmiana to część strategii Angular by zmniejszyć boilerplate w nowych aplikacjach. HttpClient jest tak powszechnie używany, że wymaganie jawnego providera było niepotrzebną ceremonią. Interceptory nadal wymagają konfiguracji, bo ich użycie nie jest uniwersalne. Porównaj konfigurację przed i po:

// Angular 20 i wcześniej - wymagane jawne dostarczenie
export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(), // Wymagane!
  ],
};

// Angular 21 - HttpClient domyślnie dostępny
export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes),
    // HttpClient jest już dostępny!
  ],
};

// Nadal możesz dostosować HttpClient jeśli potrzebujesz
export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(
      withInterceptors([authInterceptor, loggingInterceptor]),
      withFetch(), // Używaj Fetch API zamiast XMLHttpRequest
    ),
  ],
};

Nowa właściwość responseType w HttpResponse:

import { HttpClient, HttpResponse } from '@angular/common/http';

@Component({...})
export class ApiDebugComponent {
  private http = inject(HttpClient);

  checkCors() {
    this.http.get('/api/data', { observe: 'response' })
      .subscribe((response: HttpResponse<any>) => {
        // Nowa właściwość w Angular 21
        console.log('Response type:', response.responseType);
        // 'basic' - same-origin request
        // 'cors' - cross-origin request that succeeded
        // 'opaque' - no-cors request
        // 'opaqueredirect' - redirect followed in no-cors mode

        if (response.responseType === 'cors') {
          console.log('CORS request successful');
        }
      });
  }
}

7. Schematy migracyjne - automatyczna modernizacja kodu

Wyjaśnienie w 30 sekund

Angular 21 wprowadza nowe schematy migracyjne: ngclass-to-class konwertuje [ngClass] na [class], ngstyle-to-style konwertuje [ngStyle] na [style], a refactor-jasmine-vitest migruje testy. Wszystkie są eksperymentalne ale znacząco przyspieszają modernizację kodu.

Wyjaśnienie w 2 minuty

Schematy migracyjne to potężne narzędzie do automatycznej modernizacji dużych codebases. Zamiast ręcznie szukać i zamieniać setki wystąpień ngClass czy ngStyle, Angular CLI robi to za ciebie z pełnym zrozumieniem kontekstu. Schematy są eksperymentalne, więc zawsze przejrzyj zmiany przed commitem.

# Migracja [ngClass] na [class]
ng generate @angular/core:ngclass-to-class

# Przed:
# <div [ngClass]="{'active': isActive, 'disabled': isDisabled}">
# Po:
# <div [class.active]="isActive" [class.disabled]="isDisabled">

# Migracja [ngStyle] na [style]
ng generate @angular/core:ngstyle-to-style

# Przed:
# <div [ngStyle]="{'color': textColor, 'font-size': fontSize + 'px'}">
# Po:
# <div [style.color]="textColor" [style.font-size.px]="fontSize">

# Migracja testów Jasmine do Vitest
ng g @schematics/angular:refactor-jasmine-vitest

# Konwertuje:
# - spyOn() na vi.spyOn()
# - jasmine.createSpy() na vi.fn()
# - Aktualizuje konfigurację testów

Przykład migracji ngClass:

<!-- Przed migracją -->
<div [ngClass]="{
  'card': true,
  'card--active': isActive(),
  'card--highlighted': isHighlighted(),
  'card--disabled': isDisabled()
}">
  {{ content }}
</div>

<!-- Po migracji -->
<div class="card"
     [class.card--active]="isActive()"
     [class.card--highlighted]="isHighlighted()"
     [class.card--disabled]="isDisabled()">
  {{ content }}
</div>

Przykład migracji testów:

// Przed (Jasmine)
describe('UserService', () => {
  let service: UserService;
  let httpSpy: jasmine.SpyObj<HttpClient>;

  beforeEach(() => {
    httpSpy = jasmine.createSpyObj('HttpClient', ['get', 'post']);
    service = new UserService(httpSpy);
  });

  it('should fetch users', () => {
    httpSpy.get.and.returnValue(of([{ id: 1, name: 'Test' }]));

    service.getUsers().subscribe(users => {
      expect(users.length).toBe(1);
    });

    expect(httpSpy.get).toHaveBeenCalledWith('/api/users');
  });
});

// Po (Vitest)
describe('UserService', () => {
  let service: UserService;
  let httpMock: { get: Mock; post: Mock };

  beforeEach(() => {
    httpMock = {
      get: vi.fn(),
      post: vi.fn(),
    };
    service = new UserService(httpMock as unknown as HttpClient);
  });

  it('should fetch users', () => {
    httpMock.get.mockReturnValue(of([{ id: 1, name: 'Test' }]));

    service.getUsers().subscribe(users => {
      expect(users.length).toBe(1);
    });

    expect(httpMock.get).toHaveBeenCalledWith('/api/users');
  });
});

Na co rekruterzy naprawdę zwracają uwagę

Po rozmowach z rekruterami i hiring managerami pracującymi z Angular, oto co faktycznie sprawdzają w kontekście Angular 21:

Czy rozumiesz ewolucję Angulara - kandydat który zna tylko najnowszą wersję bez zrozumienia dlaczego Angular ewoluował w tym kierunku, budzi wątpliwości. Pytania typu "dlaczego zoneless stał się domyślny?" testują głębsze zrozumienie.

Czy potrafisz migrować istniejący kod - większość firm ma istniejące aplikacje. Umiejętność stopniowej migracji z Zone.js na zoneless, z Reactive Forms na Signal Forms, z Karma na Vitest jest cenniejsza niż pisanie nowego kodu.

Czy znasz trade-offy - Signal Forms są świetne, ale eksperymentalne. Vitest jest szybki, ale jsdom ma ograniczenia. Angular Aria jest w developer preview. Kandydat który ślepo zachwyca się nowościami bez rozumienia ograniczeń, nie robi dobrego wrażenia.

Czy umiesz testować zoneless komponenty - testowanie bez Zone.js wymaga jawnego fixture.detectChanges() i rozumienia kiedy Signals się aktualizują. To częste źródło problemów w projektach migrujących.

Zadania praktyczne

  1. Migracja formularza - weź istniejący Reactive Form z walidacją i przekształć go na Signal Forms. Zachowaj tę samą funkcjonalność.
  2. Test zoneless komponentu - napisz testy dla komponentu używającego Signals i computed(). Upewnij się że prawidłowo wywołujesz detectChanges().
  3. Integracja Angular Aria - zbuduj dropdown menu z obsługą klawiatury używając Angular Aria primitives.
  4. Migracja testów - uruchom schemat migracji z Jasmine do Vitest na małym projekcie i napraw ewentualne problemy.

Zobacz też


Chcesz więcej pytań z Angulara?

Ten artykuł to tylko wierzchołek góry lodowej. Mamy ponad 150 pytań z Angular z szczegółowymi odpowiedziami, przykładami kodu i wyjaśnieniami - od podstaw po zaawansowane tematy jak Signals, Dependency Injection i optymalizacja wydajności.

Sprawdź Pełny Zestaw Pytań Angular

Lub wypróbuj nasz darmowy podgląd pytań Angular, żeby zobaczyć więcej pytań w tym stylu.

Chcesz więcej pytań rekrutacyjnych?

To tylko jeden temat z naszego kompletnego przewodnika po rozmowach rekrutacyjnych. Uzyskaj dostęp do 800+ pytań z 13 technologii.

Kup pełny dostęp Zobacz bezpłatny podgląd
Powrót do blogu

Zostaw komentarz

Pamiętaj, że komentarze muszą zostać zatwierdzone przed ich opublikowaniem.