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

20. Template-driven Forms

Template-driven формы используют директивы в шаблоне для управления формой. Логика живёт в HTML, а не в TypeScript. Отличный выбор для простых форм с несложной валидацией.


// standalone компонент
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [FormsModule],
template: `...`
})
export class RegistrationComponent {}

Или в NgModule:

import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [FormsModule],
declarations: [RegistrationComponent]
})
export class AppModule {}

NgForm автоматически добавляется к каждому <form>. Получить доступ к ней можно через шаблонную переменную:

<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<!-- поля формы -->
<button type="submit" [disabled]="!f.valid">Отправить</button>
</form>

Свойства NgForm:

f.valid // true — все поля валидны
f.invalid // true — есть ошибки валидации
f.dirty // true — пользователь изменил хотя бы одно поле
f.pristine // true — форма не тронута
f.touched // true — фокус был хотя бы на одном поле
f.untouched // true — ни одно поле не получало фокус
f.value // объект со всеми значениями: { email: '...', name: '...' }

[(ngModel)] создаёт двустороннюю привязку. name атрибут обязателен — именно он становится ключом в f.value:

<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<input
type="text"
name="username"
[(ngModel)]="user.username"
required
minlength="3"
#username="ngModel"
/>
<div *ngIf="username.invalid && username.touched">
<span *ngIf="username.errors?.['required']">Имя обязательно</span>
<span *ngIf="username.errors?.['minlength']">
Минимум {{ username.errors?.['minlength'].requiredLength }} символов
</span>
</div>
</form>

#username="ngModel" — шаблонная переменная для доступа к состоянию конкретного поля.


registration.component.ts
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { CommonModule } from '@angular/common';
interface User {
username: string;
email: string;
password: string;
age: number | null;
agreeToTerms: boolean;
}
@Component({
standalone: true,
imports: [FormsModule, CommonModule],
template: `
<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<!-- Имя пользователя -->
<div class="field">
<label>Имя пользователя</label>
<input
type="text"
name="username"
[(ngModel)]="user.username"
required
minlength="3"
maxlength="20"
#username="ngModel"
[class.error]="username.invalid && username.touched"
/>
<div class="errors" *ngIf="username.invalid && username.touched">
<span *ngIf="username.errors?.['required']">⚠️ Обязательное поле</span>
<span *ngIf="username.errors?.['minlength']">⚠️ Минимум 3 символа</span>
<span *ngIf="username.errors?.['maxlength']">⚠️ Максимум 20 символов</span>
</div>
</div>
<!-- Email -->
<div class="field">
<label>Email</label>
<input
type="email"
name="email"
[(ngModel)]="user.email"
required
email
#emailField="ngModel"
/>
<div class="errors" *ngIf="emailField.invalid && emailField.touched">
<span *ngIf="emailField.errors?.['required']">⚠️ Укажите email</span>
<span *ngIf="emailField.errors?.['email']">⚠️ Неверный формат email</span>
</div>
</div>
<!-- Кнопка -->
<button type="submit" [disabled]="f.invalid">
Зарегистрироваться
</button>
<!-- Отладка -->
<pre>{{ f.value | json }}</pre>
</form>
`
})
export class RegistrationComponent {
user: User = {
username: '',
email: '',
password: '',
age: null,
agreeToTerms: false
};
onSubmit(form: NgForm) {
if (form.valid) {
console.log('Отправляем:', form.value);
}
}
}

ngModelGroup позволяет группировать поля в объект внутри f.value:

<form #f="ngForm">
<div ngModelGroup="personalInfo" #personalInfo="ngModelGroup">
<input name="firstName" [(ngModel)]="data.firstName" required />
<input name="lastName" [(ngModel)]="data.lastName" required />
</div>
<div ngModelGroup="address" #address="ngModelGroup">
<input name="city" [(ngModel)]="data.city" required />
<input name="street" [(ngModel)]="data.street" required />
</div>
</form>

Результат f.value:

{
"personalInfo": {
"firstName": "Яша",
"lastName": "Смирнов"
},
"address": {
"city": "Москва",
"street": "Ленина, 5"
}
}

