Перейти к содержимому

21. Reactive Forms

Reactive Forms — это подход, где вся логика формы описана в TypeScript, а шаблон лишь связывается с ней. Полный контроль, мощная валидация, прекрасная тестируемость.


import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `...`
})
export class SignupComponent {}

FormControl управляет значением и состоянием одного поля:

import { FormControl, Validators } from '@angular/forms';
// Простой контрол
const email = new FormControl('');
// С начальным значением и валидаторами
const email = new FormControl('', [
Validators.required,
Validators.email
]);
// Со строгой типизацией (Angular 14+)
const age = new FormControl<number | null>(null, [
Validators.required,
Validators.min(18)
]);

В шаблоне связываем через [formControl]:

<input [formControl]="email" type="email" />
<div *ngIf="email.invalid && email.touched">
{{ email.errors | json }}
</div>

import { FormGroup, FormControl, Validators } from '@angular/forms';
this.form = new FormGroup({
name: new FormControl('', [Validators.required, Validators.minLength(2)]),
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
});
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" />
<input formControlName="email" type="email" />
<input formControlName="password" type="password" />
<button [disabled]="form.invalid">Зарегистрироваться</button>
</form>

FormBuilder сокращает код, убирает new FormControl():

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({ ... })
export class SignupComponent {
form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
profile: this.fb.group({
city: ['', Validators.required],
bio: ['']
})
});
}
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}

FormArray для списков полей (например, список телефонов):

import { FormArray, FormControl, Validators } from '@angular/forms';
this.form = this.fb.group({
name: ['', Validators.required],
phones: this.fb.array([
this.fb.control('', Validators.required)
])
});
// Геттер для удобного доступа
get phones(): FormArray {
return this.form.get('phones') as FormArray;
}
// Добавление нового телефона
addPhone() {
this.phones.push(this.fb.control('', Validators.required));
}
// Удаление телефона
removePhone(index: number) {
this.phones.removeAt(index);
}
<div formArrayName="phones">
<div *ngFor="let phone of phones.controls; index as i">
<input [formControlName]="i" placeholder="+7 999 000 00 00" />
<button type="button" (click)="removePhone(i)">✕</button>
</div>
<button type="button" (click)="addPhone()">+ Добавить телефон</button>
</div>

setValue — устанавливает все поля (требует полный объект):

// ✅ Все ключи должны быть указаны
this.form.setValue({
name: 'Яша',
password: '123456'
});
// ❌ Ошибка — пропущен ключ password
this.form.setValue({ name: 'Яша', email: '[email protected]' });

patchValue — обновляет только указанные поля:

// ✅ Можно обновлять частично
this.form.patchValue({ name: 'Яша' });
this.form.patchValue({ email: '[email protected]' });
// Обновление вложенной группы
this.form.patchValue({
profile: { city: 'Москва' }
});

valueChanges — это Observable, который эмитит при каждом изменении:

ngOnInit() {
// Следим за всей формой
this.form.valueChanges.subscribe(value => {
console.log('Форма изменилась:', value);
});
// Следим за одним полем
this.form.get('email')!.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(email => this.userService.checkEmailExists(email))
)
.subscribe(exists => {
if (exists) {
this.form.get('email')!.setErrors({ emailTaken: true });
}
});
}

this.form.statusChanges.subscribe(status => {
// 'VALID', 'INVALID', 'PENDING', 'DISABLED'
console.log('Статус формы:', status);
this.isFormPending = status === 'PENDING';
});

// Через get()
const nameControl = this.form.get('name');
const cityControl = this.form.get('profile.city'); // вложенный
// Прямые геттеры (рекомендуется)
get name() { return this.form.get('name')!; }
get email() { return this.form.get('email')!; }
<!-- В шаблоне -->
<div *ngIf="name.invalid && name.touched">
<span *ngIf="name.errors?.['required']">Имя обязательно</span>
</div>

