|

Elegante Navigation Guards in Angular - Teil 1: Verhindern ungespeicherter Änderungen

Der Verlust von Fortschritten in einem mehrstufigen Formular ist eine der frustrierendsten Benutzererfahrungen im Web. Ein einfacher, versehentlicher Klick auf die Zurück-Schaltfläche oder einen Navigationslink kann die Arbeit von Minuten zunichte machen.

In Angular ist die Verhinderung dieses Szenarios traditionell die Aufgabe eines CanDeactivate-Guards. In dieser zweiteiligen Serie werden wir einen modernen, flexiblen und entkoppelten Guard für ungespeicherte Änderungen (Unsaved Changes Guard) in Angular unter Verwendung von funktionalen Route-Guards und Dependency Injection (DI) Providern erstellen.

In diesem ersten Teil konzentrieren wir uns auf die Kernimplementierung: Wir erstellen den Komponentenvertrag, schreiben den funktionalen Guard und richten ein reaktives Formular ein, das verhindert, dass Benutzer die Seite verlassen, wenn sie ungespeicherte Änderungen vorgenommen haben.


Schritt 1: Definieren des Komponentenvertrags

Anstatt einen Guard zu schreiben, der eine ganz bestimmte Komponente prüft, soll unser Guard vollständig wiederverwendbar sein. Jede Komponente in unserer Anwendung, die Formulare enthält, sollte sich daran andocken können.

Um dies zu erreichen, definieren wir ein TypeScript-Interface. Erstellen wir eine Datei namens pending-changes.ts:

import { Observable } from 'rxjs';

// Der Vertrag für Komponenten, die ungespeicherte Änderungen enthalten können
export interface PendingChanges {
  isDirty(): boolean | Observable<boolean> | Promise<boolean>;
}

Indem wir den Rückgabetyp als Union aus boolean, Observable<boolean> und Promise<boolean> definieren, stellen wir sicher, dass unser Guard sowohl synchrone Prüfungen (wie das Prüfen des lokalen Formularstatus) als auch asynchrone Prüfungen (wie eine Serverabfrage oder das Prüfen eines State-Stores) verarbeiten kann.


Schritt 2: Erstellen des funktionalen Route-Guards

Das moderne Angular (seit Angular 14.2+) standardisiert funktionale Route-Guards. Anstatt ausführliche klassenbasierte Guards mit viel Boilerplate-Code zu schreiben, können wir eine einfache CanDeactivateFn-Funktion definieren.

Schreiben wir unseren Guard in pending-changes.ts:

import { CanDeactivateFn } from '@angular/router';

export const pendingChangesGuard: CanDeactivateFn<PendingChanges> = (component) => {
  // 1. Wenn die Komponente nicht existiert oder nicht "dirty" (geändert) ist, Navigation sofort erlauben
  if (!component || !component.isDirty()) {
    return true;
  }

  // 2. Fallback-Warnung über das native Bestätigungsfenster des Browsers
  return confirm('Sie haben ungespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?');
};

Schritt 3: Implementierung des Interfaces in einer Komponente

Bauen wir nun eine typische Profilbearbeitungs-Komponente mit Angulars Reactive Forms. Die Komponente implementiert das Interface PendingChanges, indem sie prüft, ob das Formular geändert wurde (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">E-Mail</label>
      <input id="email" formControlName="email" />

      <button type="submit" [disabled]="profileForm.invalid">Änderungen speichern</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]]
    });
  }

  // Implementierung des PendingChanges-Vertrags
  isDirty(): boolean {
    return this.profileForm.dirty;
  }

  save() {
    if (this.profileForm.valid) {
      // HTTP-Speicherlogik hier ausführen...
      
      // Formular als makellos (pristine) markieren, damit der Guard die Navigation erlaubt
      this.profileForm.markAsPristine();
    }
  }
}

Schritt 4: Registrierung des Guards im Routing

Schließlich wenden wir unseren Guard in der Routendefinition innerhalb von app.routes.ts an:

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]
  }
];

Fazit & Ausblick

Wir haben nun einen funktionierenden, wiederverwendbaren Guard, der über Angulars Reactive Forms und native Browser-Bestätigungen prüft, ob ein Formular geändert wurde.

Die direkte Hardcodierung von confirm() in einem Guard bringt jedoch Nachteile mit sich:

  1. Es erschwert Unit-Tests (Testläufe blockieren am nativen Dialog).
  2. Es wirkt veraltet und passt oft nicht zu modernen UI/UX-Designsystemen.

In Teil 2 werden wir dieses Setup refaktorieren, um Dependency Injection (DI) Provider und die moderne Methode makeEnvironmentProviders zu nutzen. Dadurch können wir die native Browser-Aufforderung elegant durch benutzerdefinierte UI-Modale (wie Angular Material oder Tailwind Modals) ersetzen.