Chapter 1 — Why Forms Are a Big Deal
1.1 — Forms Are Everywhere
Every meaningful interaction a user has with a web application goes through a form. Login, registration, search, checkout, writing a post, updating a profile, placing an order, leaving a review — all of these are forms.
A form is not just an HTML <form> tag. A form is a system. It needs to collect user input, validate that input, show meaningful error messages at the right time, handle the loading state while submitting, and deal with success and failure responses.
Getting forms right makes the difference between an app that feels professional and one that feels broken. When a user fills out 10 fields and hits submit, sees a generic error message with no indication of what went wrong, and then realizes all their data was wiped — that is a bad form experience. Angular gives you the tools to make forms that are a pleasure to use.
1.2 — Two Approaches to Forms in Angular
Angular provides two completely different ways to build forms. They are not variations of the same thing — they are genuinely different approaches with different strengths.
Template-Driven Forms — You describe the form almost entirely in the HTML template using directives. Angular reads your template and builds the form model behind the scenes for you. This approach is simpler and faster to set up for basic forms. Less TypeScript code. The logic lives in the template.
Reactive Forms — You build the form model explicitly in TypeScript. The template then binds to that model. This approach is more powerful, more predictable, easier to test, and better for complex forms. More TypeScript code, but more control.
The rule of thumb is:
Use Template-Driven Forms for simple forms — a contact form, a newsletter signup, a basic login with two fields.
Use Reactive Forms for anything complex — multi-step forms, forms with dynamic fields, forms that depend on API data, forms that need complex validation.
In real applications, you will mostly use Reactive Forms. But understanding both is important because you will encounter both in existing codebases.
Chapter 2 — Template-Driven Forms
2.1 — Setting Up FormsModule
Template-driven forms require FormsModule. Import it directly in any component that uses template-driven forms:
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-contact',
imports: [FormsModule],
templateUrl: './contact.html',
styleUrl: './contact.css'
})
export class Contact {
}
2.2 — Your First Template-Driven Form
Let's build a complete contact form step by step.
src/app/contact/contact.ts:
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-contact',
imports: [FormsModule],
templateUrl: './contact.html',
styleUrl: './contact.css'
})
export class Contact {
name: string = '';
email: string = '';
subject: string = '';
message: string = '';
isSubmitted: boolean = false;
onSubmit(): void {
console.log('Form submitted!');
console.log('Name:', this.name);
console.log('Email:', this.email);
console.log('Subject:', this.subject);
console.log('Message:', this.message);
this.isSubmitted = true;
}
}
src/app/contact/contact.html:
<div class="form-container">
@if (isSubmitted) {
<div class="success-message">
<h2>✓ Message Sent!</h2>
<p>Thank you for reaching out. We will get back to you soon.</p>
</div>
} @else {
<h1>Contact Us</h1>
<form #contactForm="ngForm" (ngSubmit)="onSubmit()">
<div class="field">
<label for="name">Your Name</label>
<input
id="name"
type="text"
name="name"
[(ngModel)]="name"
required
minlength="2"
placeholder="Rahul Sharma"
#nameField="ngModel">
@if (nameField.invalid && nameField.touched) {
<div class="error">
@if (nameField.errors?.['required']) {
<span>Name is required</span>
}
@if (nameField.errors?.['minlength']) {
<span>Name must be at least 2 characters</span>
}
</div>
}
</div>
<div class="field">
<label for="email">Email Address</label>
<input
id="email"
type="email"
name="email"
[(ngModel)]="email"
required
email
placeholder="rahul@example.com"
#emailField="ngModel">
@if (emailField.invalid && emailField.touched) {
<div class="error">
@if (emailField.errors?.['required']) {
<span>Email is required</span>
}
@if (emailField.errors?.['email']) {
<span>Please enter a valid email address</span>
}
</div>
}
</div>
<div class="field">
<label for="subject">Subject</label>
<input
id="subject"
type="text"
name="subject"
[(ngModel)]="subject"
required
placeholder="How can we help?"
#subjectField="ngModel">
@if (subjectField.invalid && subjectField.touched) {
<div class="error">
<span>Subject is required</span>
</div>
}
</div>
<div class="field">
<label for="message">Message</label>
<textarea
id="message"
name="message"
[(ngModel)]="message"
required
minlength="10"
rows="5"
placeholder="Write your message here..."
#messageField="ngModel">
</textarea>
@if (messageField.invalid && messageField.touched) {
<div class="error">
@if (messageField.errors?.['required']) {
<span>Message is required</span>
}
@if (messageField.errors?.['minlength']) {
<span>Message must be at least 10 characters</span>
}
</div>
}
</div>
<button
type="submit"
[disabled]="contactForm.invalid">
Send Message
</button>
</form>
}
</div>
There is a lot happening here. Let's understand each concept one by one.
2.3 — Understanding the Key Pieces
#contactForm="ngForm"
This is a template reference variable. When you write #contactForm="ngForm" on the <form> element, Angular creates an NgForm object representing the entire form and stores it in contactForm. You can then use contactForm anywhere in the template.
contactForm.invalid is true when any field in the form fails validation. That is why the submit button becomes disabled when the form is invalid — [disabled]="contactForm.invalid".
contactForm.valid, contactForm.dirty, contactForm.touched, contactForm.value — these are all properties available on the form reference.
name="name" on every input
Every input that participates in a template-driven form must have a name attribute. Angular uses the name attribute to register the field with the form. Without it, the field is invisible to Angular's form system.
[(ngModel)]="name"
This is two-way binding connecting the input to your TypeScript property. When the user types, this.name updates. When this.name changes in TypeScript, the input value updates.
required, minlength="2", email
These are Angular's built-in validators written directly as HTML attributes. required marks the field as mandatory. minlength sets the minimum character count. email validates that the value looks like an email address.
Behind the scenes, Angular reads these attributes and adds validation logic to the field.
#nameField="ngModel"
Just like #contactForm="ngForm" gives you the whole form, #nameField="ngModel" gives you an NgModel object for this specific field. This object has properties you can use to show or hide error messages.
The most important properties are:
nameField.valid — is this field currently passing all validators?
nameField.invalid — is this field currently failing any validator?
nameField.touched — has the user ever clicked into this field and then clicked away?
nameField.dirty — has the user typed anything in this field?
nameField.pristine — has the user NOT typed anything yet?
nameField.errors — an object containing all current validation errors.
Why touched matters for showing errors
You never want to show error messages the moment the page loads — before the user has even interacted with the form. That would be terrible UX. You only want to show errors after the user has interacted with a field and it is invalid.
touched becomes true after the user focuses a field and then moves focus away from it. So nameField.invalid && nameField.touched means "this field is invalid AND the user has already interacted with it" — the perfect condition for showing an error.
nameField.errors?.['required']
nameField.errors is an object like { required: true } when required validation fails, or { minlength: { requiredLength: 2, actualLength: 1 } } when minlength fails, or null when there are no errors.
The ?. is optional chaining — it safely accesses the property without throwing an error if errors is null.
src/app/contact/contact.css:
.form-container {
max-width: 600px;
margin: 48px auto;
padding: 0 24px;
}
h1 {
font-size: 32px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 32px;
}
.field {
margin-bottom: 22px;
}
label {
display: block;
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 7px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input, textarea {
width: 100%;
padding: 11px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 15px;
color: #333;
transition: border-color 0.2s;
font-family: inherit;
box-sizing: border-box;
}
input:focus, textarea:focus {
outline: none;
border-color: #0070f3;
box-shadow: 0 0 0 3px rgba(0,112,243,0.1);
}
input.ng-invalid.ng-touched,
textarea.ng-invalid.ng-touched {
border-color: #dc3545;
}
input.ng-valid.ng-touched,
textarea.ng-valid.ng-touched {
border-color: #28a745;
}
.error {
margin-top: 6px;
font-size: 13px;
color: #dc3545;
}
button {
width: 100%;
padding: 13px;
background: #0070f3;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #005ac1; }
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.success-message {
text-align: center;
padding: 64px 24px;
}
.success-message h2 {
font-size: 28px;
color: #28a745;
margin-bottom: 12px;
}
.success-message p {
color: #666;
font-size: 16px;
}
Notice input.ng-invalid.ng-touched and input.ng-valid.ng-touched in the CSS. Angular automatically adds CSS classes to every form field based on its current state:
ng-valid — field passes all validators
ng-invalid — field fails at least one validator
ng-touched — user has interacted with field
ng-untouched — user has not yet interacted with field
ng-dirty — user has changed the value
ng-pristine — user has not yet changed the value
You can style these classes directly in your CSS to visually indicate field states — red border for invalid, green for valid.
2.4 — Resetting the Form
To reset all fields back to empty after submission:
import { Component, ViewChild } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-contact',
imports: [FormsModule],
templateUrl: './contact.html',
styleUrl: './contact.css'
})
export class Contact {
@ViewChild('contactForm') contactForm!: NgForm;
name: string = '';
email: string = '';
message: string = '';
onSubmit(): void {
console.log('Submitted:', { name: this.name, email: this.email, message: this.message });
// Reset the form — clears values AND resets touched/dirty state
this.contactForm.reset();
}
}
NgForm.reset() clears all field values AND resets the form's state — touched, dirty, and invalid all go back to their initial state. This is important because if you just reset the TypeScript properties without calling .reset(), the form still thinks the fields were touched and would still show validation errors.
Chapter 3 — Reactive Forms
3.1 — The Philosophy of Reactive Forms
Reactive Forms are Angular's more powerful, more explicit forms approach. Instead of Angular reading your HTML template and building a form model from it, YOU build the form model in TypeScript and then connect the template to it.
This gives you:
Full control — you define every field, every validator, every initial value in TypeScript. There is no magic happening behind the scenes.
Type safety — because the form model is in TypeScript, you get autocomplete, type checking, and refactoring support.
Testability — you can test form logic without any DOM at all because the logic is in TypeScript.
Dynamic forms — adding and removing form fields at runtime is straightforward because you are working with objects in TypeScript.
Reactive — the form model is observable. You can subscribe to changes in specific fields or the entire form and react to them.
3.2 — Setting Up ReactiveFormsModule
Import ReactiveFormsModule in any component that uses reactive forms:
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule],
templateUrl: './register.html',
styleUrl: './register.css'
})
export class Register {
}
3.3 — The Three Building Blocks of Reactive Forms
Before building anything, you need to understand the three classes that make up a reactive form:
FormControl — represents a single form field. It holds the current value, the validation state, and the validators for that one field.
import { FormControl, Validators } from '@angular/forms';
const emailControl = new FormControl('', [Validators.required, Validators.email]);
// First arg: initial value
// Second arg: array of validators
FormGroup — represents a group of form controls. A login form is a FormGroup with an email control and a password control. The group itself has a combined validity — the group is only valid if all its controls are valid.
import { FormGroup, FormControl, Validators } from '@angular/forms';
const loginForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(8)])
});
FormArray — represents a list of form controls or form groups. Used when you need a dynamic number of fields — like adding multiple phone numbers, or a list of education entries. We cover this in Chapter 5.
3.4 — FormBuilder — The Cleaner Syntax
Creating forms with new FormControl() and new FormGroup() everywhere gets verbose quickly. Angular provides FormBuilder — a service that lets you create the same form with a much cleaner syntax:
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule],
templateUrl: './register.html',
styleUrl: './register.css'
})
export class Register {
private fb = inject(FormBuilder);
// Using FormBuilder — much cleaner
registerForm = this.fb.group({
fullName: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
age: [null, [Validators.required, Validators.min(18), Validators.max(100)]],
role: ['user', Validators.required]
});
}
this.fb.group({...}) creates a FormGroup. Each key is a field name. The value is an array where the first element is the initial value and the second element is the validators (or an array of validators).
This is exactly equivalent to:
registerForm = new FormGroup({
fullName: new FormControl('', [Validators.required, Validators.minLength(2)]),
email: new FormControl('', [Validators.required, Validators.email]),
// ... etc
});
FormBuilder is just a convenience wrapper. Use it — it is the standard in real Angular projects.
3.5 — All Built-in Validators
Angular's Validators class provides these built-in validators:
Validators.required // field must have a value
Validators.requiredTrue // value must be exactly true (for checkboxes)
Validators.email // value must look like an email
Validators.minLength(n) // value must be at least n characters
Validators.maxLength(n) // value must be at most n characters
Validators.min(n) // number value must be >= n
Validators.max(n) // number value must be <= n
Validators.pattern(regex) // value must match the regex pattern
You can combine multiple validators in an array:
phone: ['', [
Validators.required,
Validators.minLength(10),
Validators.maxLength(10),
Validators.pattern(/^[0-9]+$/) // only digits
]]
3.6 — Connecting the Form to the Template
In the template, you connect the reactive form using formGroup and formControlName directives:
src/app/register/register.html:
<div class="form-container">
<h1>Create Account</h1>
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<div class="field">
<label>Full Name</label>
<input
type="text"
formControlName="fullName"
placeholder="Rahul Sharma">
@if (registerForm.get('fullName')?.invalid &&
registerForm.get('fullName')?.touched) {
<div class="error">
@if (registerForm.get('fullName')?.errors?.['required']) {
<span>Full name is required</span>
}
@if (registerForm.get('fullName')?.errors?.['minlength']) {
<span>Name must be at least 2 characters</span>
}
</div>
}
</div>
<div class="field">
<label>Email</label>
<input
type="email"
formControlName="email"
placeholder="rahul@example.com">
@if (registerForm.get('email')?.invalid &&
registerForm.get('email')?.touched) {
<div class="error">
@if (registerForm.get('email')?.errors?.['required']) {
<span>Email is required</span>
}
@if (registerForm.get('email')?.errors?.['email']) {
<span>Please enter a valid email</span>
}
</div>
}
</div>
<div class="field">
<label>Password</label>
<input
type="password"
formControlName="password"
placeholder="Minimum 8 characters">
@if (registerForm.get('password')?.invalid &&
registerForm.get('password')?.touched) {
<div class="error">
@if (registerForm.get('password')?.errors?.['required']) {
<span>Password is required</span>
}
@if (registerForm.get('password')?.errors?.['minlength']) {
<span>Password must be at least 8 characters</span>
}
</div>
}
</div>
<div class="field">
<label>Confirm Password</label>
<input
type="password"
formControlName="confirmPassword"
placeholder="Re-enter your password">
@if (registerForm.errors?.['passwordMismatch'] &&
registerForm.get('confirmPassword')?.touched) {
<div class="error">
<span>Passwords do not match</span>
</div>
}
</div>
<div class="field">
<label>Age</label>
<input
type="number"
formControlName="age"
placeholder="Must be 18 or older">
@if (registerForm.get('age')?.invalid &&
registerForm.get('age')?.touched) {
<div class="error">
@if (registerForm.get('age')?.errors?.['required']) {
<span>Age is required</span>
}
@if (registerForm.get('age')?.errors?.['min']) {
<span>You must be at least 18 years old</span>
}
@if (registerForm.get('age')?.errors?.['max']) {
<span>Please enter a valid age</span>
}
</div>
}
</div>
<div class="field">
<label>Role</label>
<select formControlName="role">
<option value="user">Regular User</option>
<option value="author">Author</option>
<option value="admin">Admin</option>
</select>
</div>
<button
type="submit"
[disabled]="registerForm.invalid">
Create Account
</button>
</form>
</div>
The key directives:
[formGroup]="registerForm" — binds the <form> element to your TypeScript FormGroup.
formControlName="fullName" — binds an input to the fullName FormControl inside the group. This is how the input and the form control stay connected. The value of formControlName must exactly match the key you used in fb.group({...}).
registerForm.get('fullName') — gets a reference to the fullName control so you can check its validity and errors.
registerForm.get('fullName')?.invalid — the ?. is optional chaining because get() can return null if the control name does not exist.
3.7 — Reading Form Values and Handling Submission
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule],
templateUrl: './register.html',
styleUrl: './register.css'
})
export class Register {
private fb = inject(FormBuilder);
registerForm = this.fb.group({
fullName: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
age: [null as number | null, [Validators.required, Validators.min(18)]],
role: ['user']
});
isSubmitted: boolean = false;
onSubmit(): void {
if (this.registerForm.invalid) {
// Mark all fields as touched so errors show
this.registerForm.markAllAsTouched();
return;
}
const formData = this.registerForm.value;
console.log('Submitted:', formData);
// formData.fullName, formData.email, formData.password, etc.
this.isSubmitted = true;
this.registerForm.reset();
}
}
this.registerForm.value — returns a plain object with all the current field values. { fullName: 'Rahul', email: 'rahul@example.com', ... }.
this.registerForm.markAllAsTouched() — marks every single field as touched. This is very useful when the user clicks submit without filling anything in — you want to show all errors at once. Without this, errors only show for fields the user has individually clicked into and out of.
this.registerForm.reset() — resets all fields to their initial values and clears touched/dirty state.
3.8 — Getting Individual Controls More Cleanly
Writing registerForm.get('fullName') everywhere is verbose. A common pattern is to create getter properties for each control:
export class Register {
registerForm = this.fb.group({
fullName: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
// Getter properties for easy template access
get fullName() { return this.registerForm.get('fullName')!; }
get email() { return this.registerForm.get('email')!; }
get password() { return this.registerForm.get('password')!; }
}
Now in the template you write fullName.invalid instead of registerForm.get('fullName')?.invalid. Much cleaner:
@if (fullName.invalid && fullName.touched) {
<div class="error">
@if (fullName.errors?.['required']) {
<span>Full name is required</span>
}
</div>
}
3.9 — Updating Form Values Programmatically
Sometimes you need to update form values from TypeScript — like pre-populating a form with data loaded from an API:
// setValue — sets ALL fields, must provide every field
this.registerForm.setValue({
fullName: 'Rahul Sharma',
email: 'rahul@example.com',
password: '',
confirmPassword: '',
age: 25,
role: 'user'
});
// patchValue — sets only the fields you provide, ignores the rest
this.registerForm.patchValue({
fullName: 'Rahul Sharma',
email: 'rahul@example.com'
// other fields unchanged
});
Use patchValue when you are pre-filling only some fields. Use setValue when you are filling the entire form.
Chapter 4 — Custom Validators
4.1 — Why Custom Validators?
Angular's built-in validators cover the basics — required, email, min, max, pattern. But real applications need much more specific validation:
A phone number must be exactly 10 digits and start with 6, 7, 8, or 9. A username cannot contain spaces. A confirm password field must match the password field. A date must be in the future. A price must be a multiple of 5.
None of these are covered by built-in validators. This is where custom validators come in.
4.2 — A Custom Validator Function
A validator is just a function. It receives an AbstractControl (which can be a FormControl, FormGroup, or FormArray) and returns either null (validation passed) or an error object (validation failed).
import { AbstractControl, ValidationErrors } from '@angular/forms';
// Custom validator: no spaces allowed
export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value as string;
if (!value) {
return null; // if empty, let 'required' handle it
}
const hasSpaces = value.includes(' ');
if (hasSpaces) {
return { noSpaces: true };
// The key 'noSpaces' is how you identify this error in the template
}
return null; // null means validation passed
}
The returned error object can contain any information you want. The key is the error name. The value is typically true or an object with more details:
// Simple — just flag the error
return { noSpaces: true };
// With details — useful for showing specific info in the error message
return { minLength: { required: 8, actual: value.length } };
4.3 — Using a Custom Validator
Apply it exactly like a built-in validator:
import { noSpacesValidator } from './validators/no-spaces.validator';
registerForm = this.fb.group({
username: ['', [
Validators.required,
Validators.minLength(3),
noSpacesValidator // ← just add it to the array
]]
});
And check for it in the template:
@if (username.errors?.['noSpaces']) {
<span>Username cannot contain spaces</span>
}
4.4 — A Validator with Configuration (Validator Factory)
If your validator needs configuration — like checking that a value is at least N characters but N is dynamic — you create a validator factory: a function that takes parameters and returns a validator function.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Validator factory — takes a blacklist and returns a validator
export function notInListValidator(blacklist: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = (control.value as string)?.toLowerCase();
if (!value) return null;
const isBlacklisted = blacklist.includes(value);
if (isBlacklisted) {
return { notInList: { value: control.value } };
}
return null;
};
}
Using it:
username: ['', [
Validators.required,
notInListValidator(['admin', 'root', 'system', 'test'])
]]
4.5 — Cross-Field Validation — The Password Match Validator
Cross-field validation is when you need to validate one field against another — the classic example being password and confirm password must match.
This validator goes on the FormGroup level, not on an individual control, because it needs to read two controls:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function passwordMatchValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
if (!password || !confirmPassword) {
return null;
}
if (password !== confirmPassword) {
return { passwordMismatch: true };
}
return null;
};
}
Apply it to the FormGroup, not an individual control:
registerForm = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
},
{ validators: passwordMatchValidator() } // ← second argument to group()
);
In the template, you check for this error on the form itself (not on a specific control) since it is a group-level error:
@if (registerForm.errors?.['passwordMismatch'] &&
registerForm.get('confirmPassword')?.touched) {
<div class="error">
<span>Passwords do not match</span>
</div>
}
Chapter 5 — FormArray — Dynamic Fields
5.1 — What is FormArray?
A FormArray is a list of form controls or form groups. You use it when the number of fields is not fixed — it changes at runtime based on user actions.
Examples: a skills list where the user can add any number of skills, an education history form where the user adds each degree, a checkout form where the user adds multiple delivery addresses.
5.2 — Building a Dynamic Skills Form
src/app/profile/profile.ts:
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormArray, Validators, AbstractControl } from '@angular/forms';
@Component({
selector: 'app-profile',
imports: [ReactiveFormsModule],
templateUrl: './profile.html',
styleUrl: './profile.css'
})
export class Profile {
private fb = inject(FormBuilder);
profileForm = this.fb.group({
name: ['', Validators.required],
bio: ['', [Validators.required, Validators.maxLength(200)]],
skills: this.fb.array([
this.fb.control('Angular', Validators.required),
this.fb.control('TypeScript', Validators.required)
])
});
// Getter for easy access to the skills array
get skills(): FormArray {
return this.profileForm.get('skills') as FormArray;
}
get skillControls(): AbstractControl[] {
return this.skills.controls;
}
addSkill(): void {
// Add a new empty control to the array
this.skills.push(this.fb.control('', Validators.required));
}
removeSkill(index: number): void {
// Remove the control at this index
this.skills.removeAt(index);
}
onSubmit(): void {
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
console.log('Profile:', this.profileForm.value);
}
}
src/app/profile/profile.html:
<div class="form-container">
<h1>Edit Profile</h1>
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()">
<div class="field">
<label>Name</label>
<input type="text" formControlName="name" placeholder="Your name">
@if (profileForm.get('name')?.invalid && profileForm.get('name')?.touched) {
<div class="error">Name is required</div>
}
</div>
<div class="field">
<label>Bio</label>
<textarea formControlName="bio" rows="3" placeholder="Tell us about yourself"></textarea>
<div class="char-count">
{{ profileForm.get('bio')?.value?.length || 0 }} / 200
</div>
@if (profileForm.get('bio')?.errors?.['maxlength']) {
<div class="error">Bio cannot exceed 200 characters</div>
}
</div>
<div class="field">
<label>Skills</label>
<div formArrayName="skills" class="skills-list">
@for (skill of skillControls; track $index; let i = $index) {
<div class="skill-row">
<input
type="text"
[formControlName]="i"
placeholder="e.g. Angular, TypeScript">
@if (skills.at(i).invalid && skills.at(i).touched) {
<span class="error">Skill name is required</span>
}
<button
type="button"
class="remove-btn"
(click)="removeSkill(i)"
[disabled]="skills.length <= 1">
✕
</button>
</div>
}
</div>
<button type="button" class="add-btn" (click)="addSkill()">
+ Add Skill
</button>
</div>
<button type="submit" [disabled]="profileForm.invalid">
Save Profile
</button>
</form>
</div>
Key things happening here:
formArrayName="skills" — connects the <div> wrapper to the skills FormArray in your form.
[formControlName]="i" — inside a formArrayName, you use the numeric index as the control name. i is the loop index.
this.skills.push(...) — adds a new control at the end of the array.
this.skills.removeAt(index) — removes the control at the given index.
this.skills.length — the number of controls currently in the array.
Chapter 6 — Async Validators
6.1 — What Are Async Validators?
Some validation requires checking with the server. "Is this username already taken?" "Does this email already have an account?" You cannot do this check synchronously because it requires an HTTP request.
Async validators return a Promise or Observable instead of returning the result directly. Angular waits for the async operation to complete before deciding if the field is valid.
6.2 — Building an Async Username Validator
import { inject } from '@angular/core';
import { AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';
// Simulated list of taken usernames
const takenUsernames = ['rahul', 'priya', 'admin', 'user123'];
export function usernameAvailableValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const value = control.value as string;
if (!value || value.length < 3) {
return of(null); // 'of' creates an Observable that immediately emits a value
}
// timer(500) introduces a 500ms debounce
// This prevents checking on every single keystroke
return timer(500).pipe(
switchMap(() => {
// Simulating an HTTP call — in real app this would be an actual API call
const isTaken = takenUsernames.includes(value.toLowerCase());
if (isTaken) {
return of({ usernameTaken: true });
}
return of(null);
}),
catchError(() => of(null)) // if the server errors, don't block the form
);
};
}
Using it in the form — async validators go as the THIRD argument to FormControl or fb.control(), after sync validators:
import { usernameAvailableValidator } from './validators/username-available.validator';
registerForm = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3)], // sync validators — 2nd arg
[usernameAvailableValidator()] // async validators — 3rd arg
]
});
In the template, Angular provides a pending state while the async validator is running — use it to show a loading indicator:
<div class="field">
<label>Username</label>
<div class="input-with-status">
<input type="text" formControlName="username" placeholder="Choose a username">
@if (registerForm.get('username')?.pending) {
<span class="checking">Checking...</span>
}
@if (registerForm.get('username')?.valid && !registerForm.get('username')?.pending) {
<span class="available">✓ Available</span>
}
</div>
@if (registerForm.get('username')?.errors?.['required'] &&
registerForm.get('username')?.touched) {
<div class="error">Username is required</div>
}
@if (registerForm.get('username')?.errors?.['minlength']) {
<div class="error">Username must be at least 3 characters</div>
}
@if (registerForm.get('username')?.errors?.['usernameTaken']) {
<div class="error">This username is already taken. Please choose another.</div>
}
</div>
pending is true while any async validator on that control is still running. Use it to show a "Checking..." spinner.
Chapter 7 — Listening to Form Changes
7.1 — valueChanges
Reactive forms give you an Observable called valueChanges that emits a new value every time the form's value changes. You can subscribe to it to react to any change:
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-search',
imports: [ReactiveFormsModule],
templateUrl: './search.html',
styleUrl: './search.css'
})
export class Search implements OnInit, OnDestroy {
private fb = inject(FormBuilder);
private subscription?: Subscription;
searchForm = this.fb.group({
query: [''],
category: ['all']
});
searchResults: string[] = [];
ngOnInit(): void {
// Listen to the entire form changing
this.subscription = this.searchForm.valueChanges
.pipe(
debounceTime(300), // wait 300ms after user stops typing
distinctUntilChanged() // only emit if value actually changed
)
.subscribe(value => {
console.log('Form value changed:', value);
this.performSearch(value.query || '', value.category || 'all');
});
}
// You can also listen to a single control
ngOnInit2(): void {
this.searchForm.get('query')?.valueChanges
.pipe(debounceTime(300))
.subscribe(query => {
console.log('Query changed:', query);
});
}
performSearch(query: string, category: string): void {
// Simulate search results
if (query.length > 0) {
this.searchResults = [
`Result 1 for "${query}" in ${category}`,
`Result 2 for "${query}" in ${category}`,
`Result 3 for "${query}" in ${category}`
];
} else {
this.searchResults = [];
}
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
}
This is the real-time search pattern. Every time the user types in the search box, valueChanges fires. debounceTime(300) waits until the user pauses typing for 300ms before running the search — preventing an API call on every single keystroke. distinctUntilChanged() prevents running the search if the value did not actually change.
7.2 — statusChanges
statusChanges emits whenever the form's validation status changes — from VALID to INVALID, INVALID to VALID, or PENDING while async validators run:
this.registerForm.statusChanges.subscribe(status => {
console.log('Form status:', status);
// status is 'VALID', 'INVALID', or 'PENDING'
});
Chapter 8 — A Complete Real-World Form Project
Let's build a complete User Registration and Profile system that demonstrates everything from this phase.
Setup
ng new forms-app --style=css
cd forms-app
ng g c pages/register --skip-tests
ng g c pages/login --skip-tests
ng g c pages/profile --skip-tests
Custom Validators File
src/app/validators/validators.ts:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) return null;
return (control.value as string).includes(' ')
? { noSpaces: true }
: null;
}
export function passwordMatchValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
if (!password || !confirmPassword) return null;
return password !== confirmPassword ? { passwordMismatch: true } : null;
};
}
export function indianPhoneValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) return null;
const phone = control.value.toString();
const isValid = /^[6-9][0-9]{9}$/.test(phone);
return isValid ? null : { invalidPhone: true };
}
Registration Form
src/app/pages/register/register.ts:
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import {
noSpacesValidator,
passwordMatchValidator,
indianPhoneValidator
} from '../../validators/validators';
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule],
templateUrl: './register.html',
styleUrl: './register.css'
})
export class Register {
private fb = inject(FormBuilder);
private router = inject(Router);
isSubmitting = signal(false);
submitSuccess = signal(false);
form = this.fb.group(
{
fullName: ['', [Validators.required, Validators.minLength(2)]],
username: ['', [Validators.required, Validators.minLength(3), noSpacesValidator]],
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.required, indianPhoneValidator]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
role: ['user', Validators.required],
agreeToTerms: [false, Validators.requiredTrue]
},
{ validators: passwordMatchValidator() }
);
get fullName() { return this.form.get('fullName')!; }
get username() { return this.form.get('username')!; }
get email() { return this.form.get('email')!; }
get phone() { return this.form.get('phone')!; }
get password() { return this.form.get('password')!; }
get confirmPassword() { return this.form.get('confirmPassword')!; }
get agreeToTerms() { return this.form.get('agreeToTerms')!; }
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isSubmitting.set(true);
// Simulate API call
setTimeout(() => {
console.log('Registration data:', this.form.value);
this.isSubmitting.set(false);
this.submitSuccess.set(true);
}, 1500);
}
goToLogin(): void {
this.router.navigate(['/login']);
}
}
src/app/pages/register/register.html:
<div class="page">
<div class="form-card">
@if (submitSuccess()) {
<div class="success">
<div class="success-icon">✓</div>
<h2>Account Created!</h2>
<p>Welcome aboard. You can now log in.</p>
<button (click)="goToLogin()">Go to Login</button>
</div>
} @else {
<h1>Create Account</h1>
<p class="subtitle">Join us today — it's free</p>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-row">
<div class="field">
<label>Full Name</label>
<input type="text" formControlName="fullName" placeholder="Rahul Sharma">
@if (fullName.invalid && fullName.touched) {
<div class="error">
@if (fullName.errors?.['required']) { <span>Full name is required</span> }
@if (fullName.errors?.['minlength']) { <span>At least 2 characters required</span> }
</div>
}
</div>
<div class="field">
<label>Username</label>
<input type="text" formControlName="username" placeholder="rahul_dev">
@if (username.invalid && username.touched) {
<div class="error">
@if (username.errors?.['required']) { <span>Username is required</span> }
@if (username.errors?.['minlength']) { <span>At least 3 characters required</span> }
@if (username.errors?.['noSpaces']) { <span>Username cannot contain spaces</span> }
</div>
}
</div>
</div>
<div class="form-row">
<div class="field">
<label>Email Address</label>
<input type="email" formControlName="email" placeholder="rahul@example.com">
@if (email.invalid && email.touched) {
<div class="error">
@if (email.errors?.['required']) { <span>Email is required</span> }
@if (email.errors?.['email']) { <span>Enter a valid email address</span> }
</div>
}
</div>
<div class="field">
<label>Phone Number</label>
<input type="tel" formControlName="phone" placeholder="9876543210">
@if (phone.invalid && phone.touched) {
<div class="error">
@if (phone.errors?.['required']) { <span>Phone is required</span> }
@if (phone.errors?.['invalidPhone']) { <span>Enter a valid 10-digit Indian phone number</span> }
</div>
}
</div>
</div>
<div class="form-row">
<div class="field">
<label>Password</label>
<input type="password" formControlName="password" placeholder="Min. 8 characters">
@if (password.invalid && password.touched) {
<div class="error">
@if (password.errors?.['required']) { <span>Password is required</span> }
@if (password.errors?.['minlength']) { <span>At least 8 characters required</span> }
</div>
}
</div>
<div class="field">
<label>Confirm Password</label>
<input type="password" formControlName="confirmPassword" placeholder="Re-enter password">
@if (form.errors?.['passwordMismatch'] && confirmPassword.touched) {
<div class="error"><span>Passwords do not match</span></div>
}
</div>
</div>
<div class="field">
<label>Account Type</label>
<select formControlName="role">
<option value="user">Regular User</option>
<option value="author">Author</option>
<option value="admin">Administrator</option>
</select>
</div>
<div class="field checkbox-field">
<label class="checkbox-label">
<input type="checkbox" formControlName="agreeToTerms">
I agree to the Terms of Service and Privacy Policy
</label>
@if (agreeToTerms.invalid && agreeToTerms.touched) {
<div class="error">You must agree to the terms to continue</div>
}
</div>
<button
type="submit"
[disabled]="form.invalid || isSubmitting()">
{{ isSubmitting() ? 'Creating Account...' : 'Create Account' }}
</button>
<p class="login-link">
Already have an account?
<a (click)="goToLogin()" style="cursor:pointer; color:#0070f3">Sign in</a>
</p>
</form>
}
</div>
</div>
src/app/pages/register/register.css:
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
padding: 40px 24px;
}
.form-card {
background: white;
padding: 40px;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
width: 100%;
max-width: 680px;
}
h1 {
font-size: 28px;
font-weight: 800;
color: #1a1a2e;
margin-bottom: 6px;
}
.subtitle {
color: #888;
font-size: 15px;
margin-bottom: 32px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.field {
margin-bottom: 20px;
}
label {
display: block;
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 7px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
select {
width: 100%;
padding: 11px 14px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
transition: border-color 0.2s;
box-sizing: border-box;
font-family: inherit;
color: #333;
}
input:focus, select:focus {
outline: none;
border-color: #0070f3;
box-shadow: 0 0 0 3px rgba(0,112,243,0.08);
}
input.ng-invalid.ng-touched {
border-color: #dc3545;
}
input.ng-valid.ng-dirty {
border-color: #28a745;
}
.error {
margin-top: 5px;
font-size: 12px;
color: #dc3545;
}
.checkbox-field { margin-top: 8px; }
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 14px;
color: #555;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin-top: 2px;
flex-shrink: 0;
}
button[type="submit"] {
width: 100%;
padding: 14px;
background: #0070f3;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s;
}
button[type="submit"]:hover { background: #005ac1; }
button[type="submit"]:disabled { background: #ccc; cursor: not-allowed; }
.login-link {
text-align: center;
margin-top: 20px;
font-size: 14px;
color: #888;
}
.success {
text-align: center;
padding: 40px 0;
}
.success-icon {
width: 72px;
height: 72px;
background: #28a745;
color: white;
border-radius: 50%;
font-size: 36px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.success h2 {
font-size: 26px;
color: #1a1a2e;
margin-bottom: 8px;
}
.success p {
color: #666;
margin-bottom: 24px;
}
.success button {
background: #0070f3;
color: white;
border: none;
padding: 12px 28px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
App Routes
src/app/app.routes.ts:
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'register',
pathMatch: 'full'
},
{
path: 'register',
loadComponent: () => import('./pages/register/register').then(m => m.Register)
}
];
src/app/app.ts:
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`
})
export class App { }
src/styles.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
Run ng serve -o. You have a complete registration form with:
Required field validation on every field. Email format validation. Minimum length on name, username, and password. Custom no-spaces validator on username. Custom Indian phone number validator using regex. Password match cross-field validator on the group level. Terms agreement with requiredTrue. All errors show only after the user has touched a field. markAllAsTouched() reveals all errors when submit is clicked on an incomplete form. Loading state during fake submission. Success state after submission.
Phase 7 — Complete Summary
Here is everything you learned in this phase.
Two approaches to forms — Template-Driven for simple forms, logic in the HTML. Reactive Forms for complex forms, logic in TypeScript. Both are valid but Reactive Forms are more powerful.
Template-Driven Forms — FormsModule required. #form="ngForm" on the <form> element. name attribute required on every input. [(ngModel)] for two-way binding. #field="ngModel" for field-level state. Angular CSS classes — ng-valid, ng-invalid, ng-touched, ng-dirty. form.reset() to clear the form.
Reactive Forms — ReactiveFormsModule required. FormControl, FormGroup, FormArray are the three building blocks. FormBuilder provides cleaner syntax. [formGroup] on the form element. formControlName on inputs. form.get('field') to access a control. form.value to get all values. markAllAsTouched() to trigger all error messages.
Built-in validators — required, requiredTrue, email, minLength, maxLength, min, max, pattern — all available on Validators.
Custom validators — A function that takes AbstractControl and returns null or an error object. Apply them in the validators array exactly like built-in ones. Validator factories take configuration parameters and return a validator function. Cross-field validators go on the FormGroup level.
FormArray — A list of FormControl or FormGroup instances. Add with .push(). Remove with .removeAt(index). Access controls with .controls. Connect in template with formArrayName and index-based formControlName.
Async validators — Return a Promise or Observable instead of a value directly. Go as the third argument to FormControl. pending state is true while running. Good for checking username/email availability against an API.
valueChanges and statusChanges — Observables on forms and controls. valueChanges emits on every change. statusChanges emits when validity changes. Use debounceTime and distinctUntilChanged for search-as-you-type patterns. Always unsubscribe in ngOnDestroy.
What's Next — Phase 8
In Phase 8 we cover HTTP Client and APIs — connecting your Angular app to a real backend:
Setting up HttpClient for making API calls. GET, POST, PUT, PATCH, DELETE requests with typed responses. Handling loading states and error responses. HTTP interceptors — adding auth tokens to every request, global error handling, loading indicators. Environment variables for switching between development and production API URLs. Best practices for organizing API calls in services.
No comments:
Post a Comment