// Полный сброс — значения и состояния
onReset(form: NgForm) {
form.reset();
}
// Сброс с начальными значениями
onReset(form: NgForm) {
form.resetForm({
username: '',
email: '',
agreeToTerms: false
});
}
<button type="button" (click)="onReset(f)">Очистить форму</button>

После reset() все поля станут pristine и untouched.


Angular автоматически добавляет CSS-классы к элементам формы:

КлассКогда добавляется
ng-validПоле прошло валидацию
ng-invalidПоле не прошло валидацию
ng-pristineПоле не изменялось
ng-dirtyЗначение изменено
ng-untouchedФокуса не было
ng-touchedФокус был и убран
input.ng-invalid.ng-touched {
border-color: red;
background: #fff0f0;
}
input.ng-valid.ng-dirty {
border-color: green;
}

Не всегда нужна двусторонняя привязка [()]. Для простых случаев:

<!-- Только из модели в шаблон -->
<input type="text" name="email" [ngModel]="user.email" />
<!-- Только из шаблона в модель -->
<input type="text" name="email" (ngModelChange)="user.email = $event" />
<!-- Двустороннее = оба вместе -->
<input type="text" name="email" [(ngModel)]="user.email" />

КритерийTemplate-DrivenReactive
СложностьПростаяВыше
ТестируемостьСложнееЛегко
Динамические поляСложноПросто
ВалидацияВ шаблонеВ TypeScript
РеактивностьЧерез NgModelЧерез Observables
Подходит дляПростых формСложных форм

