Elegant Navigation Guards in Angular - Part 1: Preventing Unsaved Changes
Losing progress in a multi-step form is one of the most frustrating user experiences on the web. A simple accidental click of the back button or a navigation link can wipe away minutes of input.
In Angular, preventing this is traditionally the job of a CanDeactivate guard. In this two-part series, we will build a modern, flexible, and decoupled unsaved changes guard in Angular using functional route guards and dependency injection (DI) providers.
In this first part, we will focus on the core implementation: creating the component contract, writing the functional guard, and setting up a reactive form that prevents users from navigating away when they have unsaved changes.
Step 1: Defining the Component Contract
Instead of writing a guard that checks a specific component, we want our guard to be completely reusable. Any component in our application that has forms should be able to hook into it.
To achieve this, we define a TypeScript interface. Let’s create a file named pending-changes.ts:
import { Observable } from 'rxjs';
// The contract for components that can contain unsaved changes
export interface PendingChanges {
isDirty(): boolean | Observable<boolean> | Promise<boolean>;
}
By defining the return type as a union of boolean, Observable<boolean>, and Promise<boolean>, we ensure our guard can handle both synchronous checks (like checking local form state) and asynchronous checks (like asking a remote server or checking a state store).
Step 2: Creating the Functional Route Guard
Modern Angular (Angular 14.2+) standardizes on functional route guards. Instead of writing verbose class-based guards with boilerplate constructors, we can write a simple CanDeactivateFn function.
Let’s write our guard in pending-changes.ts:
import { CanDeactivateFn } from '@angular/router';
export const pendingChangesGuard: CanDeactivateFn<PendingChanges> = (component) => {
// 1. If the component doesn't exist or isn't dirty, allow navigation immediately
if (!component || !component.isDirty()) {
return true;
}
// 2. Fallback warning using the browser's native confirm alert
return confirm('You have unsaved changes. Do you really want to leave?');
};
Step 3: Implementing the Contract in a Component
Now let’s build an edit profile component using Angular’s Reactive Forms. The component will implement PendingChanges by checking if the form has been modified (dirty).
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { PendingChanges } from './pending-changes';
@Component({
selector: 'app-edit-profile',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="profileForm" (ngSubmit)="save()">
<label for="name">Name</label>
<input id="name" formControlName="name" />
<label for="email">Email</label>
<input id="email" formControlName="email" />
<button type="submit" [disabled]="profileForm.invalid">Save Changes</button>
</form>
`
})
export class EditProfileComponent implements PendingChanges {
profileForm: FormGroup;
constructor(private fb: FormBuilder) {
this.profileForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]]
});
}
// Implement the PendingChanges contract
isDirty(): boolean {
return this.profileForm.dirty;
}
save() {
if (this.profileForm.valid) {
// Handle HTTP save logic here...
// Reset form state to pristine so guard allows navigation
this.profileForm.markAsPristine();
}
}
}
Step 4: Registering the Guard in Routes
Finally, we apply our guard to the route definition inside app.routes.ts:
import { Routes } from '@angular/router';
import { EditProfileComponent } from './edit-profile.component';
import { pendingChangesGuard } from './pending-changes';
export const routes: Routes = [
{
path: 'edit-profile',
component: EditProfileComponent,
canDeactivate: [pendingChangesGuard]
}
];
Summary & What’s Next
We now have a working, reusable guard that checks if a form is modified using Angular’s Reactive Forms and native browser confirmations.
However, hardcoding confirm() directly inside a guard creates issues:
- It is hard to unit test (test runs will hang on the native modal).
- It looks dated and doesn’t match modern UI/UX design systems.
In Part 2, we will refactor this setup to use Dependency Injection (DI) Providers and the modern makeEnvironmentProviders method, allowing us to swap the native prompt with customized UI dialogs (like Angular Material or Tailwind modals) cleanly.
