import { CanDeactivateFn } from '@angular/router';
import { Observable } from 'rxjs';
export interface HasUnsavedChanges {
canDeactivate(): boolean | Observable<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (
component
) => {
if (!component || typeof component.canDeactivate !== 'function') {
return true;
}
return component.canDeactivate();
};
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ConfirmDialogService {
confirmLeave(message: string): Observable<boolean> {
// Swap this for a MatDialog / custom modal that emits the user's choice.
const result = window.confirm(message);
return of(result);
}
}
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { HasUnsavedChanges } from './unsaved-changes.guard';
import { ConfirmDialogService } from './confirm-dialog.service';
import { ProfileService } from './profile.service';
@Component({
selector: 'app-profile-edit',
templateUrl: './profile-edit.component.html',
})
export class ProfileEditComponent implements OnInit, HasUnsavedChanges {
private readonly fb = inject(FormBuilder);
private readonly profiles = inject(ProfileService);
private readonly confirm = inject(ConfirmDialogService);
form!: FormGroup;
saving = false;
ngOnInit(): void {
this.form = this.fb.group({
displayName: ['', [Validators.required, Validators.maxLength(80)]],
bio: ['', Validators.maxLength(500)],
});
this.profiles.load().subscribe((profile) => {
this.form.patchValue(profile);
this.form.markAsPristine();
});
}
save(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.saving = true;
this.profiles
.update(this.form.value)
.pipe(finalize(() => (this.saving = false)))
.subscribe(() => this.form.markAsPristine());
}
canDeactivate(): boolean | Observable<boolean> {
if (!this.form.dirty || this.saving === false && this.form.pristine) {
return true;
}
if (!this.form.dirty) {
return true;
}
return this.confirm.confirmLeave(
'You have unsaved changes. Leave without saving?'
);
}
}
This snippet shows the canonical Angular pattern for preventing accidental navigation away from an edit form that has unsaved changes. Angular's CanDeactivate route guard is functional-first in modern versions, so the guard delegates the decision to the component itself rather than hard-coding form knowledge into routing config. The result is a guard that is completely generic and reusable across any editing screen.
In unsaved-changes.guard.ts, the guard is typed against a small HasUnsavedChanges interface that exposes a single canDeactivate() method. Because the guard is written with CanDeactivateFn, it is a plain function that Angular's dependency injection can call with the deactivating component instance. The guard simply returns component.canDeactivate(), which may be a boolean or an Observable<boolean>. Pushing the logic into the component keeps the guard from needing to know about form state, confirm dialogs, or async save flows — it just asks the component whether it is safe to leave.
In confirm-dialog.service.ts, a thin service wraps whatever confirmation mechanism the app uses. Here it returns an Observable<boolean> so the guard can suspend navigation until the user answers. Modeling the prompt as an observable rather than a synchronous window.confirm is what allows the pattern to scale to Material dialogs or custom modals without touching the guard.
In profile-edit.component.ts, the component implements HasUnsavedChanges. Its canDeactivate() first checks a saved flag and the reactive form's dirty state; if nothing is at risk it returns true immediately so navigation is instant. Only when the form is dirty does it defer to ConfirmDialogService, returning the observable that resolves to the user's choice. Marking the form pristine after a successful save via markAsPristine() ensures a saved form no longer triggers the prompt.
The key trade-off is that the guard trusts each component to implement canDeactivate() honestly, so the interface contract matters. A common pitfall is forgetting to reset dirty state after saving, which produces a spurious warning. This approach is worth reaching for on any form where losing edits is costly, and it composes cleanly with lazy-loaded routes.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.