21. Reactive Forms
⚛️ Reactive Forms в Angular
Заголовок раздела «⚛️ Reactive Forms в Angular»Reactive Forms — это подход, где вся логика формы описана в TypeScript, а шаблон лишь связывается с ней. Полный контроль, мощная валидация, прекрасная тестируемость.
🔧 Подключение
Заголовок раздела «🔧 Подключение»import { Component } from '@angular/core';import { ReactiveFormsModule } from '@angular/forms';
@Component({ standalone: true, imports: [ReactiveFormsModule], template: `...`})export class SignupComponent {}🎯 FormControl — базовый элемент
Заголовок раздела «🎯 FormControl — базовый элемент»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>🏗️ FormGroup — группа контролов
Заголовок раздела «🏗️ FormGroup — группа контролов»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 — удобный синтаксис
Заголовок раздела «🚀 FormBuilder — удобный синтаксис»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 — динамические поля
Заголовок раздела «📚 FormArray — динамические поля»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>🔄 patchValue vs setValue
Заголовок раздела «🔄 patchValue vs setValue»setValue — устанавливает все поля (требует полный объект):
// ✅ Все ключи должны быть указаныthis.form.setValue({ name: 'Яша', password: '123456'});
// ❌ Ошибка — пропущен ключ passwordpatchValue — обновляет только указанные поля:
// ✅ Можно обновлять частичноthis.form.patchValue({ name: 'Яша' });
// Обновление вложенной группыthis.form.patchValue({ profile: { city: 'Москва' }});👁️ valueChanges — Observable изменений
Заголовок раздела «👁️ valueChanges — Observable изменений»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 }); } });}📊 statusChanges — Observable статусов
Заголовок раздела «📊 statusChanges — Observable статусов»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 уже занят' });🔒 Типизированные Reactive Forms (Angular 14+)
Заголовок раздела «🔒 Типизированные Reactive Forms (Angular 14+)»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!; // stringconst age: number | null | undefined = form.value.age; // number | null | undefinedexport 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: '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> );}