Різниця між формами, керованими шаблоном, та реактивними формами в Angular
Форми на основі шаблонів і реактивні форми (reactive forms) - два підходи, які Angular пропонує для роботи зі станом форм, валідацією та введенням даних. Відрізняються вони насамперед тим, де живе логіка: в HTML чи в TypeScript.
Теорія
TL;DR
- Template-driven: підключаєш
FormsModule, додаєшngModelдо інпутів, Angular сам будує модель форми з шаблону - Reactive: підключаєш
ReactiveFormsModule, описуєшFormGroupу TypeScript, в шаблоні прив'язуєш черезformControlName - Головна різниця: template-driven використовує двостороннє зв'язування через
ngModel, реактивні форми - однонаправлений потік через observablevalueChanges - До 5 статичних полів без динамічної логіки? Форми на основі шаблонів підійдуть. Динамічні поля, крос-польова валідація, помилки з сервера? Тільки реактивні.
- Реактивні форми тестуються без DOM: створюєш
FormGroup, викликаєшform.setValue(), перевіряєшform.valid
Швидкий приклад
Форма на основі шаблону:
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<input name="email" ngModel required email #email="ngModel">
<div *ngIf="email.invalid && email.touched">Невалідний email</div>
<button [disabled]="userForm.invalid">Надіслати</button>
</form>onSubmit(form: NgForm) {
console.log(form.value); // { email: "test@example.com" }
}Angular зчитує директиву ngModel і автоматично створює модель форми з шаблону. HTML тут у ролі джерела правди.
Реактивна форма:
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email])
});
onSubmit() {
console.log(this.form.value); // { email: "test@example.com" }
}<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email">
<div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
Невалідний email
</div>
<button [disabled]="form.invalid">Надіслати</button>
</form>TypeScript визначає модель першим. Шаблон лише прив'язується до неї.
Ключова різниця
Форми на основі шаблонів використовують директиви ngModel та NgForm, щоб автоматично побудувати й синхронізувати модель форми з HTML. FormControl явно не створюється - Angular робить це сам. Реактивні форми починаються з FormGroup, описаного в TypeScript. Це програмне дерево контролів, яке можна спостерігати через RxJS, змінювати напряму і тестувати без жодного DOM. Дані рухаються в один бік: від моделі до подання.
Коли що використовувати
- Форма входу, контактна форма, 3-4 статичні поля: на основі шаблону, швидше написати
- Поля, що з'являються або зникають залежно від дій користувача: реактивна (
addControl()/removeControl()) - Крос-польова валідація (пароль має збігатися з підтвердженням): реактивна, валідатор на рівні групи
- Помилки з сервера, прив'язані до конкретного поля: реактивна (
control.setErrors()) - Потрібно тестувати логіку форми без браузера: реактивна, чистий TypeScript
- Швидкий прототип або проста форма в Ionic: на основі шаблону
На практиці більшість команд переходить на реактивні форми після першого разу, коли потрібно динамічно додати поле до форми на основі шаблону і виявляється, що жодного API для цього немає.
Таблиця порівняння
| Характеристика | Template-driven | Reactive |
|---|---|---|
| Налаштування | FormsModule + ngModel в HTML | ReactiveFormsModule + FormGroup у TS |
| Потік даних | Двостороннє зв'язування (ngModel) | Однонаправлений (valueChanges observable) |
| Валідація | Атрибути HTML + помилки директив | Масив Validators на FormControl |
| Динамічні поля | Обхідні шляхи через *ngFor | addControl() / removeControl() |
| Асинхронна валідація | Можлива, більше налаштувань | Вбудована через asyncValidators |
| Тестування | Потрібен DOM, складніше ізолювати | Тестуєш FormGroup напряму |
| Коли використовувати | Прості, статичні форми | Динамічні, складні або тестовані форми |
Як Angular обробляє обидва підходи
Для форм на основі шаблонів компілятор Angular сканує шаблон на директиви ngModel і будує NgForm, зіставляючи атрибути name з деревом директив. Якщо пропустити name на інпуті - це поле просто не потрапить у form.value.
Реактивні форми будують дерево FormGroup у TypeScript під час виконання. Зміни значень надходять через observable valueChanges і statusChanges без участі шаблону. Саме тому в тестах можна викликати form.setValue() і одразу читати form.valid - рендерити нічого не потрібно.
Типові помилки
1. Змішування ngModel з реактивними формами
<!-- Викликає помилку NG0100 або автоматичне перевизначення моделі -->
<form [formGroup]="form">
<input formControlName="email" [(ngModel)]="model.email">
</form>Обери один підхід. Якщо використовуєш formControlName, прибери ngModel.
2. Пряма зміна form.value
// Неправильно: Angular не бачить цієї зміни, UI не оновлюється
this.form.value.email = 'new@email.com';
// Правильно
this.form.patchValue({ email: 'new@email.com' });form.value - це знімок стану, а не модель для запису.
3. Відсутній атрибут name у формах на основі шаблонів
<!-- Поле не потрапить у form.value -->
<input ngModel required>
<!-- Правильно -->
<input name="email" ngModel required>4. Забули підписатися на valueChanges
// Нічого не робить
this.form.valueChanges;
// Правильно
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(value => console.log(value));5. Очікуєш, що patchValue запустить асинхронний валідатор
// Асинхронний валідатор не спрацює, якщо встановлено updateOn: 'blur'
this.emailControl.patchValue('new@example.com');
// Щоб примусово перевалідувати:
this.emailControl.updateValueAndValidity();Де зустрічається в реальних проектах
- Wizards на Angular Material
mat-stepper: реактивні, бо потрібна валідація між кроками - Інтеграція з NgRx: значення реактивного
FormGroupзберігаються в стані для undo/redo - JSON-driven форми (наприклад,
jsonforms.io): реактивні зFormArrayдля списків змінної довжини - Прості форми входу в Ionic: на основі шаблонів, менше коду
Питання на співбесіді
Q: Як додати нове поле у форму після того, як вона вже відрендерена?
A: Тільки в реактивній: this.form.addControl('phone', new FormControl('')). У формах на основі шаблонів API для цього немає. Можна обійти через *ngFor по масиву конфігів полів, але це швидко стає складним.
Q: Яка різниця між setValue і patchValue?
A: setValue вимагає передати всі поля FormGroup, інакше викине помилку. patchValue приймає частковий об'єкт і оновлює лише ті поля, які ти передав.
Q: Як тестувати реактивну форму без браузера?
A: Створюєш FormGroup напряму в тесті, викликаєш form.setValue() або form.patchValue(), потім перевіряєш form.valid або помилки конкретного контролу. Рендерити компонент не потрібно.
Q: Як уникнути запиту на сервер при кожному натисканні клавіші в асинхронному валідаторі?
A: Встанови updateOn: 'blur' в опціях FormControl. Тоді асинхронний валідатор запускається лише коли поле втрачає фокус, а не на кожну зміну.
Q: FormArray з 10 рядками. Користувач змінює їх порядок. Асинхронні валідатори спрацьовують не для тих рядків. Як виправити?
A: Відстежуй рядки за унікальним id, а не за індексом масиву. Додай debounceTime до асинхронного валідатора, щоб злити швидкі зміни в один виклик. Після зміни порядку виклич updateValueAndValidity() явно на потрібних контролах, не чекай автоматичного перезапуску. Звертатися до контролів через AbstractControl.at(i) після свопу безпечніше, ніж тримати застарілі посилання.
Приклади
Форма входу (на основі шаблону)
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
<input name="email" ngModel required email #emailField="ngModel">
<div *ngIf="emailField.invalid && emailField.touched">Введіть валідний email</div>
<input name="password" type="password" ngModel required minlength="8">
<button [disabled]="loginForm.invalid">Увійти</button>
</form>onSubmit(form: NgForm) {
if (form.valid) {
console.log(form.value); // { email: "...", password: "..." }
this.authService.login(form.value);
}
}Angular будує NgForm з атрибутів name і директив ngModel. Правила валідації задаються атрибутами HTML. Просто і швидко - покриває більшість простих сценаріїв.
Реєстрація з крос-польовою валідацією (реактивна форма)
import { FormBuilder, FormGroup, Validators, AbstractControl } from '@angular/forms';
export class RegisterComponent {
form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
confirmEmail: ['', Validators.required],
password: ['', [Validators.required, Validators.minLength(8)]]
}, { validators: this.emailMatchValidator });
}
emailMatchValidator(group: AbstractControl) {
const email = group.get('email')?.value;
const confirm = group.get('confirmEmail')?.value;
return email === confirm ? null : { mismatch: true };
}
addPhoneField() {
this.form.addControl('phone', this.fb.control(''));
}
}<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email">
<input formControlName="confirmEmail"
[class.error]="form.hasError('mismatch')">
<div *ngIf="form.hasError('mismatch')">Emails не збігаються</div>
<button type="button" (click)="addPhoneField()">Додати телефон</button>
<button [disabled]="form.invalid">Зареєструватись</button>
</form>Валідатор на рівні групи перевіряє весь FormGroup і повертає { mismatch: true }, коли emails різні. addPhoneField() додає новий контрол під час роботи без перезавантаження форми. Жоден з цих сценаріїв не реалізується у формах на основі шаблонів.
Асинхронний валідатор із перевіркою на сервері
import { AbstractControl } from '@angular/forms';
import { timer, of } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';
asyncEmailValidator(control: AbstractControl) {
return timer(500).pipe(
switchMap(() =>
this.http.get<boolean>(`/api/check-email/${control.value}`)
),
map(isTaken => isTaken ? { emailTaken: true } : null),
catchError(() => of(null)) // мережева помилка - не блокуємо форму
);
}
// Використання в FormControl
email = new FormControl('', {
validators: [Validators.required, Validators.email],
asyncValidators: [this.asyncEmailValidator.bind(this)],
updateOn: 'blur' // перевіряємо тільки при втраті фокусу
});timer(500) дебаунсить виклик, щоб швидкий набір не генерував кілька запитів. updateOn: 'blur' обмежує запуск валідатора моментом виходу з поля. Якщо API недоступний, catchError повертає null, форма не застрягає. Нюанс: якщо викликати patchValue() на цьому контролі, асинхронний валідатор не перезапуститься автоматично. Виклич updateValueAndValidity() явно, якщо потрібна примусова перевірка.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.