Wróć do strony głównej
Angular

Typed Forms

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:

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:

Typ generyczny FormGroup’a będzie równoznaczny z typem jego propsa controls. Z takiej inicjalizacji wywnioskowany zostanie typ:

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:

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:

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:

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:

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:

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:

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!

O autorze

Dominik Kalinowski

Angular Developer, fan strict typingu, unit testów, UX-a i skrupulatnego code review. Stara się pisać kod tak, żeby nie było lipy.

Chcesz razem z nami przygotowywać treści na bloga?
Dołącz do nas i twórz wartościowy content dla sympatyków Angulara z Angular.love!

9 komentarzy

  1. Przemek

    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 🙂

    • Dominik Kalinowski

      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.

      • Przemek

        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 😉

        • Dominik Kalinowski

          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.

  2. Bartek

    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

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *