W 14 wersji Angulara pojawiły się nowości w Reactive Forms. Opowiadałem o tym temacie podczas #4 Angular Meetupu. Nagranie prelekcji możesz obejrzeć tutaj.
Powracam do tematu z nowo zdobytym doświadczeniem, aby przedstawić jego najważniejsze założenia, oraz opisać dobre praktyki.
Co wnosi nowe api?
Krótko mówiąc, typuje wartości przetrzymywane w FormControl’kach oraz kontrolki przetrzymywane w kontenerach kontrolek (FormGroup, FormArray, FormRecord).
W poniższych przykładach celowo będę tworzył kontrolki używając konstruktorów zamiast skorzystać z FormBuilder’a, (jak nakazywały by dobre praktyki) aby krok po kroku prześledzić zmiany w tych klasach.
Przykład inicjalizacji kontrolki:
1 |
const nameControl = new FormControl('John'); |
Wygląda to tak jak dawniej, prawda?
Jednak kiedyś wartość nameControl.value byłaby zatypowana jako any. Obecnie otrzymujemy wartość typu string | null.
Wykorzystywanie zatypowanych wartości uodparnia nas na błędy wynikające z przetwarzania wartości w sposób niezgodny z jej typem. Ułatwia również naszą pracę – możemy liczyć na pomoc IDE, które podpowiada nam właściwości typu, na którym operujemy.
Aby zapewnić, że wartość będzie danego typu, zatypowane zostało całe api klasy FormControl. Oznacza to, że metody: setValue, patchValue oraz reset przyjmą jako argument wartości, które są zgodne z typem wynikającym z inicjalizacji (czyli typem generycznym kontrolki).
Przykład inicjalizacji FormGroup’a:
1 2 3 4 |
const addressForm = new FormGroup({ line1: new FormControl(''), country: new FormControl('PL'), }); |
Typ generyczny FormGroup’a będzie równoznaczny z typem jego propsa controls. Z takiej inicjalizacji wywnioskowany zostanie typ:
1 2 3 4 |
{ line1: FormControl<string | null>, country: FormControl<string | null>, } |
Dzięki temu przy odwoływaniu się do zawartych kontrolek skończy się konieczność rzutowania do odpowiednich typów (wcześniej każda z nich była zatypowana jako AbstractControl, bez specyfikacji typu wartości).
Aby to umożliwić wprowadzono ograniczenia w usuwaniu kontrolek. Żeby kontrolka mogła być usunięta z FormGroup’y, trzeba ją wprost zadeklarować jako opcjonalną. Ta opcjonalność jest indukowana na typ wartości wyciąganej z FormGroup’a.
To samo tyczy się dodawania kontrolek. Jeśli chcemy dodać kontrolkę, która nie była obecna w inicjalnej wartości, musimy dodać ją do typu generycznego FormGroup’a jako opcjonalną .
Podawanie typu generycznego wprost
Podawanie typu generycznego dla złożonego formularza jest żmudnym zadaniem, które bardzo komplikuje jego deklarację. Powinniśmy go zatem unikać, jak najszerzej polegając na typescript’owym wnioskowaniu typu. Opcjonalność kontrolek jest jednym z niewielu przypadków gdzie jego podanie jest konieczne.
Wydaje mi się, że jest to przypadek dość rzadki i ze względu na jego złożoność nie będę się tu o nim rozpisywał. W przyszłości pojawi się osobny artykuł w formie “case studies” gdzie zagłębię się w ten temat.
Innym przypadkiem, w którym typ nie zostanie wywnioskowany automatycznie, jest przekazywanie formularza przez input do komponentu-dziecka. Możemy go jednak uniknąć zmieniając architekturę współdzielenia formularza między komponentami.
Np. możemy wynieść logikę jego tworzenia do osobnego serwisu, tak zwanego prezentera, który zaprovidujemy na poziomie komponentu-rodzica i wstrzykniemy w obu tych komponentach pozwalając na wygodne współdzielenie formularza i opieranie się na typie wynikającym z inicjalizacji.
Co gdy z góry nie znamy kontrolek jakich będziemy potrzebować?
Ktoś może zaraz powiedzieć – “Dominik, ale ja nie wiem z góry jakie kontrolki i pod jakimi kluczami będę przetrzymywał w moim formularzu, one są dynamicznie dodawane na podstawie akcji użytkownika lub danych otrzymywanych z serwera!”.
Aby taki problem rozwiązać możemy skorzystać z FormArray’a, czy z nowej klasy FormRecord, która podobnie jak FormGroup przechowuje kontrolki pod kluczami, nie wymagając zadeklarowania ich przy inicjalizacji.
Zacznijmy jednak od FormArray’a – w którym możemy dodawać i usuwać kontrolki do woli, z zastrzeżeniem, że muszą to być kontrolki zgodne z typem generycznym FormArray’a.
Przykład inicjalizacji FormArray’a:
1 2 3 4 |
const readBooks = new FormArray([ new FormControl('The Black Obelisk'), new FormControl('Arch of Triumph'), ]); |
Typ kontrolek, które FormArray może przechowywać, jest wnioskowany na podstawie kontrolek wpisanych przy inicjalizacji oraz/lub na podstawie typu generycznego podanego wprost. W przypadku pustego FormArray’a zachodzi konieczność zadeklarowania go wprost. Jeśli inicjalizujemy go z pustą tablicą kontrolek bez podania typu generycznego, FormArray będzie bezużyteczny – nie będziemy mogli dodać do niego kontrolki o żadnym typie. Poprawna inicjalizacja wygląda następująco:
1 |
const readBooks = new FormArray<FormControl<string | null>>([]); |
Niektórzy z was mogą mi powiedzieć teraz: “Dominik, ale ja nie chcę trzymać tych dynamicznie dodawanych kontrolek jako listę! Chcę mieć możliwość odwoływania się do nich po kluczach!” I oczywiście jest to przydatne rozwiązanie – przejdźmy zatem do nowej klasy FormRecord.
Przykład inicjalizacji FormRecord’u:
1 2 3 4 5 6 |
const readBooksByAuthor = new FormRecord({ 'Erich Maria Remarque': new FormArray([ new FormControl('The Black Obelisk'), new FormControl('Arch of Triumph'), ]), }); |
W tym przykładzie mamy podwójnie dynamiczną strukturę – mapę autorów z listą książek ich autorstwa. Typem generycznym będzie typ kontrolki jaką FormRecord może przetrzymywać – w tym przypadku będzie to FormArray<FormControl<string | null>>. W przypadku inicjalizacji pustego FormRecord’u powinniśmy przekazać go wprost.
Skąd bierze się null w typie?
Jak pewnie zauważyliście w typie generycznym inicjowanych FormControl’ek pojawia się null. Bierze on się stąd, że przy użyciu metody reset wartość kontrolek resetuje się domyślnie do null – w typie musimy uwzględnić, że może mieć to miejsce.
Takie resetowanie jest potencjalnie niebezpieczne. Możemy przypadkiem wstawić null w miejsce w którym wcale go nie oczekujemy, a przez to wprowadzić do naszego kodu błąd, który może być trudny do wykrycia. Przykładem tego może być addressForm zadeklarowany wcześniej z domyślną wartością kontrolki county = ‘PL’. Resetując ten formularz bez podania wartości, do country przypiszemy null, tracąc wartość domyślną ‘PL’.
Znacznie częściej oczekujemy, że reset formularza przywróci jego kształt sprzed interakcji użytkownika. Aby to umożliwić, do konfiguracji propsa dodano nową właściwość nonNullable.
Przykład inicjalizacji nonNullable FormControl’ki:
1 |
const nameControl = new FormControl('', { nonNullable: true }); |
W ten sposób resetowanie kontrolki przywróci jej wartość początkową. Korzyścią jest również zawężenie typu generycznego o null.
Czas na FormBuilder (a nawet na NonNullableFormBuilder)
Korzystanie z FormBuilder’a skraca ilość kodu potrzebną do zainicjalizowania formularza, jeśli tworzymy FormGroup czy FormArray to nie musimy przekazywać do niego wartości owrapowanych w FormControl’ki – zrobi on to za nas!
Przykład:
1 2 3 4 5 6 |
const addressForm = this._fb.group({ line1: '', city: 'PL', }); constructor(private readonly _fb: FormBuilder) |
Różnica jest szczególnie zauważalna, jeśli zgodnie z dobrą praktyką chcielibyśmy ustawić wszystkie kontrolki jako nonNullable. Służy do tego klasa NonNullableFormBuilder, którą najlepiej bezpośrednio wstrzyknąć przez DI zamiast zwykłego FormBuilder’a.
Jak zaimplementować formularz dostosowany do trybów tworzenia i edycji?
Weźmy za przykład komponent, który zawiera formularz. Formularz ten może być w stanie edycji encji, bądź jej tworzenia. Zależy to od tego, czy przekazaliśmy mu przez input dane, którymi inicjalnie wypełniamy go w trybie edycji.
Częstą praktyką jest inicjalizowanie formularza w hook’u OnInit. Wówczas, jeśli dane istnieją to tworzymy go z ich użyciem. W przeciwnym razie używamy wartości domyślnych.
To podejście ma jednak problem – rozdzielając deklarację od inicjalizacji, uniemożliwiamy typescript’owi wnioskowanie jaki będzie typ formularza.
Aby zaadresować ten problem, zainicjalizujmy formularz wartościami domyślnymi od razu przy deklaracji, a jeśli dane przyjdą, to wypełnijmy go nimi korzystając z metody setValue lub patchValue.
Pozwoli nam to mieć w pełni zatypowany formularz. Dodatkowo, trzymając się tej konwencji i korzystając z NonNullableFormBuildera, osiągniemy przewidywalność metody reset – zawsze będzie ona resetować formularz do ich wartości domyślnej, czyli takiej, w której jest inicjalnie w trybie tworzenia.
W powyższym przypadku wszystkie kontrolki przechowują wartości typu string. Wówczas wartością domyślnie pustej kontrolki może być po prostu pusty string.
A co z kontrolkami typu number czy Date? Wówczas możemy chcieć, żeby inicjalnie ich wartość była np. nullem, zamiast jakimś domyślnym numerem czy datą. Dobrym rozwiązaniem będzie zrzutowanie “inicjalnego nulla” do typu jaki dana kontrolka będzie zawierać.
Przykład:
1 2 3 4 5 6 |
addressForm = this._fb.group({ line1: '', country: 'PL', countryCode: null as number | null, creationDate: null as Date | null }); |
Migracja do Typed Forms
Update do v14 zmienia wszystkie użycia klas formularzowych na ich niezatypowane odpowiedniki (UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, etc.). Pozwoli nam to na stopniowe przechodzenie na zatypowane api, oraz łatwe stwierdzenie, które formularze zostały już zrefaktorowane.
Dobre praktyki
Zebrałem poniżej dobre praktyki i porady, dotyczące używania typed forms.
- Jeśli jeszcze tego nie zrobiłeś – zrób update Angulara i zacznij je wykorzystywać!
- Polegaj na typie wnioskowanym przez typescript, unikaj deklarowania go wprost (wyjątkami są inicjalnie puste FormArray i FormRecord).
- Wystrzegaj się podawania typu formularza wprost – deklaracja formularza powinna być zawsze połączona z jego inicjalizacją. Jeśli musisz wypełnić go danymi niedostępnymi w constructor time, zrób to używając metody setValue/patchValue gdy dane będą dostępne.
- Deklaruj kontrolki jako nonNullable.
- Do budowowania formularzy i kontrolek używaj FormBuilder’a a najlepiej NonNullableFormBuilder’a.
- Odwołując się do zagnieżdżonych kontrolek używajac propsa controls zamiast metody get. Korzystanie z get gubi typ kontrolki zachowując jedynie zgodność typu generycznego (zamiast FormControl<string> otrzymamy AbstractControl<string> | null).
- Pobierając wartość kontenera kontrolek pamiętajmy, że korzystanie z propsa value pominie kontrolki w stanie disabled. Jest to również uwzględnione w ich typie – każda z nich jest zatypowana unią swojego typu generycznego oraz undefined na wypadek gdyby kontrolka była w stanie disabled. Jeśli nie chcemy aby kontrolki w stanie disabled były pominięte, a undefined nie był uwzględniony w typie ich wartości, możemy skorzystać z metody getRawValue lub pobierać ją z kontrolki zamiast z kontenera.
Czy mamy pewność, że przechowywana wartość będzie zgodna z zadeklarowanym typem?
Niestety nie.
Zawsze istnieje zagrożenie, że wartość zmiennej będzie niezgodna z zadeklarowanym typem. Aby zmniejszyć to zagrożenie powinniśmy dbać o silne typowanie, np. korzystając ze strict mode.
Niestety, niezgodność wartości z zadeklarowanym typem może pojawić się również w miejscu powiązania kontrolki z elementem inputu. Na ten moment Angular nie dostarcza mechanizmu weryfikacji zgodności między typem wartości dostarczanych przez input, a typem jakiego oczekuje kontrolka.
Przykład:
Kompilator nie wykryje błędu, a po interakcji użytkownika do kontrolki zaczną być wpisywane liczby. Na ten moment jednak nie możemy temu zaradzić. Autor Typed Forms zaznacza, że jest to oczywisty minus, ale są perspektywy na rozwiązanie tego problemu w przyszłości (patrz RFC, sekcja Limitations – Control Bindings).
Na zakończenie
Serdecznie zachęcamy do wykorzystania typed forms w waszych projektach!
Podzielcie się w komentarzach waszymi wrażeniami z ich wykorzystania, problemami, które napotkaliście, oraz co sądzicie o tym rozwiązaniu!
Fajnie, że to opisałeś. Używam typed forms w zasadzie od początku kiedy tylko się pojawiły i sobie chwalę. Ja zawsze jednak inicjalizowałem forma w ngOnInit, teraz widzę, że wcale nie muszę, dzięki! 🙂 Trochę to było irytujące, że przez to, że inicjalizowałem forma w ngOnInit, domyślnie mógł być on undefined, a więc w html musiałem często robić ngIf by się upewnić, że form już powstał co było irytujące. Dzięki 🙂
Wielkie dzięki za docenienie! To mój debiut na blogu i mega mi miło, że komuś się ten post przydał ?
Co do inicjalizacji forma bezpośrednio przy deklaracji zamiast w ngOnInit, to główną zaletą jest właśnie automatyczne wywnioskowanie typu kontrolek. Jeśli inicjalizujesz w ngOnInit to tak czy siak nie potrzebujesz ngIf w templatce, bo hook OnInit jest wykonywany przed inicjalizacją widoku.
No nie właśnie 😉 Jeśli inicjalizujesz w ngOnInit, to deklarujesz przed constructorem, czyli np.:
form: FormGroup;
ts Ci się przyczepi, że form nie jest zainicjalizowany (TS2564: Property 'form’ has no initializer and is not definitely assigned in the constructor), więc dodajesz ’?’ aby przestal się czepiać (form?: FormGroup). A to powoduje, że template zakłada, że form może być undefined i trzeba wstawić ngIf. Chyba, że mówisz o jeszcze czymś innym 😉
Mówię o tym, że form zainicjowany w OnInit będzie już istniał przy przetwarzaniu templatki.
Wówczas więcej sensu ma zatypowanie go jako form!: FormGroup, bez dodawnia ngIf’a którego warunek zawsze będzie spełniony.
Ale żeby skorzystać z typowania wewnątrz FormGroup’y warto inicjalizować bezpośrednio przy deklaracji.
Super artykuł. Właśnie dosłownie w ostatnich dniach te same problemy napotkaliśmy szykując się do migracji. Mówię tu właśnie o tym jak inicjować formularz, żeby wnioskował typy, gdzie do tej pory zawsze robiliśmy to na etapie ngOnInit. Ale wnioski generalnie podobne. Najpierw inicjalizacja w konstruktorze a potem wypełnienie danymi. Ale to było bardzo pomocne, żeby utwierdzić się w przekonaniu.
Jedno co nie wiedziałem to z tym przekazywaniem formularza do dzieci, że traci się typ. To dość bolesny fakt
Hej, wielkie dzięki za komentarz! Fajnie, gdy dochodzi się do tego samego rozwiązania ?
Co do przekazywania formularza przez input do child komponentu, to możemy podać typ wprost i wtedy będziemy mieli go zatypowanego.
Ale wówczas kończymy ze złożoną deklaracją typu, którą trzeba aktualizować w momencie gdy np. dojdzie nowa kontrolka do formularza.
Pokminiłem jeszcze nad tym chwilę i można by też skorzystać z prototypu klasy w której go inicjujemy: https://stackblitz.com/edit/angular-standalone-4pqcgt?file=src/app/parent.presenter.ts
Tylko trzeba uważać na circulary, w tym przypadku użycie prezentera nam to załatwia.
Być może ma to ten plus w porównaniu z wstrzyknięciem prezentera do child komponentu, że przez input możemy przekazać tylko wycinek formularza zamiast całego i nadal go zatypować, choć cieżko mi znaleźć sensowny use case.
Hej, myślałem też o takim rozwiązaniu ale to niestety sprawdza się w przypadku gdy przekazujemy cały formularz a nie wycinek
https://stackblitz.com/edit/angular-hkj9pa?file=src%2Fapp%2Fapp.component.ts&file=src%2Fapp%2Fchild%2Fchild.component.ts
wystarczy trochę obkroić ten typ i można przekazać również wycinek, np:
@Input() customForm: ReturnType<typeof customForm>[’controls’][’name’];
i customForm ograniczałby się tylko do kontrolki name
Fajne, nie byłem świadom, że tak można 🙂