Formularz zamówienia z dowolną liczbą produktów, lista adresów dostawy albo zestaw aliasów użytkownika szybko pokazują ograniczenia statycznego formularza. W Angularze określenie angular formarray zwykle oznacza wykorzystanie klasy FormArray do dynamicznego dodawania, usuwania i walidowania pól. Pokażę, jak zbudować takie rozwiązanie, kiedy wybrać tablicę kontrolek zamiast FormGroup oraz jak uniknąć błędów, które często pojawiają się przy edycji danych.
FormArray porządkuje formularze, których liczba pól zmienia się w czasie
- FormArray przechowuje dynamiczną listę kontrolek lub grup formularza.
- Elementy dodasz metodami push(), insert() i usuniesz przez removeAt().
- Dla list obiektów, takich jak produkty, użyj FormArray
. - Walidację możesz umieścić na pojedynczym polu albo na całej tablicy.
- W szablonie potrzebujesz dyrektywy formArrayName oraz indeksu elementu.
FormArray pasuje tam, gdzie lista nie ma stałego rozmiaru
FormArray jest kontenerem na kontrolki formularza, których liczba może zmieniać się podczas działania aplikacji. Każdy element ma pozycję w tablicy, ale nie musi mieć własnej nazwanej właściwości. To odróżnia go od FormGroup, w którym kontrolki są dostępne pod konkretnymi kluczami, na przykład firstName albo email.
W praktyce używam go wtedy, gdy użytkownik sam decyduje, ile elementów chce dodać. Dobrymi przykładami są numery telefonów, adresy, produkty w zamówieniu, członkowie zespołu i załączniki. Jeżeli formularz zawsze ma dokładnie trzy pola, zwykły FormGroup będzie prostszy i czytelniejszy.
| Typ | Najlepsze zastosowanie | Przykład |
|---|---|---|
FormControl |
Pojedyncza wartość | Adres e-mail |
FormGroup |
Stały zestaw nazwanych pól | Profil użytkownika |
FormArray |
Dynamiczna lista elementów | Lista produktów |
FormRecord |
Dynamiczny zestaw pól znanych pod kluczami | Słownik ustawień |
Ta różnica ma znaczenie także przy odczycie danych. FormGroup zwraca obiekt z nazwanymi właściwościami, natomiast FormArray zwraca tablicę wartości w tej samej kolejności, w jakiej znajdują się kontrolki.
Najprostsza lista dynamicznych pól
Zacznijmy od listy aliasów. Każdy alias będzie zwykłym FormControl, a użytkownik dostanie przyciski do dodawania i usuwania kolejnych pozycji.
import { Component, inject } from '@angular/core';
import {
FormArray,
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators
} from '@angular/forms';
@Component({
selector: 'app-profile-editor',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './profile-editor.component.html'
})
export class ProfileEditorComponent {
private readonly fb = inject(FormBuilder);
readonly profileForm = this.fb.group({
aliases: this.fb.array([
this.fb.control('', {
nonNullable: true,
validators: [Validators.required]
})
])
});
get aliases(): FormArray> {
return this.profileForm.controls.aliases;
}
addAlias(): void {
this.aliases.push(
this.fb.control('', {
nonNullable: true,
validators: [Validators.required]
})
);
}
removeAlias(index: number): void {
this.aliases.removeAt(index);
}
save(): void {
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
console.log(this.profileForm.getRawValue());
}
} Getter nie jest obowiązkowy, ale bardzo poprawia czytelność. Zamiast wielokrotnie pisać profileForm.get('aliases'), operuję na właściwości, która ma konkretny typ FormArray. Przy włączonym typowaniu formularzy taka deklaracja pomaga też szybciej wychwycić pomyłki w kodzie.
Szablon musi odwzorowywać strukturę modelu formularza. Najpierw wskazuję grupę nadrzędną przez formGroup, potem tablicę przez formArrayName, a na końcu podaję indeks kontrolki.
W nowszych wersjach Angulara blok @for pozwala jawnie określić sposób śledzenia elementów. Użycie obiektu kontrolki zamiast samego indeksu pomaga zachować stan konkretnego pola podczas zmian w tablicy.
Tablica grup sprawdza się przy produktach i adresach
W aplikacjach biznesowych rzadko potrzebujemy listy samych tekstów. Produkt ma nazwę, ilość i cenę, a adres może zawierać ulicę, kod pocztowy i miasto. W takim przypadku elementem FormArray powinna być osobna grupa formularza.
import { FormGroup, Validators } from '@angular/forms';
readonly orderForm = this.fb.group({
customerName: this.fb.control('', {
nonNullable: true,
validators: [Validators.required]
}),
items: this.fb.array([this.createItem()])
});
get items(): FormArray {
return this.orderForm.controls.items;
}
private createItem(): FormGroup {
return this.fb.group({
name: this.fb.control('', {
nonNullable: true,
validators: [Validators.required]
}),
quantity: this.fb.control(1, {
nonNullable: true,
validators: [Validators.min(1)]
}),
price: this.fb.control(0, {
nonNullable: true,
validators: [Validators.min(0)]
})
});
}
addItem(): void {
this.items.push(this.createItem());
}
removeItem(index: number): void {
this.items.removeAt(index);
}Każde wywołanie createItem() tworzy nową grupę z niezależnym stanem walidacji. To ważne, bo ponowne użycie tej samej instancji grupy w kilku miejscach prowadziłoby do współdzielenia wartości i nieprzewidywalnych zmian.
Najczęstszy błąd w tym miejscu polega na pominięciu formArrayName albo formGroupName. Angular nie znajdzie wtedy właściwej kontrolki w drzewie formularza i pojawi się błąd o braku kontrolki pod wskazaną ścieżką.
Indeks tablicy nie powinien pełnić roli identyfikatora biznesowego. Jeżeli element ma własne id z bazy danych, trzymaj je w grupie albo w osobnym modelu. Po usunięciu pierwszego elementu wszystkie kolejne indeksy się zmienią, ale identyfikator produktu powinien pozostać stabilny.
Walidacja pojedynczych elementów i całej listy
Walidatory przypisane do kontrolek sprawdzają pojedyncze wartości. Validators.required pilnuje, aby pole nie było puste, a Validators.min(1) wymusza minimalną liczbę sztuk. Stan nadrzędnego FormArray staje się niepoprawny, gdy niepoprawny jest choć jeden jego element.
Czasem trzeba jednak zweryfikować samą listę. Przykład to wymóg dodania co najmniej jednego produktu albo zakaz powtarzania tego samego adresu e-mail. Wtedy walidator umieszczam na poziomie tablicy.
import {
AbstractControl,
FormArray,
ValidationErrors,
ValidatorFn
} from '@angular/forms';
function minItems(minimum: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const array = control as FormArray;
return array.length >= minimum
? null
: {
minItems: {
required: minimum,
actual: array.length
}
};
};
}readonly items = this.fb.array(
[this.createItem()],
{
validators: [minItems(1)]
}
);W szablonie możesz pokazać komunikat na podstawie items.hasError('minItems'). Dobrą praktyką jest wyświetlanie go dopiero po interakcji użytkownika albo po próbie wysłania formularza. Sam przycisk zapisu powinien sprawdzać status całego formularza, a nie tylko pierwszego elementu tablicy.
Jeśli walidacja zależy od serwera, na przykład od unikalności kodu produktu, użyj walidatora asynchronicznego na właściwej kontrolce. Nie wysyłaj zapytania po każdej zmianie bez ograniczenia częstotliwości, bo większa tablica szybko wygeneruje niepotrzebny ruch sieciowy.
Dodawanie, usuwanie i ładowanie danych bez niespodzianek
Najważniejsze metody FormArray są proste, ale każda ma inne zastosowanie. W mojej praktyce największą różnicę robi świadome rozdzielenie operacji na strukturze formularza od operacji na jego wartościach.
| Metoda | Zastosowanie |
|---|---|
push(control) |
Dodaje element na końcu tablicy. |
insert(index, control) |
Wstawia element w określonym miejscu. |
removeAt(index) |
Usuwa element pod wskazanym indeksem. |
clear() |
Usuwa wszystkie elementy. |
setControl(index, control) |
Zastępuje istniejącą kontrolkę inną kontrolką. |
at(index) |
Zwraca kontrolkę znajdującą się pod indeksem. |
Przy ładowaniu danych z API najpierw tworzę odpowiednią strukturę kontrolek, a dopiero potem ustawiam wartości. Jeżeli liczba elementów z serwera jest zmienna, zwykle czyszczę tablicę i buduję ją ponownie.
loadOrder(order: OrderDto): void {
this.items.clear();
for (const item of order.items) {
const group = this.createItem();
group.patchValue({
name: item.name,
quantity: item.quantity,
price: item.price
});
this.items.push(group);
}
}setValue() wymaga kompletnej struktury i dobrze sygnalizuje brakujące dane. patchValue() jest bardziej tolerancyjne, dlatego wybieram je przy częściowych aktualizacjach. Przy danych z API nie warto jednak bezrefleksyjnie ufać kształtowi obiektu, szczególnie gdy backend może zwrócić brakujące albo dodatkowe pola.
Istotna pułapka dotyczy stanu dirty. Samo dodanie lub usunięcie elementu przez push(), insert() albo removeAt() nie oznacza automatycznie, że tablica stała się zmodyfikowana przez użytkownika. Jeśli aplikacja pokazuje ostrzeżenie przed opuszczeniem strony, po zmianie struktury wywołaj markAsDirty().
addItem(): void {
this.items.push(this.createItem());
this.items.markAsDirty();
}
removeItem(index: number): void {
this.items.removeAt(index);
this.items.markAsDirty();
}Przy odczycie danych pamiętaj o różnicy między value i getRawValue(). Wyłączone kontrolki są pomijane w value, natomiast getRawValue() uwzględnia także wartości pól disabled. To często decyduje o tym, czy formularz poprawnie zachowa dane w trybie edycji.
Typowe błędy, które psują dynamiczne formularze
Tworzenie kontrolek dopiero w szablonie
Reactive forms powinny mieć model utworzony w komponencie. Dodawanie kontrolek wyłącznie przez manipulowanie HTML-em utrudnia walidację, testowanie i obsługę danych z backendu. Najpierw buduję drzewo formularza, a szablon traktuję jako jego prezentację.
Użycie indeksu jako stałego identyfikatora
Indeks jest dobry do wskazania pozycji w tablicy, ale nie nadaje się do identyfikowania rekordu. Po usunięciu elementu pozycje się przesuwają, dlatego dane biznesowe powinny mieć własne stabilne id.
Brak ochrony przed pustą listą
Jeśli użytkownik może usunąć wszystkie elementy, backend powinien otrzymać świadomie obsłużoną pustą tablicę albo formularz powinien wymagać minimum jednego elementu. Walidator tablicy jest tutaj lepszym rozwiązaniem niż ręczne sprawdzanie długości dopiero w metodzie zapisu.
Współdzielenie jednej grupy przez wiele wierszy
Każdy wiersz powinien otrzymać nowy wynik funkcji fabrykującej, takiej jak createItem(). W przeciwnym razie zmiana nazwy w jednym wierszu może zmienić także pozostałe, bo wszystkie wskazują na tę samą instancję kontrolki.
Przeczytaj również: Pętla for w JavaScript - składnia, przykłady i typowe błędy
Brak ograniczeń przy dużych listach
FormArray nie rozwiązuje problemu wydajności za aplikację. Przy kilkuset elementach rozważ paginację, wirtualizację albo edycję jednego rekordu naraz. Dla typowych formularzy zawierających od kilku do kilkudziesięciu wierszy standardowa implementacja będzie wystarczająca, ale przy większej skali trzeba kontrolować liczbę obserwatorów i operacji walidacyjnych.
Jak podejść do FormArray w projekcie produkcyjnym
Najpierw rozstrzygam, czy lista rzeczywiście musi być edytowana jako część jednego formularza. Jeśli użytkownik dodaje kilka prostych wartości, FormArray będzie idealny. Jeśli każdy element ma kilka pól i własne reguły, wybieram tablicę grup formularza.
Drugą decyzją jest model danych. Warto od początku ustalić, czy pusta tablica jest poprawna, czy element może być tymczasowy oraz które pola pochodzą z serwera. Dzięki temu łatwiej dobrać walidatory, obsłużyć tryb tworzenia i edycji oraz przygotować poprawny payload.
Na końcu sprawdzam zachowanie w czterech sytuacjach: dodanie pierwszego elementu, usunięcie środkowego wiersza, załadowanie danych z API i próba wysłania niepoprawnej listy. Te scenariusze wykrywają większość problemów szybciej niż ręczne klikanie po gotowym formularzu.
Dynamiczna lista jest prosta, gdy model prowadzi interfejs
FormArray nie jest zamiennikiem każdego formularza. Jego siła pojawia się wtedy, gdy liczba elementów zależy od użytkownika, a każdy element musi uczestniczyć w walidacji i obsłudze stanu formularza. Trzymaj strukturę w komponencie, twórz osobne kontrolki dla każdego wiersza, waliduj także poziom całej tablicy i nie traktuj indeksu jak identyfikatora danych.
Takie podejście daje formularz, który łatwo rozbudować o edycję, zapis do API, komunikaty błędów i testy. Najważniejsze jest to, że interfejs nie zarządza formularzem na własną rękę. Model reaktywny pozostaje jednym źródłem prawdy, a dynamiczne wiersze stają się przewidywalną częścią aplikacji.
