Angular Forms Explained — Template-Driven & Reactive Step-by-Step
Angular forms let you capture, validate, and process user input in web applications. This step-by-step guide covers both template-driven and reactive approaches so you can choose the right one for any project.
What You’ll Learn
- Build template-driven forms with
ngModeland two-way binding - Create reactive forms with
FormBuilderandFormGroup - Add built-in and custom validators
- Track form state (touched, dirty, valid, pending)
- Handle dynamic form controls with
FormArray - Choose between template-driven and reactive for your use case
Why Forms Matter
Every application collects user input: login screens, registration forms, checkout flows, and settings pages. Durga Antivirus Pro uses reactive forms for its subscription management dashboard, validating credit card details, license keys, and user preferences in real time. Poor form handling leads to data loss, security vulnerabilities, and frustrated users. Angular gives you the tools to build forms that are both user-friendly and robust.
flowchart LR
A["Angular Basics"] --> B["**Angular Forms**"]
B --> C["Angular Routing & HTTP"]
style B fill:#f97316,stroke:#c2410c,color:#fff
Two Approaches — Which One Should You Use?
Angular gives you two ways to build forms. Think of them as a bicycle vs. a car:
| Aspect | Template-Driven | Reactive |
|---|---|---|
| Complexity | Simple forms | Complex, dynamic forms |
| Setup | Minimal (add FormsModule) | More setup (ReactiveFormsModule) |
| Validation | HTML5 attributes + directives | Code-based validators |
| Testability | Harder to unit test | Easy to unit test |
| Dynamic controls | Difficult | Built-in (FormArray) |
| Best for | Login, contact, search | Registration, checkout, dashboards |
Rule of thumb: If your form has more than 5 fields, conditional logic, or cross-field validation, use reactive forms.
flowchart TD
A[Angular Forms] --> B[Template-Driven]
A --> C[Reactive Forms]
B --> D[FormsModule]
B --> E[ngModel]
B --> F[Easy to start]
C --> G[ReactiveFormsModule]
C --> H[FormBuilder / FormGroup]
C --> I[Scalable & testable]
Template-Driven Forms — The Simple Approach
Template-driven forms are called that because most of the logic lives in the HTML template rather than the component class.
Step 1: Import FormsModule
import { FormsModule } from "@angular/forms";
// In your standalone component
@Component({
standalone: true,
imports: [FormsModule], // Required for ngModel and ngForm
})Step 2: Build the Template
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
<div>
<label>Email</label>
<input
type="email"
name="email"
ngModel
required
email
#email="ngModel"
/>
@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</span> }
</div>
}
</div>
<div>
<label>Password</label>
<input
type="password"
name="password"
ngModel
required
minlength="6"
#password="ngModel"
/>
@if (password.invalid && password.touched) {
<div class="error">
@if (password.errors?.['required']) { <span>Password is required</span> }
@if (password.errors?.['minlength']) {
<span>Min {{ password.errors?.['minlength'].requiredLength }} characters</span>
}
</div>
}
</div>
<button type="submit" [disabled]="loginForm.invalid">Login</button>
</form>Let’s break down what each part does:
#loginForm="ngForm"— Creates a template reference variable.ngFormis the directive that Angular applies automatically to<form>elements. The#loginFormlets you access form state (likeloginForm.invalid).ngModel— Without brackets or parentheses,ngModelin its bare form registers the input with the parentngForm.name="email"— Required. This is howngFormidentifies each control.#email="ngModel"— Creates a reference to the individual control, giving you access toemail.invalid,email.touched,email.errors.requiredandemail— HTML5 validation attributes that Angular’sngModelrecognizes as validators.@if (email.invalid && email.touched)— Only show validation errors after the user has interacted with and left the field.
Step 3: Handle Submission
import { Component } from "@angular/core";
import { FormsModule, NgForm } from "@angular/forms";
@Component({
selector: "app-login",
standalone: true,
imports: [FormsModule],
templateUrl: "./login.component.html"
})
export class LoginComponent {
onSubmit(form: NgForm) {
if (form.valid) {
console.log("Form submitted:", form.value);
// Output: { email: "user@example.com", password: "secret123" }
form.reset(); // Clear form and reset validation state
}
}
}You might be wondering: “Why do I need
NgFormtype annotation?” It gives you TypeScript autocompletion and type checking for form properties likevalid,value, and methods likereset().
Reactive Forms — The Scalable Approach
Reactive forms are more explicit and testable. Instead of relying on template directives, you build the form structure in your component class using FormBuilder and FormGroup.
Step 1: Import ReactiveFormsModule
import { ReactiveFormsModule } from "@angular/forms";
@Component({
standalone: true,
imports: [ReactiveFormsModule],
})Step 2: Build the Form in the Component
import { Component, OnInit } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from "@angular/forms";
@Component({
selector: "app-register",
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<div>
<label>Name</label>
<input formControlName="name" />
@if (name?.invalid && name?.touched) {
<span class="error">Name is required</span>
}
</div>
<div>
<label>Email</label>
<input formControlName="email" type="email" />
</div>
<div formGroupName="passwords">
<input formControlName="password" type="password" placeholder="Password" />
<input formControlName="confirm" type="password" placeholder="Confirm" />
@if (registerForm.errors?.['mismatch'] && passwords?.touched) {
<span class="error">Passwords don't match</span>
}
</div>
<button type="submit" [disabled]="registerForm.invalid">Register</button>
</form>
<pre>{{ registerForm.value | json }}</pre>
`
})
export class RegisterComponent implements OnInit {
registerForm!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.registerForm = this.fb.group({
name: ["", [Validators.required, Validators.minLength(2)]],
email: ["", [Validators.required, Validators.email]],
passwords: this.fb.group({
password: ["", [Validators.required, Validators.minLength(6)]],
confirm: ["", [Validators.required]]
}, { validators: this.passwordMatchValidator })
});
}
passwordMatchValidator(group: FormGroup) {
const pwd = group.get("password")?.value;
const confirm = group.get("confirm")?.value;
return pwd === confirm ? null : { mismatch: true };
}
get name() { return this.registerForm.get("name"); }
get email() { return this.registerForm.get("email"); }
get passwords() { return this.registerForm.get("passwords"); }
onSubmit() {
if (this.registerForm.valid) {
console.log("Registered:", this.registerForm.value);
}
}
}Here’s what each piece does:
FormBuilder— A service that helps create form controls with less boilerplate.this.fb.group({...})creates aFormGroup.formControlName="name"— Links the input to a specific control in theFormGroup.formGroupName="passwords"— Creates a nested group within the form for related fields.[formGroup]="registerForm"— Binds the entire form to the component’sFormGroup.Validators.required— A built-in validator function. You pass an array of validators to each control.
Cross-Field Validation
Notice how the password match validator is applied to the group level, not individual controls. That’s because password and confirm are two different controls — you need to check their relationship, not their individual values.
passwordMatchValidator(group: FormGroup) {
const pwd = group.get("password")?.value;
const confirm = group.get("confirm")?.value;
return pwd === confirm ? null : { mismatch: true };
}Built-in Validators Reference
import { Validators } from "@angular/forms";
Validators.required // Field must have a value
Validators.requiredTrue // Checkbox must be checked
Validators.email // Must be a valid email format
Validators.minLength(6) // Min string length
Validators.maxLength(100) // Max string length
Validators.min(18) // Minimum numeric value
Validators.max(120) // Maximum numeric value
Validators.pattern(/^[a-z]+$/) // Regex pattern match
Custom Validators
When built-in validators aren’t enough, create your own:
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
// A reusable validator factory
export function forbiddenNameValidator(names: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = names.find(n =>
control.value?.toLowerCase().includes(n.toLowerCase())
);
return forbidden ? { forbiddenName: { value: control.value } } : null;
};
}
// Usage in a form
this.form = this.fb.group({
username: ["", [Validators.required, forbiddenNameValidator(["admin", "root"])]]
});A custom validator is simply a function that takes an AbstractControl and returns either null (valid) or an error object (invalid). The error object’s keys become the error names you check in the template.
Form State Properties
Every form control exposes state properties that help you show the right UI:
<input formControlName="name" />
<p>Touched: {{ name?.touched }}</p> <!-- Field was focused & blurred -->
<p>Dirty: {{ name?.dirty }}</p> <!-- User changed the value -->
<p>Pristine: {{ name?.pristine }}</p> <!-- Not yet modified -->
<p>Valid: {{ name?.valid }}</p> <!-- No validation errors -->
<p>Invalid: {{ name?.invalid }}</p> <!-- Has validation errors -->
<p>Pending: {{ name?.pending }}</p> <!-- Async validation in progress -->
<p>Errors: {{ name?.errors | json }}</p> <!-- All validation errors -->| State | Meaning |
|---|---|
touched | User focused on the field and then left it (blur event) |
dirty | User has changed the value |
pristine | Opposite of dirty — untouched value |
valid | All validators pass |
invalid | At least one validator fails |
pending | Async validation is still running |
Dynamic Forms with FormArray
What if you need a form where users can add or remove items dynamically — like a list of hobbies or multiple addresses? That’s what FormArray is for:
import { FormArray } from "@angular/forms";
// In your component
get hobbies() {
return this.form.get("hobbies") as FormArray;
}
addHobby() {
this.hobbies.push(this.fb.control("", Validators.required));
}
removeHobby(index: number) {
this.hobbies.removeAt(index);
}
ngOnInit() {
this.form = this.fb.group({
hobbies: this.fb.array([this.fb.control("")]) // Start with one
});
}<div formArrayName="hobbies">
@for (hobby of hobbies.controls; track hobby; let i = $index) {
<div>
<input [formControlName]="i" placeholder="Hobby {{ i + 1 }}" />
<button type="button" (click)="removeHobby(i)">✕</button>
</div>
}
</div>
<button type="button" (click)="addHobby()">+ Add Hobby</button>FormArray is like an array of form controls. You can push new controls, remove them, and reorder them. This is perfect for any “add another” pattern in your UI.
Common Mistakes
1. Forgetting to import FormsModule or ReactiveFormsModule
Without the right module, ngModel, formGroup, and formControlName won’t work, and Angular won’t throw a useful error — the inputs will just appear as regular HTML elements.
2. Mixing template-driven and reactive in the same form
Choose one approach per form. Don’t use ngModel alongside formControlName on the same form — they conflict.
3. Not resetting form state after submit
After successful submission, call form.reset(). This clears values and resets touched, dirty, and pristine states so errors don’t reappear.
4. Missing cross-field validation for password confirmation
Individual field validators can’t check relationships between fields. Always use a FormGroup-level validator for this.
5. Not using FormBuilder for complex forms
Manually creating new FormControl(), new FormGroup() instances is verbose and error-prone. FormBuilder.group() provides a cleaner, more readable API.
6. Forgetting async validators return Promises or Observables
Async validators must return Promise<ValidationErrors | null> or Observable<ValidationErrors | null>. A regular function won’t work.
Practice Questions
What is the key difference between template-driven and reactive forms? Template-driven forms put logic in the HTML template using directives like
ngModel. Reactive forms build the form structure in the component class usingFormBuilder,FormGroup, andFormControl.How do you add a custom validator to a FormControl? Create a function that matches the
ValidatorFnsignature (takesAbstractControl, returnsValidationErrors | null), then pass it in the validators array:['value', [Validators.required, myValidator]].What is
FormArrayused for? Managing a dynamic collection of controls — adding, removing, or reordering them at runtime. Common use cases: lists of phone numbers, multiple addresses, or skill tags.What does
form.reset()do? Clears all form values back to their initial state and resets thetouched,dirty, andpristineflags. It does NOT clear validators.When should you validate at the FormGroup level rather than FormControl level? When validation depends on a relationship between two or more fields, like password confirmation or “end date must be after start date.”
Challenge
Build a reactive survey form with: a required name, an email with format validation, a dynamic list of answers (FormArray), and a rating from 1-10. Add a custom validator that ensures at least one answer is provided.
FAQ
Try It Yourself
Create a reactive registration form with:
- Full name (required, min 2 characters)
- Email (required, valid email)
- Password and confirm password (must match)
- Terms checkbox (must be checked)
- Display the submitted data below the form
// app.component.ts — start with this
import { Component } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from "@angular/forms";
@Component({
selector: "app-root",
standalone: true,
imports: [ReactiveFormsModule],
template: `
<h2>Create Account</h2>
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<!-- Add your form controls here -->
</form>
@if (submitted) {
<pre>{{ signupForm.value | json }}</pre>
}
`
})
export class AppComponent {
signupForm: FormGroup;
submitted = false;
constructor(private fb: FormBuilder) {
// Build your form here
}
onSubmit() {
if (this.signupForm.valid) {
this.submitted = true;
}
}
}What’s Next
| Tutorial | Description |
|---|---|
| https://tutorials.dodatech.com/frameworks/angular/angular-routing/ | Add navigation and multi-page views |
| https://tutorials.dodatech.com/frameworks/angular/angular-http/ | Connect forms to backend APIs with HTTP Client |
| https://tutorials.dodatech.com/frameworks/angular/cli/ | Speed up development with Angular CLI commands |
Related topics: TypeScript, HTML, REST API.
What’s Next
Congratulations on completing this Angular Forms tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro