20 lis 2025
8 min

Angular 21 – co nowego ?

Wydano kolejną główną aktualizację Angulara, więc najwyższy czas się jej przyjrzeć. Przeanalizujmy, co zostało zmienione i jak możemy wykorzystać te zmiany w naszej codziennej pracy.

Zoneless jest już domyślnie ustawiony w Angularze

Jak zostało wspomniane we wcześniejszym artykule, wraz z wprowadzeniem Angular 20.2 mechanizm wykrywania zmian w trybie zoneless osiągnął stabilny stan. W oparciu o to Angular 21 wprowadza zoneless jako domyślną opcję dla nowych aplikacji, a także przygotowano schematy wspomagające migrację istniejących projektów Angular do trybu zoneless.

Signal Forms

Mamy nadzieję, że każdy deweloper Angulara zna Angular Signals. Jeśli nie – najwyższy czas to nadrobić. Możesz zacząć od przeczytania naszego artykułu: https://angular.love/angular-signals-a-new-feature-in-angular-16.

Prawdopodobnie znasz też Angular Forms. Jeśli nie, sprawdź proszę: https://angular.love/typed-forms-2. Zrozumienie działania sygnałów jest kluczowe przed zagłębieniem się w tę sekcję. Zakładając, że już je znasz, przejdźmy do nowej funkcji Angulara o nazwie Signal Forms.

Stwórzmy bardzo prosty formularz reaktywny

export class App implements OnInit {
 private readonly _fb = inject(FormBuilder);
 protected form!: PersonForm;


 ngOnInit() {
   this.initForm();
 }


 protected onSubmit() {
   if (this.form.valid) {
     console.log('Form submitted:', this.form.value);
   }
 }


 private initForm() {
   this.form = this._fb.group({
     name: this._fb.nonNullable.control('', 
       [ 
         Validators.required,
         Validators.minLength(3)
       ]
     ),
     surname: this._fb.nonNullable.control('',
       [ 
         Validators.required,
         Validators.maxLength(10)
       ]
     ),
     telephoneNumber: this._fb.control(
       null,
       Validators.required
     ),
   });
 }

Następnie możemy użyć go w naszej templatce:

template: `
   <form [formGroup]="form" (ngSubmit)="onSubmit()">
     <div>
       <label>
         Name:
         <input type="text" formControlName="name" />
       </label>
       @if(form.controls.name.invalid && form.controls.name.touched) {
         <p>Name is required</p>
       }
       </div>


     <div>
       <label>
         Surname:
         <input type="text" formControlName="surname" />
       </label>
       @if(form.controls.surname.invalid && form.controls.surname.touched) 
       {
         <p>Surname is required</p>
       }
     </div>


     <div>
       <label>
         Telephone Number:
         <input type="number" formControlName="telephoneNumber"/>
       </label>
       @if(form.controls.telephoneNumber.invalid && 
           form.controls.telephoneNumber.touched
       ) {
         <p>Telephone number is required</p>
       }


     </div>
     <button type="submit" [disabled]="form.invalid">Submit</button>
   </form>


   <pre>{{ form.value | json }}</pre>
 `,

Jak widzimy, jest to bardzo prosty przykład. Zainicjalizowaliśmy podstawowy formularz reaktywny z trzema kontrolkami: imię, nazwisko i numer telefonu. W szablonie sprawdzamy, czy użytkownik wchodził w interakcję z daną kontrolką, a jeśli tak to czy spełnione są warunki walidacji. Jeśli nie są, wyświetlamy prosty komunikat o błędzie. Zobaczmy, jak można to zmodyfikować i ulepszyć w nowej wersji Angulara.

Najpierw zaktualizujemy klasę naszego komponentu:

protected readonly person = signal<PersonForm>({
   name: '',
   surname: '',
   telephoneNumber: null
 })
protected readonly personForm = form(this.person);

Jak widać, stworzyliśmy prosty sygnał, który pełni rolę modelu dla naszego nowego formularza sygnałowego. Usunęliśmy również metodę OnInit, nie jest już potrzebna. Funkcja form() to nowe API wprowadzone w najnowszej wersji Angulara. Pamiętaj, że gdy zaktualizujemy wartość w personForm, wartość w naszym modelu formularza również zostanie automatycznie zaktualizowana:

changePersonName(value: string) {
   this.personForm.name().value.set(value);
   console.log(this.person()); // {name: 'John', surname: '',    telephoneNumber: null}
 }

Przejdźmy teraz do szablonu, aby zobaczyć, jak można sprawić, by formularz działał w jego obrębie. Zanim zaktualizujesz swój formularz, upewnij się, że dyrektywa field – odpowiedzialna za powiązanie pól formularza z komponentami interfejsu użytkownika  została prawidłowo zaimportowana. Gdy to będzie gotowe, możemy zaktualizować formularz w następujący sposób:

template: `
   <form (ngSubmit)="onSubmit()">
     <div>
       <label>
         Name:
         <input [field]="personForm.name" type="text"/>
       </label>
     </div>


     <div>
       <label>
         Surname:
         <input [field]="personForm.surname" type="text"/>
       </label>
     </div>


     <div>
       <label>
         Telephone Number:
         <input [field]="personForm.telephoneNumber" type="number" />
       </label>
     </div>
     <button type="submit"    [disabled]="personForm().invalid()">Submit</button>
   </form>


   <pre>{{ personForm().value() | json }}</pre>
 `,

To tak proste, jak w powyższym przykładzie. Prawdopodobnie zauważyłeś, że obsługa błędów została usunięta – zrobiliśmy to celowo, aby pokazać, jak zmienił się mechanizm walidacji. Teraz możemy obsługiwać błędy w prosty sposób, iterując przez możliwe błędy i wyświetlając te, które występują.

protected readonly personForm = form(this.person, (path) => {
     required(path.name);
     required(path.surname);
     minLength(path.name, 3);
     maxLength(path.surname, 40);
});

W ten sposób wyświetlamy błędy w naszej templatce:

 <div>
       <label>
         Name:
         <input [field]="personForm.name" type="text" />
       </label>
       @for(err of personForm.name().errors(); track $index) {
         @if(err.kind === 'required') {
           <p>Name is required</p>
         }
      }
     </div>

Możesz się zastanawiać, co tak naprawdę dzieje się wewnątrz personForm. W rzeczywistości nie ma tu żadnej magii — nasza logika walidacji jest po prostu opakowana funkcją schema. Korzystamy też z nowych narzędzi walidacyjnych, takich jak required(). Wystarczy, że podamy poprawną ścieżkę do każdej funkcji walidacyjnej, którą możemy uzyskać z argumentu przekazywanego do funkcji schema.

Pamiętaj, że Signal Forms wciąż znajdują się w fazie eksperymentalnej, więc zarówno ich API, jak i zachowanie mogą ulec zmianie przed stabilnym wydaniem.

Angular aria – nowa biblioteka UI

Dostępność staje się coraz ważniejszym elementem naszego codziennego procesu tworzenia oprogramowania. To już nie tylko zalecane dobre praktyki – jeśli Twoja aplikacja ma być dostępna na terenie UE, powinna spełniać standardy opisane w tym artykule:
https://angular.love/pl/dostepnosc-cyfrowa-2025-jak-uniknac-kar-i-zyskac-nowych-uzytkownikow

Mając na uwadze rosnącą rolę dostępności, zespół Angulara przygotował nową bibliotekę UI: Angular Aria. Dzięki niej deweloperzy otrzymują kolejne narzędzie do budowania interfejsów użytkownika, obok Angular Material i CDK. Warto pamiętać, że biblioteka znajduje się obecnie w fazie developer preview.

Bibliotekę możesz w prosty sposób dodać do swojego projektu, uruchamiając w terminalu odpowiednią komendę:

npm install @angular/aria

Simple Changes jest typem generycznym

W najnowszej wersji Angular 21 typ SimpleChanges został zaktualizowany tak, aby był typem generycznym. Oznacza to, że możemy teraz jawnie określić typ danych, które przenosi każda właściwość oznaczona dekoratorem @Input(), co pozwala TypeScriptowi wymuszać silniejsze sprawdzanie typów wewnątrz metody ngOnChanges. Wcześniej SimpleChange używał typu any dla previousValue i currentValue, co oznaczało, że deweloperzy nie mieli żadnych gwarancji na etapie kompilacji dotyczących typów przekazywanych wartości. Poniżej możesz zobaczyć, jak działa to obecnie.

export interface User {
  userName: string;
  age: number;
}

@Component({
 //…//
})
export class App {
  @Input({required: true}) userName!: string;
  @Input({required: true}) age!: number;


  ngOnChanges(changes: SimpleChanges<User>) {
    if (changes.age) {
      const newAge = changes.age.currentValue;
      const oldAge = changes.age.previousValue;


      const diff = newAge - oldAge;


      console.log(`Age increased by ${diff} years`);
    }
  }
}

HttpClient zapewniony domyślnie

Wraz z wprowadzeniem najnowszej wersji Angulara nie musimy już samodzielnie zapewniać HttpClient w naszej aplikacji. Jest on teraz dostarczany domyślnie. Oznacza to, że podczas tworzenia obiektu konfiguracyjnego możesz pominąć dostarczanie HttpClient, jeśli tylko chcesz. 

// import { provideHttpClient } from `@angular/common`


export const appConfig: AppConfig = {
  providers: [
   ...anotherProviders,
   // provideHttpClient()
  ]
}

NgClass directive to style binding new schematics are on the board

Jak wiemy, używanie ngClass nie jest zalecane; mimo to nadal możesz korzystać z tej dyrektywy w swojej aplikacji. Dobrą praktyką jest jednak jej unikanie. Aby ułatwić i przyspieszyć pracę, zespół Angulara przygotował schemat migracyjny, który automatycznie konwertuje wszystkie użycia ngClass na powiązania klas (class bindings).

Oto, jak wygląda to przed migracją:

@Component({
 //…//
 imports: [NgClass],
 template: `
   <button [ngClass]="{
             'isNew': isNew()
   }">Click me</button> //before migration
 `,
})
export class App {
 protected readonly isNew = signal(true);
}

Po migracji:

@Component({
 //…//
 // imports: [NgClass] - is’s no longer needed
 template: `
   <button [class]="{
             'isNew': isNew()
   }">Click me</button> //after migration
 `,
})
export class App {
 protected readonly isNew = signal(true);
}

Jak widać, nie musimy już importować NgClass, co sprawia, że nasz bundle jest mniejszy, a kod krótszy — a więc również przyjemniejszy w czytaniu. Ten schemat migracyjny można uruchomić za pomocą następującego polecenia:

ng generate @angular/core:ngclass-to-class

Migracja dyrektywy NgStyle do powiązań stylów  nowe schematy migracyjne

Podobnie jak w przypadku migracji dyrektywy ngClass, przygotowano schemat umożliwiający migrację dyrektywy ngStyle do powiązań stylów.

Jak wcześniej, poniżej znajduje się przykład przed i po migracji:

@Component({
 //…//
 imports: [NgStyle],
 template: `
   <button [ngStyle]="{
             'border-color': borderColor(),
   }">Click me</button> //before migration
 `,
})
export class App {
  readonly theme = input.required<ColorTheme>();
  protected readonly borderColor = computed(() => this.theme() ===   'primary' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.5)' ));
}

Po migracji:

@Component({
 //…//
 // imports: [NgStyle], - is’s no longer needed
 template: `
   <button [style]="{
             'border-color': borderColor(),
   }">Click me</button> //before migration
 `,
})
export class App {
  readonly theme = input.required<ColorTheme>();
  protected readonly borderColor = computed(() => this.theme() ===   'primary' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.5)' ));

Jak w poprzednim przykładzie, nie musisz już importować dyrektywy. Możesz uruchomić migrację następującą komendą:

ng generate @angular/core:ngstyle-to-style

KeyValue pipe wspiera opcjonalne klucze

Od najnowszego wydania Angulara można używać pipe keyvalue na obiektach z opcjonalnymi kluczami bez powodowania błędów TypeScript. Ta zmiana poprawia bezpieczeństwo typów i ułatwia pracę z modelami danych, które nie zawsze definiują wszystkie swoje właściwości.

export interface User {
  name: string;
  surname?: string;
  age?: number;
}


@Component({
  selector: 'app-root',
  imports: [KeyValuePipe],
  template: `
    @for (prop of user | keyvalue; track $index) {
      <p>Property key: {{ prop.key }}, property value: {{ prop.value }}</p>
    }
  `,
})
export class App {
  protected readonly user: User = {
    name: 'John',
    surname: 'Doe',
    age: 37
  };
}

Rozszerzenie dla  HttpResponse oraz HttpErrorResponse

W najnowszej wersji Angulara klasy HttpResponse i HttpErrorResponse wprowadzają nową właściwość responseType. Właściwość ta udostępnia typ odpowiedzi z natywnego API Fetch (na przykład: ‘basic’, ‘cors’, ‘opaque’ lub ‘opaqueredirect’).

To usprawnienie ułatwia diagnozowanie problemów związanych z CORS i daje lepszy wgląd w kontekst bezpieczeństwa odpowiedzi HTTP, przy jednoczesnym zachowaniu dotychczasowego działania HttpClient.

@Injectable({ providedIn: 'root' })
export class DataService {
  private readonly _httpClient = inject(HttpClient);


  getData(): Observable<HttpResponse<any>> {
    return this.http.get('/api/data', { observe: 'response' }).pipe(
      tap(response => {
        console.log('Response type:', response.responseType);


        if (response.responseType === 'opaque') {
          console.warn('CORS issue detected — response is opaque.');
        }
      })
    );
  }
}

Vitest jest naszym nowym domyślnym test runnerem

Wraz z wprowadzeniem Angulara 21, Vitest staje się naszym domyślnym test runnerem. Dodatkowo jest on już stabilny wraz z wprowadzeniem najnowszej wersji angular. Jeśli twoje testy wykorzystują Karmę bądź Jest – nie musisz się wahać jeśli chodzi o update wersji Angulara. Wciąż będą one bowiem wspierane. Jakkolwiek eksperymentalna migracja została przygotowana przez zespół Angulara. Możesz z niej skorzystać wpisz komendę w swoim terminalu:

ng g @schematics/angular:refactor-jasmine-vitest

Pełną instrukcję dotyczącą migracji testów znajdziesz tu:

https://angular.dev/guide/testing/migrating-to-vitest

Podsumowanie

Jeśli uruchamiasz aplikację Angular i nie zaktualizowałeś jej jeszcze do wersji 21, to świetny moment, aby to rozważyć. Wersja 21 wprowadza kilka istotnych usprawnień – przede wszystkim pojawienie się Signal Forms. Zmiany te znacząco poprawiają doświadczenie deweloperskie, a w wielu scenariuszach także wydajność.

Aby głębiej zgłębić to, co pojawiło się w tym wydaniu, oraz dowiedzieć się, jak najlepiej wykorzystać te nowości w swoim projekcie, koniecznie zajrzyj do naszej wcześniejszej analizy ewolucji Angulara i jego nowych funkcji.

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.