19 paź 2026
11 min

Formularze od podstaw – Angular

Pomyśl przez chwilę o ostatniej aplikacji, z której korzystałeś. Pewnie gdzieś się w niej logowałeś, coś wyszukiwałeś albo uzupełniałeś dane do zamówienia. Za każdym z tych ekranów stoi formularz – i dlatego w projekcie angularowym trafisz na niego prędzej, niż się spodziewasz.

Problem w tym, że na początku formularze w Angularze potrafią nieźle namieszać w głowie. Zaglądasz do kodu jednego projektu i widzisz [(ngModel)]. Trafiasz do innego – a tam wszędzie formControlName. W jeszcze innym pojawia się jakiś [formField]. A do tego wszędzie przewijają się tajemnicze invalid, touched czy valueChanges i skomplikowana konfiguracja. Brzmi znajomo?

Spokojnie, wszystko po kolei. W tym artykule rozplączemy ten kłębek: zobaczymy, jakie rodzaje formularzy daje nam Angular, czym się od siebie różnią, jak podpiąć je pod HTML i co oznaczają te wszystkie stany. Nie będziemy zaglądać w każdy zakamarek – chodzi o to, żebyś po przeczytaniu tego artykułu otworzył komponent z formularzem i przynajmniej z grubsza kojarzył, na co patrzysz.

Formularze w Angularze

Na pierwszy rzut oka formularz to po prostu <form> z kilkoma <input> w środku. Czego tu jeszcze potrzeba?

Okazuje się, że całkiem sporo. Chcemy wiedzieć, co użytkownik wpisał. Chcemy sprawdzić, czy e-mail wygląda jak e-mail, a hasło ma odpowiednią długość i  złożoność. Chcemy pokazać błąd – ale nie od razu po otwarciu strony, tylko wtedy, gdy użytkownik faktycznie coś w polu zmienił. Robienie tego ręcznie, nasłuchując zdarzeń input czy blur w każdym polu, szybko zamieniłoby się w drogę przez mękę.

I właśnie tym zajmują się formularze w Angularze. Framework śledzi za nas wartość każdego pola, jego poprawność oraz to, czy użytkownik zdążył już coś w nim zrobić. My możemy się skupić na tym, co z tymi informacjami zrobić.

Co ciekawe, Angular nie daje nam jednego sposobu na formularze, tylko trzy:

Podejście Gdzie definiujemy formularz Wiązanie w HTML Import
Template-driven forms w szablonie HTML [(ngModel)] FormsModule z @angular/forms
Reactive forms w klasie komponentu [formGroup], formControlName ReactiveFormsModule z @angular/forms
Signal forms w klasie komponentu, na bazie sygnału [formField] FormField z @angular/forms/signals

Template-driven i reactive forms są z nami od lat, więc bez problemu spotkasz je w istniejących projektach. Signal forms to najmłodsze dziecko w rodzinie – pojawiły się jako eksperyment w Angularze 21, a w Angularze 22 dostały już status stabilnego API.

Dobra wiadomość jest taka, że wszystkie trzy rozwiązują ten sam problem. Gdy zrozumiesz jedno podejście, w pozostałych odnajdziesz bardzo dużo znajomych pojęć. Zacznijmy więc od najprostszego.

Template-driven forms

Zacznijmy od podejścia, które wielu osobom wydaje się najbardziej naturalne. Wszystko, co dotyczy pól i walidacji, opisujemy w HTML-u, a klasa komponentu trzyma jedynie dane. Resztą Angular zajmuje się sam, po cichu, pod spodem.

Zobaczmy to na przykładzie prostego logowania. Na start potrzebujemy FormsModule:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-login',
  imports: [FormsModule],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  email = '';
  password = '';

  onSubmit(): void {
    console.log(this.email, this.password);
  }
}

Czas połączyć pola z naszymi właściwościami. Służy do tego [(ngModel)]:

<form (ngSubmit)="onSubmit()">
  <input type="email" name="email" [(ngModel)]="email" required />
  <input type="password" name="password" [(ngModel)]="password" required minlength="8" />

  <button type="submit">Zaloguj</button>
</form>

Zapis [(ngModel)] to tzw. two-way binding, czyli wiązanie dwukierunkowe. Co najważniejsze, takie wiązanie działa w obie strony: gdy użytkownik wpisze coś w pole, wartość trafia do email w komponencie, a gdy zmienimy email w kodzie, pole w formularzu też się zaktualizuje.

Przy okazji warto zwrócić uwagę na kilka drobiazgów, o które łatwo się potknąć na początku:

  • atrybut name jest obowiązkowy – to po nim Angular rozpoznaje pole w formularzu.
  • walidację dodajemy zwykłymi atrybutami HTML – required, minlength, email czy pattern wyglądają jak standardowy HTML, ale Angular traktuje je jako swoje walidatory.
  • (ngSubmit) zamiast (submit) – dzięki temu wysłanie formularza nie przeładuje nam strony.

Kiedy więc sięgać po template-driven forms? Dziś odpowiedź brzmi: raczej tylko wtedy, gdy trafisz na nie w istniejącym kodzie. To najstarsze z trzech podejść, jeszcze z czasów, gdy Angular używał modułów. Opiera się na FormsModule i dyrektywie ngModel, nie daje silnego typowania i słabo dogaduje się z sygnałami, w stronę których zmierza dziś cały framework. Formalnie nie zostały oznaczone jako depricated, ale w nowym kodzie lepiej po nie nie sięgać – nawet przy małym formularzu. Do tego cała logika siedzi w HTML-u, więc gdy formularz rośnie, szablon robi się zatłoczony, a testowanie przestaje być przyjemnością.

Reactive forms

W reactive forms odwracamy role. Tym razem to klasa komponentu decyduje, jak wygląda formularz, a szablon jedynie się do niego podpina. Można powiedzieć, że HTML przestaje być „mózgiem” formularza, a staje się jego „twarzą”.

Jeśli pracujesz w większym, komercyjnym projekcie, jest spora szansa, że trafisz w nim właśnie na reactive forms.

Całość składa się z trzech podstawowych klocków:

  • FormControl – pojedyncze pole, np. e-mail,
  • FormGroup – grupa pól, np. cały formularz logowania,
  • FormArray – lista pól, których liczba może się zmieniać, np. kolejne numery telefonu dodawane przyciskiem „Dodaj”.

Przepiszmy nasz formularz logowania na reactive forms:

import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  imports: [ReactiveFormsModule],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  readonly loginForm = new FormGroup({
    email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
    password: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] }),
  });

  onSubmit(): void {
    console.log(this.loginForm.getRawValue());
  }
}

Zauważ, że walidatory przeniosły się z HTML-a do kodu – teraz przekazujemy je jako funkcje z klasy Validators. A co robi tajemnicze nonNullable: true? Sprawia, że po zresetowaniu formularza pole wraca do wartości początkowej, czyli pustego stringa, zamiast zamieniać się w null. Drobiazg, który oszczędza sporo nerwów.

Ten sam formularz możemy zapisać krócej za pomocą FormBuilder, czyli serwisu, który tworzy kontrolki za nas:

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  imports: [ReactiveFormsModule],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  private readonly fb = inject(FormBuilder);

  readonly loginForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
  });
}

Co nam to upraszcza? Porównaj oba zapisy:

  • nie piszemy new FormGroup i new FormControl przy każdym polu – wystarczy this.fb.group(…),
  • każde pole to krótka tablica [‘wartość początkowa’, [walidatory]] zamiast obiektu z opcjami,
  • mniej importów – FormGroup i FormControl nie są nam już potrzebne.

Efekt jest dokładnie taki sam: pod spodem dalej powstaje FormGroup z dwoma FormControl. To tylko wygodniejszy sposób zapisu, który docenisz zwłaszcza przy formularzach z kilkunastoma polami.

Pozostaje tylko powiedzieć szablonowi, który formularz ma obsługiwać:

<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
  <input type="email" formControlName="email" />
  <input type="password" formControlName="password" />

  <button type="submit" [disabled]="loginForm.invalid">Zaloguj</button>
</form>

Dwie rzeczy robią tu całą robotę:

  • [formGroup] – mówi elementowi <form>: „to jest twój FormGroup z klasy”,
  • formControlName – łączy konkretny input z polem o tej samej nazwie wewnątrz grupy.

Zamiast (ngSubmit) na elemencie <form> możemy też po prostu wywołać metodę bezpośrednio z przycisku:

<button 
  type="button" 
  (click)="onSubmit()" 
  [disabled]="loginForm.invalid"
>Zaloguj</button>

To również zadziała, ale warto znać różnicę. Przy (ngSubmit) formularz wyśle się także po wciśnięciu Enter w dowolnym polu, a Angular oznaczy go jako wysłany (submitted). Przy (click) na przycisku obsługujemy wyłącznie kliknięcie, więc o Enterze musimy pamiętać sami.

A co, jeśli potrzebujemy tylko jednego pola, bez całej grupy? Na przykład samej wyszukiwarki? Wtedy możemy podpiąć FormControl bezpośrednio przez [formControl]:

<input type="search" [formControl]="searchControl" />

W klasie komponentu tworzymy wtedy samą kontrolkę, bez żadnej grupy:

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  template: `<input type="search" [formControl]="searchControl" />`,
})
export class SearchComponent {
  readonly searchControl = new FormControl('');
}

Taka pojedyncza kontrolka ma wszystko to, co pole w grupie: wartość, walidatory i stany.

Stany pól formularza

Wspominaliśmy na początku, że Angular „pilnuje” każdego pola. Co to właściwie znaczy?

Każde pole – a także cały formularz – ma swój stan. Możesz o nim myśleć jak o krótkiej notatce, którą Angular prowadzi dla każdej kontrolki: „czy wpisana wartość jest poprawna?”, „czy użytkownik już tu zaglądał?”, „czy coś zmienił?”. Ta notatka aktualizuje się sama, a my możemy w każdej chwili do niej zajrzeć.

Stany występują zazwyczaj w parach:

Stan Przeciwieństwo Co oznacza
valid invalid czy wartość przechodzi wszystkie walidatory
touched untouched czy użytkownik wszedł w pole i z niego wyszedł (focus, a potem blur)
dirty pristine czy użytkownik zmienił wartość pola
pending – czy trwa walidacja asynchroniczna, np. sprawdzanie na serwerze, czy login jest wolny
disabled enabled czy pole jest wyłączone; takie pole nie bierze udziału w walidacji

Do tego dochodzi errors, czyli lista „zarzutów” wobec pola. To obiekt mówiący, które walidatory nie przeszły, np. { required: true } albo { minlength: { requiredLength: 8, actualLength: 3 } }. Gdy wszystko jest w porządku, errors ma wartość null.

Najwięcej zamieszania robi zwykle para touched i dirty. Wyobraź sobie, że użytkownik klika w pole, rozmyśla się i przechodzi dalej, nic nie wpisując. Takie pole jest już touched, bo ktoś w nim był, ale wciąż pristine, bo nikt niczego nie zmienił.

A teraz odwrotna sytuacja: użytkownik coś wpisuje, po chwili wszystko usuwa i pole znów jest puste. Wygląda dokładnie tak samo jak na początku, prawda? A jednak jest już dirty. Angular nie porównuje bieżącej wartości z początkową – zapamiętuje po prostu, że użytkownik coś w polu zmieniał. Raz „pobrudzonego” pola nie da się już samym usunięciem tekstu z powrotem „wyczyścić”. Przywraca je do stanu pristine dopiero np. reset().

Po co nam to wszystko? Najczęściej po to, żeby pokazywać błędy we właściwym momencie. Wyobraź sobie formularz, który zaraz po otwarciu strony krzyczy na czerwono „pole jest wymagane!”, zanim zdążyłeś cokolwiek zrobić. Niezbyt przyjemne, prawda? Dlatego błędy pokazujemy dopiero wtedy, gdy pole jest invalid i jednocześnie touched:

@let email = loginForm.controls.email;

<input type="email" formControlName="email" />

@if (email.invalid && email.touched) {
  @if (email.hasError('required')) {
    <p class="error">Podaj adres e-mail.</p>
  }
  @if (email.hasError('email')) {
    <p class="error">To nie wygląda na poprawny adres e-mail.</p>
  }
}


A jak dobrać się do stanu w template-driven forms, skoro nie mamy tam FormControl w klasie? Wystarczy zmienna szablonu:

<input type="email" name="email" [(ngModel)]="email" required #emailField="ngModel" />

@if (emailField.invalid && emailField.touched) {
  <p class="error">Podaj adres e-mail.</p>
}

Na koniec mały bonus. Angular sam dokleja do pól klasy CSS odpowiadające ich stanom: ng-valid / ng-invalid, ng-touched / ng-untouched, ng-dirty / ng-pristine oraz ng-pending. Dzięki temu proste podświetlenie błędnych pól zrobisz w samym CSS-ie, bez ani jednej linijki TypeScriptu:

input.ng-invalid.ng-touched {
  border-color: red;
}

valueChanges i statusChanges

Skoro mowa o reactive forms, nie sposób pominąć tego, od czego wzięły swoją nazwę. Każde pole i każda grupa udostępniają dwa strumienie (Observable), które informują nas na bieżąco, co się dzieje:

  • valueChanges – daje znać za każdym razem, gdy zmieni się wartość,
  • statusChanges – daje znać, gdy zmieni się status walidacji: ‘VALID’, ‘INVALID’, ‘PENDING’ albo ‘DISABLED’.

