Jesteśmy już po publikacji najnowszej wersji Angulara – 21 – jeśli nie miałeś jeszcze okazji zapoznać się z wszystkimi zmianami jakie zostały wdrożone w ramach tej iteracji zachęcam Cię do zapoznania się z naszym artykułem.
Tutaj skupimy się tylko i wyłącznie na signal forms, które notabene były prawdopodobnie najbardziej wyczekiwanym featurem ostatnich miesięcy (lat?). W tym artykule dowiesz się czym signal forms różnią się od Reactive Forms, jak wygląda nowy model walidacji i reaktywności, jak tworzyć własne kontrolki bez boilerplate’u ControlValueAccessor, oraz jak stopniowo migrować istniejące formularze dzięki compatForm.
Signal formsy w porównaniu do swoich poprzedników są sporym powiewem świeżości. Znajome koncepty jak walidatory, stan dirty, valid czy invalid pozostają, ale ich implementacja została całkowicie przepisana z wykorzystaniem sygnałów. Natomiast nie wszystko jest bliźniacze względem poprzedników – nie mamy już takich pojęć jak form group, form control czy form array. Za to typowane formularze są teraz faktycznie typowane.
Wprowadzenie do signal forms
Signal forms same w sobie są już nowością, działającą w oparciu o znane nam już dosyć dobrze sygnały. Mimo to do świata formularzy wprowadzają nową nomenklaturę i feature”y, których nie mamy szansy znać na bazie poprzednich lat z Reactive oraz Template Driven Forms. Jedną z nich jest form model.
Form Model
Form model to writable signal, którym inicjalizujemy nasz formularz. Jest to kluczowe, ponieważ form model odpowiada bezpośrednio za typ naszego formularza. Formularze obecnie są bardzo dobrze typowane i w sposób bezpośredni inferują typ z obiektu inicjalizującego.
Kolejnym bardzo istotnym punktem jest to, że wszelkiego rodzaju modyfikacje i aktualizacje naszego form modelu będą bezpośrednio propagowane i odzwierciedlane przez formularz. Obecnie ten sygnał inicjalizacyjny jest właścicielem stanu, który reprezentuje formularz – pozostają one w pełnej synchronizacji.
export class LoginComponent {
// Form model
loginModel = signal({
email: '',
password: ''
})
// We init form with defined form model
loginForm = form(this.loginModel)
}
Jest to fundamentalna zmiana w porównaniu do reactive forms. W poprzednim podejściu formularz samodzielnie zarządzał swoim stanem – mapowaliśmy pola obiektu na kontrolki formularza, ale stan formularza istniał niezależnie od źródłowego obiektu. Każda synchronizacja formularza z zewnętrznym modelem wymagała ręcznej aktualizacji wartości.
// We map entity properties into form controls, since that point they are not synchronized
form = fb.group({
email: [entity.email],
password: [entity.password]
),
Inicjalizowanie formularza
Tworzenie nowego formularza odbywa się poprzez funkcję form(). Jest to znamienne już dla Angulara, że coraz więcej funkcjonalności jest realizowanych w sposób funkcyjny. Pierwszym argumentem funkcji jest nasz uprzednio wspomniany form model, który nada typ naszemu formularzowi i w oparciu o niego zostanie zainicjalizowany Form Tree – hierarchiczna struktura pól, gdzie każdy obiekt w modelu staje się węzłem z własnymi dziećmi, a każda wartość prymitywna polem końcowym (liściem). Dzięki temu nawigacja po formularzu naturalnie odpowiada nawigacji po danych.
import { form } from '@angular/forms/signals';
loginForm = form(this.loginModel);
// Nawigacja przez kropkę - jak po zwykłym obiekcie
loginForm.email // pole email
loginForm.password // pole password
Typowanie – koniec z kompromisami
Typed Reactive Forms wprowadzone w Angular 14 były krokiem w dobrą stronę. Jednak w praktyce ich typowanie ma ograniczenia, które potrafią frustrować na co dzień. Signal forms, zaprojektowane od podstaw z myślą o TypeScript, rozwiązują te problemy.
Problem 1: Nullable wszędzie
W Reactive Forms każdy FormControl domyślnie ma typ T | null:
const emailControl = new FormControl('');
// Typ: FormControl<string | null>
emailControl.value; // string | null - zawsze nullable!
Możemy użyć nonNullable, ale wymaga to jawnej deklaracji przy każdej kontrolce:
const emailControl = new FormControl('', { nonNullable: true });
// Dopiero teraz typ to FormControl<string>
Signal forms – typ wynika bezpośrednio z modelu:
const model = signal({ email: '' });
const myForm = form(model);
myForm.email().value(); // string - bez żadnego null!
Problem 2: Metoda get() gubi typy
To jeden z najbardziej irytujących aspektów typed Reactive Forms:
const form = new FormGroup({
user: new FormGroup({
email: new FormControl(''),
name: new FormControl('')
})
});
// Mimo że form jest otypowany...
const email = form.get('user.email');
// ...email jest typu: AbstractControl<unknown, unknown> | null
// Musimy castować ręcznie
const emailTyped = form.get('user.email') as FormControl<string | null>;
Metoda get() przyjmuje string – TypeScript nie jest w stanie zweryfikować, czy ścieżka jest poprawna.Signal forms – pełne typowanie nawigacji:
const model = signal({
user: { email: '', name: '' }
});
const myForm = form(model);
// Pełne typowanie na każdym poziomie
myForm.user.email().value(); // string
// Literówka? Błąd kompilacji!
myForm.user.emial; // ❌ Property 'emial' does not exist
Problem 3: FormArray traci strukturę
FormArray w typed forms potrafi być problematyczny:
const users = new FormArray([
new FormGroup({
name: new FormControl(''),
email: new FormControl('')
})
]);
// Przy dostępie przez at()...
users.at(0); // AbstractControl - gubimy informację o strukturze FormGroup!
users.at(0).get('name'); // znowu AbstractControl | null
Signal forms zachowują pełną strukturę:
interface User {
name: string;
email: string;
}
const model = signal<{ users: User[] }>({
users: [{ name: 'Jan', email: 'jan@example.com' }]
});
const myForm = form(model);
// Pełne typowanie zachowane!
myForm.users[0].name().value(); // string
myForm.users[0].email().value(); // string
// Iteracja też jest otypowana
for (const [index, userField] of myForm.users) {
userField.name().value(); // TypeScript wie, że to string
}
Problem 4: Dynamiczne formularze
Dodawanie kontrolek w runtime to zmora typowania:
const form = new FormGroup({
name: new FormControl('')
});
form.addControl('email', new FormControl(''));
// TypeScript nadal myśli, że form ma tylko 'name'
form.controls.email; // ❌ Property 'email' does not exist
Signal forms – model jest źródłem prawdy:
const model = signal<{ name: string; email?: string }>({
name: ''
});
const myForm = form(model);
// Dodanie pola = aktualizacja modelu
model.update(m => ({ ...m, email: 'new@example.com' }));
// Typowanie automatycznie uwzględnia opcjonalne pole
if (myForm.email) {
myForm.email().value(); // string
}
Jak to działa pod maską?
Sercem systemu typów jest FieldTree<TModel> – typ, który rekurencyjnie mapuje strukturę modelu na strukturę formularza:
- Dla obiektów – każda właściwość staje się polem formularza
- Dla tablic – elementy dostępne przez indeks z zachowaniem typu
- Dla prymitywów – pole końcowe bez dzieci
Dzięki temu TypeScript zawsze wie, jaki typ ma każde pole – bez ręcznych asercji, bez castowania, bez zgadywania.
Podsumowanie
| Aspekt | Typed Reactive Forms | Signal Forms |
| Nullable domyślnie | Tak (T | null) | Nie – zależy od modelu |
| Nawigacja (get() / kropka) | Gubi typy | Pełne typowanie |
| Tablice | at() zwraca AbstractControl | Zachowuje strukturę |
| Dynamiczne pola | Wymagają type assertion | Model jako źródło prawdy |
| Refaktoring | Częściowo bezpieczny | W pełni bezpieczny |
Typed Reactive Forms były kompromisem – dodano typowanie do istniejącego API. Signal forms zaprojektowano od zera z TypeScript jako priorytetem. Różnica jest odczuwalna od pierwszej linii kodu.
Walidacja
Podobnie jak w Reactive Forms, mamy dostęp do predefiniowanych walidatorów. Jednak sposób ich aplikowania jest zupełnie inny – zamiast przekazywać walidatory przy tworzeniu kontrolki, wywołujemy funkcje wskazując na pole i walidator.
import { form, required, minLength, email, pattern } from '@angular/forms/signals';
const loginForm = form(this.loginModel, (login) => {
required(login.email);
email(login.email);
required(login.password);
minLength(login.password, 8);
});
Wszystkie walidatory przekazywane są w jednym miejscu – jako drugi parametr funkcji form(). To centralizuje logikę walidacji, co ma swoje plusy i minusy. Z jednej strony mamy pełen obraz reguł w jednym miejscu. Z drugiej – walidator nie jest aplikowany bezpośrednio obok definicji pola, jak to bywało w Reactive Forms:
// Reactive Forms - walidator przy polu
new FormControl('', [Validators.required, Validators.email])
// Signal Forms - walidatory w osobnej sekcji
form(model, (f) => {
required(f.email);
email(f.email);
});
Każdy będzie musiał subiektywnie ocenić, jak wpływa to na czytelność formularza. Przy małych formularzach różnica jest kosmetyczna. Przy dużych – centralizacja może być zaletą.
Predefiniowane walidatory
Signal forms dostarczają zestaw wbudowanych walidatorów:
required(path); // pole wymagane
min(path, minValue); // minimalna wartość liczbowa
max(path, maxValue); // maksymalna wartość liczbowa
minLength(path, length); // minimalna długość
maxLength(path, length); // maksymalna długość
pattern(path, regex); // wzorzec regex
email(path); // format email
Custom walidatory
Tworzenie własnych walidatorów jest prostsze niż kiedykolwiek:
import { form, validate, customError } from '@angular/forms/signals';
const registrationForm = form(this.model, (f) => {
// Własny walidator - funkcja otrzymuje kontekst z wartością
validate(f.username, ({ value }) => {
const username = value();
if (username.includes(' ')) {
return customError({ kind: 'no-spaces', message: 'Nazwa nie może zawierać spacji' });
}
return undefined; // brak błędu
});
// Walidator z dostępem do innych pól
validate(f.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(f.password)) {
return customError({ kind: 'password-mismatch', message: 'Hasła nie są identyczne' });
}
return undefined;
});
});
Kontekst walidatora (ctx) daje dostęp do:
- value() – wartość aktualnego pola
- valueOf(path) – wartość dowolnego innego pola
- state – pełny stan pola (touched, dirty, etc.)
- stateOf(path) – stan dowolnego innego pola
Reaktywność walidatorów – automatyczne śledzenie zależności
Tu kryje się jedna z największych zalet signal forms. Walidatory działają wewnątrz reaktywnego kontekstu, co oznacza, że Angular automatycznie śledzi wszystkie odczytane sygnały.
Spójrzmy na walidator porównujący hasła:
validate(f.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(f.password)) {
return customError({ kind: 'password-mismatch' });
}
return undefined;
});
Ten walidator uruchomi się gdy:
- Zmieni się confirmPassword (bo wywołujemy value())
- Zmieni się password (bo wywołujemy valueOf(f.password))
Czyli walidator reaguje na zmianę każdego odczytanego sygnału, nie tylko pola do którego jest przypisany.
Dlaczego to jest rewolucyjne?
Pomyśl o klasycznym scenariuszu “hasła muszą być identyczne”:
- Użytkownik wpisuje hasło w password → walidator confirmPassword się uruchamia → błąd (confirm jest puste)
- Użytkownik wpisuje to samo w confirmPassword → walidator się uruchamia → OK
- Użytkownik wraca i zmienia password → walidator confirmPassword automatycznie się uruchamia → błąd (już nie pasują)
W Reactive Forms punkt 3 wymagał ręcznej pracy:
// Reactive Forms - trzeba ręcznie powiązać
this.form.get('password').valueChanges.subscribe(() => {
this.form.get('confirmPassword').updateValueAndValidity();
});
W signal forms dzieje się to automatycznie. Zero subskrypcji, zero ręcznego wywoływania updateValueAndValidity().
Uwaga na wydajność
Skoro walidator reaguje na wszystkie odczytane sygnały, warto odczytywać tylko to, co naprawdę potrzebne:
// ⚠️ Odczytuje cały formularz - uruchomi się przy KAŻDEJ zmianie
validate(f.someField, ({ stateOf }) => {
const everything = stateOf(f).value(); // cały formularz!
// ...
});
// ✅ Precyzyjne zależności - uruchomi się tylko gdy zmieni się jedno z dwóch pól
validate(f.someField, ({ value, valueOf }) => {
const mine = value();
const related = valueOf(f.otherField);
// ...
});
Walidacja asynchroniczna
Dla walidacji wymagających zapytań do serwera mamy validateAsync i validateHttp:
import { validateHttp } from '@angular/forms/signals';
const form = form(this.model, (f) => {
validateHttp(f.username, {
request: ({ value }) =>
value() ? `/api/check-username?name=${value()}` : undefined,
onSuccess: (result) =>
result.taken ? customError({ kind: 'taken', message: 'Nazwa zajęta' }) : undefined,
onError: () =>
customError({ kind: 'server-error', message: 'Błąd sprawdzania dostępności' })
});
});
Walidacja asynchroniczna uruchamia się dopiero gdy walidacja synchroniczna przejdzie pomyślnie.
Warunkowe funkcje
Analogicznie do walidatorów, mamy funkcje pozwalające dynamicznie kontrolować stan pól:
import { form, disabled, hidden, readonly } from '@angular/forms/signals';
const orderForm = form(this.model, (order) => {
// Pole wyłączone warunkowo
disabled(order.discountCode, ({ valueOf }) =>
valueOf(order.orderType) === 'wholesale'
);
// Pole ukryte warunkowo
hidden(order.companyName, ({ valueOf }) =>
valueOf(order.customerType) !== 'business'
);
// Pole tylko do odczytu
readonly(order.totalPrice);
});
Kluczowa różnica względem Reactive Forms: te funkcje są reaktywne. Zmiana orderType automatycznie włączy/wyłączy pole discountCode – bez ręcznego subskrybowania i wywoływania enable()/disable().
Co oznacza disabled/hidden/readonly?
Pola w tych stanach są pomijane przy określaniu stanu rodzica:
- Ukryte pole z błędem nie sprawia, że formularz jest invalid
- Wyłączone pole oznaczone jako dirty nie wpływa na dirty rodzica
- Pole readonly nie uczestniczy w walidacji
Reużywalność – Schema
Zupełną nowością są schematy. Schema pozwala zdefiniować zestaw reguł raz i stosować go w wielu miejscach:
import { schema, required, email, minLength } from '@angular/forms/signals';
// Definiujemy raz
const addressSchema = schema<Address>((addr) => {
required(addr.street);
required(addr.city);
required(addr.zipCode);
pattern(addr.zipCode, /^\d{2}-\d{3}$/);
});
const contactSchema = schema<Contact>((contact) => {
required(contact.email);
email(contact.email);
minLength(contact.phone, 9);
});
Stosowanie schematów
import { form, apply, applyEach } from '@angular/forms/signals';
// Aplikujemy do zagnieżdżonego obiektu
const customerForm = form(this.customerModel, (customer) => {
required(customer.name);
apply(customer.billingAddress, addressSchema);
apply(customer.shippingAddress, addressSchema);
apply(customer.contact, contactSchema);
});
// Aplikujemy do każdego elementu tablicy
const orderForm = form(this.orderModel, (order) => {
applyEach(order.addresses, addressSchema);
});
Warunkowe schematy
Możemy aplikować schematy warunkowo:
import { applyWhen, applyWhenValue } from '@angular/forms/signals';
const form = form(this.model, (f) => {
// Schema aplikowana gdy warunek spełniony
applyWhen(f.payment,
({ valueOf }) => valueOf(f.paymentMethod) === 'card',
cardPaymentSchema
);
// Schema aplikowana na podstawie wartości pola (z type narrowing!)
applyWhenValue(f.document,
(doc): doc is Invoice => doc.type === 'invoice',
invoiceSchema
);
});
Schematy to potężne narzędzie do porządkowania architektury aplikacji. Definiujesz reguły dla Address raz – masz pewność, że każdy formularz z adresem waliduje go identycznie.
Dyrektywa Field – jeden sposób na wszystko
W Reactive Forms musieliśmy pamiętać o różnych dyrektywach:
<!-- Reactive Forms - różne dyrektywy -->
<input [formControl]="emailControl">
<input formControlName="email">
<div formGroupName="address">...</div>
<div formArrayName="items">...</div>
Signal forms upraszczają to do jednej dyrektywy [field]:
<!-- Signal Forms - zawsze [field] -->
<input [field]="myForm.email">
<input [field]="myForm.address.street">
<input [field]="myForm.items[0].name">
Typowanie w szablonie
Dyrektywa Field jest ściśle otypowana. Gdy spróbujesz zbindować pole typu number do inputa oczekującego string:
<!-- myForm.age to FieldTree<number> -->
<input type="text" [field]="myForm.age">
<!-- ❌ Type 'FieldTree<number>' is not assignable to type 'FieldTree<string>' -->
To rzecz niespotykana wcześniej w Angularowych formularzach – błędy typów wykrywane w szablonie!
Automatyczne bindowanie stanu
Dyrektywa Field automatycznie synchronizuje stan między polem a kontrolką UI:
// Kontrolka może zadeklarować te inputy - Field je automatycznie wypełni
@Component({...})
export class MyInput {
value = model<string>(''); // wartość - wymagane
disabled = input<boolean>(false); // czy wyłączone
touched = input<boolean>(false); // czy dotknięte
errors = input<ValidationError[]>([]); // błędy walidacji
required = input<boolean>(false); // czy wymagane
// ... i więcej
}
<my-input [field]="myForm.email"></my-input>
<!-- Wszystkie stany zsynchronizowane automatycznie -->
Kontrakt FormValueControl
Aby stworzyć własną kontrolkę kompatybilną z [field], wystarczy zaimplementować prosty kontrakt:
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'my-custom-input',
template: `...`
})
export class MyCustomInput implements FormValueControl<string> {
// Jedyne wymagane pole
readonly value = model<string>('');
// Opcjonalnie - Field automatycznie zbinduje jeśli istnieją
readonly disabled = input<boolean>(false);
readonly errors = input<ValidationError[]>([]);
readonly touched = input<boolean>(false);
}
Migracja – compatForm
Jeśli masz istniejącą aplikację z Reactive Forms, prawdopodobnie nie przepiszesz wszystkiego na raz (i słusznie). Na szczęście Angular przewidział taki scenariusz i dostarcza compatForm() – funkcję pozwalającą mieszać oba światy.
import { compatForm } from '@angular/forms/signals';
import { FormControl, Validators } from '@angular/forms';
// Istniejący FormControl z walidatorami
const ageControl = new FormControl(5, Validators.min(3));
// Model mieszający signal forms z Reactive Forms
const model = signal({
name: 'Jan', // zwykłe pole signal forms
age: ageControl // istniejący FormControl
});
const myForm = compatForm(model);
Jak to działa?
compatForm automatycznie “rozpakowuje” wartości z FormControl:
myForm.age().value(); // 5 (number, nie FormControl!)
myForm.name().value(); // 'Jan'
// Jeśli potrzebujesz dostępu do oryginalnego FormControl:
myForm.age().control(); // FormControl<number>
Dwukierunkowa synchronizacja
Stan jest synchronizowany w obie strony:
// Zmiana przez FormControl
ageControl.setValue(10);
myForm.age().value(); // 10
// Zmiana przez signal forms
myForm.age().value.set(15);
ageControl.value; // 15
// Touched/dirty też się propaguje
ageControl.markAsTouched();
myForm.age().touched(); // true
myForm().touched(); // true (propagacja do rodzica)
Walidatory są respektowane
Walidatory zdefiniowane na FormControl działają normalnie:
const control = new FormControl(1, Validators.min(5));
const model = signal({ age: control });
const myForm = compatForm(model);
myForm.age().valid(); // false
myForm().valid(); // false (propagacja)
control.setValue(10);
myForm.age().valid(); // true
Ograniczenie: brak reguł na polach FormControl
Nie możesz aplikować reguł signal forms (jak required(), validate()) bezpośrednio do pól będących FormControl – TypeScript to zablokuje:
compatForm(model, (f) => {
required(f.name); // ✅ OK - zwykłe pole
required(f.age); // ❌ Błąd kompilacji - age to FormControl
// Ale możesz odczytywać wartości FormControl w walidatorach innych pól:
validate(f.name, ({ valueOf }) => {
return valueOf(f.age) < 18
? customError({ kind: 'too-young' })
: undefined;
});
});
To ma sens – walidacja FormControl powinna zostać przy tym FormControl. Mieszanie dwóch systemów walidacji na jednym polu to proszenie się o kłopoty.
Submit i Reset
Wysyłanie formularza
Signal forms dostarczają funkcję submit() która obsługuje typowy flow wysyłania:
import { submit } from '@angular/forms/signals';
async function onSubmit() {
await submit(myForm, async (form) => {
// 1. W tym momencie wszystkie pola są już oznaczone jako touched
// 2. Jeśli formularz jest invalid - ta funkcja NIE zostanie wywołana
// 3. form().submitting() === true podczas wykonywania
const response = await api.save(form().value());
// Możemy zwrócić błędy serwera
if (response.error) {
return [{
field: myForm.email,
error: customError({ kind: 'server', message: response.error })
}];
}
return undefined; // sukces
});
}
Co robi submit() pod maską:
- Oznacza wszystkie pola jako touched (żeby pokazać błędy)
- Sprawdza valid() – jeśli false, przerywa i nie wywołuje akcji
- Ustawia submitting na true
- Wywołuje akcję
- Aplikuje ewentualne błędy serwera do odpowiednich pól
- Ustawia submitting na false
Stan submitting
Możesz użyć submitting() do blokowania UI:
<button [disabled]="myForm().submitting()">
{{ myForm().submitting() ? 'Wysyłanie...' : 'Wyślij' }}
</button>
submitting propaguje się w dół – jeśli rodzic jest w trakcie wysyłania, dzieci też:
myForm().submitting(); // true
myForm.email().submitting(); // true
Reset formularza
Metoda reset() czyści stan interakcji (touched, dirty):
myForm.email().reset(); // resetuje pojedyncze pole
myForm().reset(); // resetuje cały formularz i wszystkie dzieci
Opcjonalnie możesz przekazać nową wartość:
myForm().reset({ email: '', password: '' });
Uwaga: reset() nie zmienia wartości jeśli jej nie przekażesz – resetuje tylko stan UI.
Debouncing
Dla pól gdzie nie chcemy reagować na każde naciśnięcie klawisza (np. wyszukiwarka, walidacja asynchroniczna), mamy debounce():
import { form, debounce } from '@angular/forms/signals';
const searchForm = form(this.model, (f) => {
// Aktualizuj model dopiero 300ms po ostatniej zmianie
debounce(f.query, 300);
});
Możesz też przekazać własną funkcję debounce:
debounce(f.query, (ctx, abortSignal) => {
return new Promise(resolve => {
const timeout = setTimeout(resolve, 500);
abortSignal.addEventListener('abort', () => clearTimeout(timeout));
});
});
Debouncing dziedziczny – jeśli ustawisz go na rodzicu, dzieci też będą debounced (chyba że nadpiszą własnym).
Własne kontrolki – koniec z ControlValueAccessor
W Reactive Forms tworzenie własnej kontrolki formularza wymagało implementacji ControlValueAccessor – interfejsu z czterema metodami, magicznym providerem z forwardRef, i ręcznym wywoływaniem onChange/onTouched. Każdy programista Angular zna ten boilerplate:
// Reactive Forms - ControlValueAccessor ?
@Component({
selector: 'my-input',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MyInputComponent),
multi: true
}
]
})
export class MyInputComponent implements ControlValueAccessor {
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string): void { /* ... */ }
registerOnChange(fn: (value: string) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(isDisabled: boolean): void { /* ... */ }
}
Signal Forms redukują to do jednej linii.
FormValueControl – minimalistyczny kontrakt
Aby stworzyć kontrolkę kompatybilną z dyrektywą [field], wystarczy zaimplementować interfejs FormValueControl<T>:
import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'my-input',
template: `
<input
[value]="value()"
(input)="value.set($event.target.value)"
/>
`
})
export class MyInputComponent implements FormValueControl<string> {
readonly value = model('');
}
To wszystko. Jeden model() signal i kontrolka jest gotowa do użycia:
<my-input [field]="myForm.email"></my-input>
Dyrektywa [field] automatycznie synchronizuje wartość między formularzem a kontrolką. Zmiana w formularzu → aktualizacja value(). Zmiana w kontrolce → aktualizacja modelu formularza.
Opcjonalne inputy – automatyczne bindowanie stanu
FormValueControl definiuje szereg opcjonalnych inputów. Jeśli je zadeklarujesz, dyrektywa [field] automatycznie je wypełni:
@Component({
selector: 'my-input',
template: `
<div class="input-wrapper" [class.has-error]="invalid()">
<input
[value]="value()"
[disabled]="disabled()"
[attr.name]="name()"
(input)="value.set($event.target.value)"
(blur)="touched.set(true)"
/>
@if (invalid() && touched()) {
<div class="errors">
@for (error of errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
`
})
export class MyInputComponent implements FormValueControl<string> {
// Wymagane
readonly value = model('');
// Opcjonalne - Field automatycznie zbinduje jeśli istnieją
readonly disabled = input(false);
readonly touched = model(false); // model() pozwala na dwukierunkowy binding
readonly errors = input<ValidationError[]>([]);
readonly invalid = input(false);
readonly name = input('');
readonly required = input(false);
readonly readonly = input(false);
}
Pełna lista opcjonalnych inputów:
- disabled – czy pole jest wyłączone
- readonly – czy pole jest tylko do odczytu
- touched – czy użytkownik wchodził w interakcję z polem (może być model() dla dwukierunkowego bindingu)
- dirty – czy wartość została zmieniona
- invalid – czy walidacja nie przeszła
- pending – czy trwa walidacja asynchroniczna
- errors – lista błędów walidacji
- name – nazwa pola w formularzu
- required – czy pole jest wymagane
- min, max, minLength, maxLength, pattern – wartości z walidatorów
Deklarujesz tylko te, których potrzebujesz. Reszta jest ignorowana.
FormCheckboxControl – dla checkboxów
Dla kontrolek typu checkbox istnieje osobny kontrakt FormCheckboxControl:
import { Component, model } from '@angular/core';
import { FormCheckboxControl } from '@angular/forms/signals';
@Component({
selector: 'my-checkbox',
template: `
<label>
<input
type="checkbox"
[checked]="checked()"
(change)="checked.set($event.target.checked)"
/>
<ng-content></ng-content>
</label>
`
})
export class MyCheckboxComponent implements FormCheckboxControl {
readonly checked = model(false);
}
Użycie:
<my-checkbox [field]="myForm.agreeToTerms">
Akceptuję regulamin
</my-checkbox>
Kontrolki jako dyrektywy
Kontrolka nie musi być komponentem – może być dyrektywą na natywnym elemencie:
@Directive({
selector: 'input[myCustomInput]',
host: {
'[value]': 'value()',
'(input)': 'value.set($event.target.value)',
'(blur)': 'onBlur()'
}
})
export class MyCustomInputDirective implements FormValueControl<string> {
readonly value = model('');
readonly touched = model(false);
onBlur() {
this.touched.set(true);
}
}
<input myCustomInput [field]="myForm.email" />
Dyrektywa [field] automatycznie wykryje FormValueControl i połączy go z formularzem.
Signal Forms eliminują ceremonię. Zamiast implementować interfejs z czterema metodami i konfigurować providery, deklarujesz jeden signal i kontrolka działa.
Zanim zaczniesz – kilka uwag
Status: Experimental
Signal forms są oznaczone jako @experimental 21.0.0. Co to oznacza w praktyce?
- API może się zmienić w kolejnych wersjach (choć rdzeń raczej pozostanie stabilny)
- Mogą pojawić się edge case’y i bugi
- Dokumentacja jest jeszcze w trakcie rozwoju
Czy to oznacza, że nie warto ich używać? Moim zdaniem – warto, szczególnie w nowych projektach. Ale w krytycznych aplikacjach produkcyjnych rozważ czy jesteś gotowy na ewentualne migracje API.
Import
Signal forms żyją w osobnym entry point:
import { form, required, validate, ... } from '@angular/forms/signals';
Nie mieszaj z importami z @angular/forms (chyba że używasz compatForm).
Podsumowanie
Signal forms to nie ewolucja Reactive Forms – to przemyślana od nowa implementacja formularzy w Angular. Kluczowe zmiany:
- Model jako źródło prawdy – formularz i dane są zawsze zsynchronizowane
- Prawdziwe typowanie – TypeScript wie wszystko, bez kompromisów
- Reaktywność z automatu – walidatory reagują na zmiany zależności bez ręcznego wiązania
- Jedno API – dyrektywa [field] zamiast zoo dyrektyw
- Schematy – reużywalne reguły walidacji
- Proste Kroki – FormValueControl zamiast ControlValueAccessor
Czy warto migrować istniejące aplikacje? Jeśli masz czas i budżet – tak. Jeśli nie – compatForm pozwala wprowadzać signal forms stopniowo, formularz po formularzu.
A nowe projekty? Tu nie ma dylematu. Signal forms to przyszłość formularzy w Angular.