// Отключить/включить контрол
this.form.get('email')!.disable();
this.form.get('email')!.enable();
// Сбросить форму
this.form.reset();
this.form.reset({ name: '', email: '' });
// Отметить все как touched (для показа ошибок при submit)
this.form.markAllAsTouched();
// Программно установить ошибку
this.form.get('email')!.setErrors({ serverError: 'Email уже занят' });

import { FormControl, FormGroup } from '@angular/forms';
interface SignupForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number | null>;
}
const form = new FormGroup<SignupForm>({
name: new FormControl('', { nonNullable: true, validators: Validators.required }),
email: new FormControl('', { nonNullable: true, validators: Validators.email }),
age: new FormControl<number | null>(null)
});
// TypeScript знает типы!
const name: string = form.value.name!; // string
const age: number | null | undefined = form.value.age; // number | null | undefined

export default function ReactiveFormsPlayground() {
const [fields, setFields] = React.useState({
name: '', email: '', password: '', city: ''
});
const [touched, setTouched] = React.useState({});
const [phones, setPhones] = React.useState(['']);
const [submitted, setSubmitted] = React.useState(null);
const [liveValues, setLiveValues] = React.useState(true);
const touch = (f) => setTouched(p => ({ ...p, [f]: true }));
const change = (f, v) => setFields(p => ({ ...p, [f]: v }));
const validators = {
name: v => !v ? 'required' : v.length < 2 ? 'minlength:2' : null,
email: v => !v ? 'required' : !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? 'email' : null,
password: v => !v ? 'required' : v.length < 8 ? 'minlength:8' : null,
city: v => !v ? 'required' : null,
};
const errors = Object.fromEntries(Object.entries(validators).map(([k, fn]) => [k, fn(fields[k])]));
const isValid = Object.values(errors).every(e => !e) && phones.every(p => p.length > 0);
const addPhone = () => setPhones(p => [...p, '']);
const removePhone = (i) => setPhones(p => p.filter((_, idx) => idx !== i));
const changePhone = (i, v) => setPhones(p => p.map((ph, idx) => idx === i ? v : ph));
const handleSubmit = () => {
setTouched(Object.fromEntries(Object.keys(fields).map(k => [k, true])));
if (isValid) setSubmitted({ ...fields, phones });
};
const reset = () => {
setFields({ name: '', email: '', password: '', city: '' });
setTouched({});
setPhones(['']);
setSubmitted(null);
};
const inputStyle = (field) => ({
width: '100%', background: '#0f172a', padding: '7px 12px', borderRadius: 6, fontSize: 13,
color: '#e2e8f0', outline: 'none', boxSizing: 'border-box',
border: `1px solid ${!touched[field] ? '#334155' : errors[field] ? '#dd0031' : '#22c55e'}`
});
return (
<div style={{ background: '#0f172a', minHeight: 500, padding: 24, borderRadius: 12, fontFamily: 'system-ui', color: '#e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<span style={{ color: '#dd0031', fontWeight: 700, fontSize: 16 }}>⚛️ Reactive Form</span>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{ fontSize: 11, padding: '3px 8px', borderRadius: 20, background: isValid ? '#15803d40' : '#7f1d1d40', color: isValid ? '#22c55e' : '#f87171', border: `1px solid ${isValid ? '#22c55e' : '#f87171'}` }}>
{isValid ? 'VALID' : 'INVALID'}
</span>
<button onClick={() => setLiveValues(v => !v)} style={{ fontSize: 11, padding: '3px 8px', borderRadius: 20, background: '#1e293b', color: '#94a3b8', border: '1px solid #334155', cursor: 'pointer' }}>
{liveValues ? 'скрыть' : 'показать'} valueChanges
</button>
</div>
</div>
{submitted ? (
<div>
<div style={{ color: '#22c55e', fontWeight: 700, marginBottom: 12 }}>✅ form.value при submit:</div>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 16, marginBottom: 16 }}>
{Object.entries(submitted).map(([k, v]) => (
<div key={k} style={{ fontSize: 12, marginBottom: 6 }}>
<span style={{ color: '#7dd3fc' }}>{k}</span>: <span style={{ color: '#a3e635' }}>{JSON.stringify(v)}</span>
</div>
))}
</div>
<button onClick={reset} style={{ background: '#dd0031', color: 'white', border: 'none', padding: '8px 20px', borderRadius: 8, cursor: 'pointer' }}>
Сбросить (form.reset())
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: liveValues ? '1fr 1fr' : '1fr', gap: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ f: 'name', label: 'Имя (FormControl)', placeholder: 'Яша' },
{ f: 'email', label: 'Email (FormControl)', placeholder: '[email protected]' },
{ f: 'password', label: 'Пароль (FormControl)', placeholder: '••••••••', type: 'password' },
{ f: 'city', label: 'Город (FormGroup → profile.city)', placeholder: 'Москва' },
].map(({ f, label, placeholder, type = 'text' }) => (
<div key={f}>
<label style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>{label}</label>
<input
type={type}
placeholder={placeholder}
value={fields[f]}
onChange={e => change(f, e.target.value)}
onBlur={() => touch(f)}
style={inputStyle(f)}
/>
{touched[f] && errors[f] && (
<div style={{ color: '#f87171', fontSize: 11, marginTop: 3 }}>⚠️ {errors[f]}</div>
)}
</div>
))}
<div>
<label style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>Телефоны (FormArray)</label>
{phones.map((ph, i) => (
<div key={i} style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<input
value={ph}
onChange={e => changePhone(i, e.target.value)}
placeholder="+7 999 000 00 00"
style={{ flex: 1, background: '#0f172a', border: `1px solid ${ph ? '#22c55e' : '#dd0031'}`, color: '#e2e8f0', padding: '7px 10px', borderRadius: 6, fontSize: 13, outline: 'none' }}
/>
{phones.length > 1 && (
<button onClick={() => removePhone(i)} style={{ background: '#7f1d1d', color: '#f87171', border: 'none', borderRadius: 6, padding: '0 10px', cursor: 'pointer', fontSize: 16 }}>✕</button>
)}
</div>
))}
<button onClick={addPhone} style={{ background: 'transparent', color: '#dd0031', border: '1px dashed #dd0031', padding: '5px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
+ Добавить телефон (phones.push())
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button onClick={handleSubmit} style={{ flex: 1, background: isValid ? '#dd0031' : '#334155', color: isValid ? 'white' : '#64748b', border: 'none', padding: '10px', borderRadius: 8, cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 13 }}>
Submit (form.valid: {String(isValid)})
</button>
<button onClick={reset} style={{ background: 'transparent', color: '#64748b', border: '1px solid #334155', padding: '10px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
Reset
</button>
</div>
</div>
{liveValues && (
<div style={{ background: '#1e293b', borderRadius: 8, padding: 14, border: '1px solid #334155', height: 'fit-content' }}>
<div style={{ color: '#7dd3fc', fontSize: 12, marginBottom: 10 }}>📡 valueChanges (live)</div>
{Object.entries(fields).map(([k, v]) => (
<div key={k} style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 2 }}>form.get('{k}').value</div>
<div style={{ fontSize: 13, color: '#a3e635', background: '#0f172a', padding: '4px 8px', borderRadius: 4 }}>
{v || <span style={{ color: '#475569' }}>""</span>}
</div>
{touched[k] && (
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<span style={{ fontSize: 10, color: errors[k] ? '#f87171' : '#22c55e', border: `1px solid ${errors[k] ? '#f87171' : '#22c55e'}`, padding: '1px 6px', borderRadius: 10 }}>
{errors[k] ? 'INVALID' : 'VALID'}
</span>
<span style={{ fontSize: 10, color: '#a78bfa', border: '1px solid #7c3aed', padding: '1px 6px', borderRadius: 10 }}>touched</span>
</div>
)}
</div>
))}
<div style={{ borderTop: '1px solid #334155', paddingTop: 10, marginTop: 4 }}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>phones (FormArray):</div>
<div style={{ fontSize: 12, color: '#a3e635' }}>{JSON.stringify(phones)}</div>
</div>
</div>
)}
</div>
)}
</div>
);
}