To trochę jak subskrypcja powiadomień. Zamiast co chwilę zaglądać do pola i sprawdzać, czy coś się zmieniło, mówimy Angularowi: „daj mi znać, kiedy coś się wydarzy”.

Gdzie to się przydaje? Klasyczny przykład to wyszukiwarka. Chcemy pokazywać wyniki na bieżąco, ale raczej nie chcemy zasypywać serwera zapytaniem po każdej wpisanej literze. Wpisanie słowa „angular” oznaczałoby aż siedem zapytań! Z pomocą przychodzi valueChanges w duecie z kilkoma operatorami RxJS:

readonly searchControl = new FormControl('', { nonNullable: true });

constructor() {
  this.searchControl.valueChanges
    .pipe(
      debounceTime(300),
      distinctUntilChanged(),
      takeUntilDestroyed(),
    )
    .subscribe(phrase => this.search(phrase));
}

Co tu się dzieje?

  • debounceTime(300) – czekamy 300 ms od ostatniej reakcji użytkownika, zanim wykonamy wyszukiwanie, czyli np. wyślemy request do serwera,
  • distinctUntilChanged() – ignorujemy sytuację, w której wartość tak naprawdę się nie zmieniła,
  • takeUntilDestroyed() – kończymy subskrypcję, gdy komponent znika. Bez tego subskrypcja żyłaby dalej własnym życiem i mogłaby prowadzić do wycieków pamięci.

Dla porządku: template-driven forms też mają valueChanges (np. przez @ViewChild na NgModel), ale w praktyce mało kto z niego korzysta. Zwykle w zupełności wystarcza zdarzenie (ngModelChange) w szablonie.

Signal forms

Jeśli śledzisz ostatnie wersje Angulara, wiesz, że sygnały są dziś wszędzie: w stanie komponentów, w inputach, w zapytaniach HTTP. Przez długi czas formularze pozostawały jednak trochę z boku tej rewolucji. Aż do teraz.

Signal forms to najnowsze podejście do formularzy, zbudowane od początku do końca na sygnałach. Nie wyrzucają do kosza reactive ani template-driven forms – są po prostu trzecią opcją, która świetnie pasuje do nowoczesnych komponentów.

Najpierw definiujemy zwykły sygnałowy obiekt z danymi, które formularz ma przechowywać. Dopiero potem „owijamy” go funkcją form():

import { Component, signal } from '@angular/core';
import { form, FormField, required, email, minLength } from '@angular/forms/signals';

interface LoginData {
  email: string;
  password: string;
}

@Component({
  selector: 'app-login',
  imports: [FormField],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  readonly loginModel = signal<LoginData>({ email: '', password: '' });

  readonly loginForm = form(this.loginModel, (schemaPath) => {
    required(schemaPath.email, { message: 'Podaj adres e-mail.' });
    email(schemaPath.email, { message: 'To nie wygląda na poprawny adres e-mail.' });
    required(schemaPath.password, { message: 'Podaj hasło.' });
    minLength(schemaPath.password, 8, { message: 'Hasło musi mieć co najmniej 8 znaków.' });
  });

  onSubmit(event: Event): void {
    event.preventDefault();
    console.log(this.loginModel());
  }
}

Drugi argument funkcji form() to tzw. schemat – miejsce, w którym zbieramy wszystkie reguły walidacji w jednym miejscu. Fajny bonus: komunikat błędu możemy zdefiniować od razu przy walidatorze. Warto też zauważyć, że wszystko importujemy z osobnego pakietu @angular/forms/signals.

Skąd tu schemaPath? Schemat nie operuje na prawdziwych polach formularza, tylko opisuje reguły z góry, zanim formularz w ogóle powstanie. schemaPath.email to więc „adres” pola – wskazujemy nim, do którego pola ma trafić dana reguła. Samą kontrolkę i jej stan dostaniemy później, przez loginForm.email().

Komunikat w { message: … } jest opcjonalny. Jeśli go pominiemy, walidacja zadziała tak samo, a błąd będzie miał tylko swój rodzaj (kind), np. ‘required’.

W szablonie każde pole podpinamy dyrektywą [formField]:

<form (submit)="onSubmit($event)">
  <input type="email" [formField]="loginForm.email" />

  @if (loginForm.email().invalid() && loginForm.email().touched()) {
    @for (error of loginForm.email().errors(); track error.kind) {
      <p class="error">{{ error.message }}</p>
    }
  }

  <input type="password" [formField]="loginForm.password" />

  <button type="submit" [disabled]="loginForm().invalid()">Zaloguj</button>
</form>

Zwróć uwagę na element <form>. Nie znajdziesz tu znanego z poprzednich przykładów (ngSubmit) – to zdarzenie pochodzi z FormsModule i ReactiveFormsModule, a w signal forms żadnego z tych modułów nie importujemy. Korzystamy więc ze zwykłego, natywnego zdarzenia (submit). Jest tylko jeden haczyk: po takim zdarzeniu przeglądarka domyślnie przeładowałaby stronę. Właśnie dlatego w metodzie onSubmit wywołujemy event.preventDefault(), które to zachowanie blokuje.

Widzisz znajome twarze? Stany invalid, touched czy dirty wróciły – tyle że teraz są sygnałami. Stan pola odczytujemy, wywołując je jak funkcję: loginForm.email() zwraca obiekt stanu, a z niego czytamy invalid(), touched(), dirty(), errors() czy value(). Stan całego formularza dostajemy w ten sam sposób, przez loginForm().

Jeśli znasz już reactive forms, zwróć uwagę na dwie różnice:

  • errors() to tablica, a nie obiekt. Każdy błąd ma swój rodzaj (kind) i komunikat (message), więc wyświetlenie wszystkich błędów to zwykła pętla @for.
  • Nie ma valueChanges. I to nie przeoczenie! Wartość formularza sama w sobie jest sygnałem, więc na zmiany reagujemy tak, jak przy każdym innym sygnale.
readonly canSubmit = computed(() => this.loginForm().valid() && this.loginModel().email.endsWith('@firma.pl'));

Synchronizacja działa przy tym w obie strony. Użytkownik wpisuje coś w pole – aktualizuje się loginModel. My zmieniamy loginModel w kodzie, np. przez loginModel.set(…) – formularz od razu pokazuje nowe wartości. Żadnego ręcznego przepisywania danych tam i z powrotem.

Na koniec mała przestroga. Signal forms zadebiutowały w Angularze 21 jako eksperyment i przez kolejne wersje ich API trochę się zmieniało. Przykładowo dyrektywa [field] z wersji 21.0 nazywa się dziś [formField]. Jeśli więc trafisz w internecie na starszy tutorial, w którym coś „nie chce działać”, sprawdź najpierw, z której wersji Angulara pochodzi.

Jeśli chcesz dowiedzieć się o nich więcej, polecam artykuł naszego GDE Mateusza.

Które podejście wybrać?

To pytanie pada bardzo często, więc odpowiedzmy na nie wprost.

Najlepiej kierować się nowoczesnymi rozwiązaniami – dziś są to signal forms – o ile pozwalają na to wymagania biznesowe. Jednocześnie trzeba uważać, żeby nie wprowadzać zamieszania w kodzie, który już istnieje. Jeśli projekt od lat stoi na reactive forms, dorzucenie signal forms w jednym, przypadkowym miejscu raczej utrudni życie zespołowi, niż pomoże. Zmiana podejścia do formularzy to decyzja dla całego zespołu, a nie pojedynczego komponentu.

Podsumowanie

Zaczynaliśmy od zera – bez żadnej wiedzy o formularzach w Angularze. Teraz wiesz już, że w ogóle istnieją, po co są i jakie mają odmiany. Choć na pierwszy rzut oka mogą wydawać się skomplikowane, w praktyce bardzo upraszczają nam życie: pilnują wartości pól, sprawdzają ich poprawność i podpowiadają, co zrobił użytkownik – a my nie musimy robić tego wszystkiego ręcznie. To, że Angular wciąż je rozwija, od template-driven, przez reactive, aż po signal forms, najlepiej pokazuje, jak ważną są częścią aplikacji i że warto je udoskonalać.

Zbierzmy najważniejsze pojęcia w jednym miejscu:

  • Template-driven forms – formularz opisany w HTML-u, pola wiązane przez [(ngModel)], walidacja atrybutami.
  • Reactive forms – formularz zbudowany w klasie z FormControl i FormGroup, podpięty w HTML-u przez [formGroup] i formControlName.
  • Signal forms – model jako sygnał, formularz tworzony funkcją form(), podpięty w HTML-u przez [formField].
  • Stany – valid/invalid, touched/untouched, dirty/pristine i errors mówią nam, co dzieje się z polem.
  • valueChanges – strumień zmian wartości w reactive forms; w signal forms jego rolę przejmują computed() i effect().

Wierzę, że ten poradnik był dla Ciebie pomocny. Polecam również dodatkowy artykuł o typowanych formularzach od Dominika. Powodzenia!

Podziel się artykułem

Zapisz się na nasz newsletter

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