export default function TemplateDrivenFormsPlayground() {
const [form, setForm] = React.useState({
username: '', email: '', password: '', age: '', agreeToTerms: false
});
const [touched, setTouched] = React.useState({});
const [submitted, setSubmitted] = React.useState(false);
const touch = (field) => setTouched(prev => ({ ...prev, [field]: true }));
const change = (field, value) => setForm(prev => ({ ...prev, [field]: value }));
const validators = {
username: (v) => {
if (!v) return 'Обязательное поле';
if (v.length < 3) return `Минимум 3 символа (сейчас ${v.length})`;
if (v.length > 20) return 'Максимум 20 символов';
return null;
},
email: (v) => {
if (!v) return 'Укажите email';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Неверный формат email';
return null;
},
password: (v) => {
if (!v) return 'Введите пароль';
if (v.length < 6) return 'Минимум 6 символов';
return null;
},
age: (v) => {
if (!v) return 'Укажите возраст';
if (parseInt(v) < 18) return 'Минимальный возраст 18 лет';
return null;
},
agreeToTerms: (v) => !v ? 'Необходимо согласие' : null,
};
const errors = Object.fromEntries(
Object.entries(validators).map(([k, fn]) => [k, fn(form[k])])
);
const isValid = Object.values(errors).every(e => !e);
const getFieldStatus = (field) => {
if (!touched[field]) return { border: '#334155', icon: '' };
if (errors[field]) return { border: '#dd0031', icon: '✗' };
return { border: '#22c55e', icon: '✓' };
};
const reset = () => {
setForm({ username: '', email: '', password: '', age: '', agreeToTerms: false });
setTouched({});
setSubmitted(false);
};
const fieldStyle = (field) => ({
width: '100%', background: '#0f172a', border: `1px solid ${getFieldStatus(field).border}`,
color: '#e2e8f0', padding: '8px 12px', borderRadius: 6, fontSize: 13, boxSizing: 'border-box',
outline: 'none', transition: 'border-color 0.2s'
});
return (
<div style={{ background: '#0f172a', minHeight: 520, 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 }}>📝 Template-Driven Form</span>
<span style={{ fontSize: 12, padding: '4px 10px', borderRadius: 20, background: isValid ? '#15803d40' : '#7f1d1d40', color: isValid ? '#22c55e' : '#f87171', border: `1px solid ${isValid ? '#22c55e' : '#f87171'}` }}>
{isValid ? 'f.valid = true' : 'f.invalid = true'}
</span>
</div>
{submitted ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 40, marginBottom: 12 }}>🎉</div>
<div style={{ color: '#22c55e', fontWeight: 700, marginBottom: 16 }}>Форма отправлена!</div>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 16, textAlign: 'left', marginBottom: 16 }}>
<div style={{ color: '#7dd3fc', fontSize: 12, marginBottom: 8 }}>f.value:</div>
{Object.entries(form).map(([k, v]) => (
<div key={k} style={{ fontSize: 12, color: '#94a3b8', marginBottom: 4 }}>
<span style={{ color: '#7dd3fc' }}>{k}</span>: <span style={{ color: '#a3e635' }}>{String(v)}</span>
</div>
))}
</div>
<button onClick={reset} style={{ background: '#dd0031', color: 'white', border: 'none', padding: '8px 20px', borderRadius: 8, cursor: 'pointer' }}>
Сбросить форму
</button>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{[
{ field: 'username', label: 'Имя пользователя', type: 'text', placeholder: 'yasha_dev' },
{ field: 'email', label: 'Email', type: 'email', placeholder: '[email protected]' },
{ field: 'password', label: 'Пароль', type: 'password', placeholder: '••••••' },
{ field: 'age', label: 'Возраст', type: 'number', placeholder: '25' },
].map(({ field, label, type, placeholder }) => (
<div key={field}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<label style={{ fontSize: 12, color: '#94a3b8' }}>{label}</label>
<span style={{ fontSize: 12, color: touched[field] && !errors[field] ? '#22c55e' : '#dd0031' }}>
{getFieldStatus(field).icon}
</span>
</div>
<input
type={type}
placeholder={placeholder}
value={form[field]}
onChange={e => change(field, e.target.value)}
onBlur={() => touch(field)}
style={fieldStyle(field)}
/>
{touched[field] && errors[field] && (
<div style={{ color: '#f87171', fontSize: 11, marginTop: 4 }}>⚠️ {errors[field]}</div>
)}
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={form.agreeToTerms}
onChange={e => { change('agreeToTerms', e.target.checked); touch('agreeToTerms'); }}
style={{ accentColor: '#dd0031' }}
/>
<label style={{ fontSize: 13, color: '#94a3b8' }}>Согласен с условиями</label>
{touched['agreeToTerms'] && errors['agreeToTerms'] && (
<span style={{ color: '#f87171', fontSize: 11 }}>⚠️</span>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => isValid && setSubmitted(true)}
disabled={!isValid}
style={{ flex: 1, background: isValid ? '#dd0031' : '#334155', color: isValid ? 'white' : '#64748b', border: 'none', padding: '10px', borderRadius: 8, cursor: isValid ? 'pointer' : 'not-allowed', fontSize: 13 }}
>
Зарегистрироваться
</button>
<button onClick={reset} style={{ background: 'transparent', color: '#64748b', border: '1px solid #334155', padding: '10px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
Сброс
</button>
</div>
</div>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 14, border: '1px solid #334155', height: 'fit-content' }}>
<div style={{ color: '#7dd3fc', fontSize: 12, marginBottom: 12 }}>📊 f.value состояние</div>
{Object.entries(form).map(([k, v]) => {
const err = errors[k];
const isTouched = touched[k];
return (
<div key={k} style={{ marginBottom: 8, fontSize: 12 }}>
<div style={{ color: '#94a3b8', marginBottom: 2 }}>
<span style={{ color: '#7dd3fc' }}>{k}</span>: <span style={{ color: '#a3e635' }}>{String(v) || '""'}</span>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: isTouched ? '#7c3aed40' : '#1e293b', color: isTouched ? '#a78bfa' : '#475569', border: `1px solid ${isTouched ? '#7c3aed' : '#334155'}` }}>
{isTouched ? 'touched' : 'untouched'}
</span>
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 10, background: err ? '#7f1d1d40' : '#15803d40', color: err ? '#f87171' : '#22c55e', border: `1px solid ${err ? '#f87171' : '#22c55e'}` }}>
{err ? 'invalid' : 'valid'}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}