skip to content

In Angular, how do you build a CanDeactivateFn that warns before leaving a form with unsaved changes, and what does it not catch?

level: middleimportance: must knowfreq 68%

answer

  1. the guard for leaving, not entering
  2. the component instance is argument one
  3. a small interface the component implements
  4. router navigations only, not tab close

basics

~20 s

A CanDeactivateFn<T> receives the component being left, checks its dirty state and returns true or a confirmation result (boolean, Observable or Promise). It sees only router navigations; reloads, tab closes and external URLs need beforeunload.

solid answer

~40 s

I define an interface such as `HasUnsavedChanges { hasUnsavedChanges(): boolean }`, have the editor component implement it, and write `unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges>`. The router calls it with `(component, currentRoute, currentState, nextState)`, so the guard reads `component.hasUnsavedChanges()` and returns `true`, or the result of a confirmation: a synchronous `confirm()` boolean, or an `Observable`/`Promise` from a custom dialog. I register it with `canDeactivate: [unsavedChangesGuard]`. Its limits: it runs only for navigations the Angular router handles, so a reload, a tab close or typing another site's URL bypasses it and needs a `beforeunload` listener; after saving I must clear the dirty flag before navigating away, or the guard prompts on my own redirect; and a rejected back-button navigation rewrites history unless `canceledNavigationResolution: 'computed'` is set.

code

ts · 33 lines
ts
import { Component, signal } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';

export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}

export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
  component.hasUnsavedChanges()
    ? confirm('You have unsaved changes. Leave this page?')
    : true;

@Component({
  selector: 'app-settings-editor',
  template: `
    <textarea (input)="dirty.set(true)"></textarea>
    <button type="button" (click)="save()">Save</button>
  `,
})
export class SettingsEditor implements HasUnsavedChanges {
  readonly dirty = signal(false);

  hasUnsavedChanges(): boolean {
    return this.dirty();
  }

  save(): void {
    // persist the settings, then mark the form clean before any navigation
    this.dirty.set(false);
  }
}

// { path: 'admin/settings', component: SettingsEditor, canDeactivate: [unsavedChangesGuard] }

go deeper

for a junior

Recall that canDeactivate guards run when leaving a route, receive the component instance first, and return true, false or an async boolean.

for a middle

Explain the interface-typed CanDeactivateFn<T>, that deactivate checks run before activate checks, and why reloads and tab closes need beforeunload instead.

for a senior

Show the production traps: prompting after your own save, back-button history damage and the canceledNavigationResolution 'computed' fix, and dialogs that never emit.

for a principal

Decide how an app standardises unsaved-change protection across many editors, balancing prompt fatigue against data loss and choosing when auto-draft beats prompting.

## What canDeactivate is for In Angular's router, `canDeactivate` guards decide whether the user may **leave** the currently active route. The classic use is an editor with unsaved changes: before the router tears the component down, it asks the guard, and the guard can ask the user. The function type is: ```ts type CanDeactivateFn<T> = ( component: T, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState: RouterStateSnapshot, ) => MaybeAsync<GuardResult>; ``` - `component` is the **instance** of the routed component being deactivated, typed as `T`. - `currentRoute` and `currentState` describe where the user is now. - `nextState` describes where the navigation is heading, so a guard can let some destinations through (for example `nextState.url` starting with the same editor section). - The result is the same set as other guards: `boolean`, `UrlTree`, `RedirectCommand`, or an `Observable`/`Promise` of one; the router uses the first value. ## Building it 1. Declare a narrow interface, for example `HasUnsavedChanges` with `hasUnsavedChanges(): boolean`. Typing the guard against the interface rather than one component lets every editor in the admin area reuse it. 2. Implement the interface in each editor component, backed by a signal or the form's `dirty` state. 3. Write the guard: if nothing is unsaved, return `true`; otherwise return the user's decision. 4. Register it on the route: `canDeactivate: [unsavedChangesGuard]`. The decision can be synchronous (`window.confirm()` returns a boolean and blocks the page) or asynchronous: a design-system dialog that returns an `Observable<boolean>` or `Promise<boolean>` works, because guards accept `MaybeAsync` results. Keep the dialog logic in a service that the guard obtains with `inject()`, so the guard stays a few lines long. ## When the guard runs in the navigation - `canDeactivate` checks run **before** any `canActivate` or `canActivateChild` checks of the target routes; if a deactivate guard says no, the activate guards are never called. - The guard runs only if its route is actually being deactivated or re-evaluated by this navigation. Navigating between two child pages under the same editor parent does not deactivate the parent. - If the guard returns `false`, the navigation is cancelled with a `NavigationCancel` event and the editor stays on screen with its state intact. ## Letting some destinations through The fourth argument, `nextState`, is the `RouterStateSnapshot` the navigation is heading to. A guard can inspect `nextState.url` to skip the prompt where leaving is harmless or expected: - Moving between tabs of the same editor, when the draft lives in a service that survives the switch. - A sign-out flow, where the app is about to discard everything anyway. - A redirect the editor itself started after a successful save, when clearing the dirty flag first is not practical. Keep these exceptions few and explicit; each one is a path on which unsaved work can silently disappear. ## What it does not catch | Situation | Does canDeactivate run? | What covers it | |---|---|---| | Clicking a `routerLink` or calling `router.navigate()` | Yes | The guard | | Browser back or forward inside the app | Yes | The guard, plus the history setting below | | Reloading the tab | No | A `beforeunload` listener | | Closing the tab or window | No | A `beforeunload` listener | | Typing another site's URL in the address bar | No | A `beforeunload` listener | The router only intercepts navigations it performs. Leaving the document entirely is a browser event, handled by listening to `window:beforeunload` (for example through the component's `host` metadata) and asking the browser to show its own generic prompt. ## The back-button trap When the user presses Back, the browser has already changed the URL before the guard runs. If the guard rejects, the router must put things back. `withRouterConfig({ canceledNavigationResolution })` controls how: - `'replace'` (the default): the router calls `location.replaceState` with the pre-navigation URL. The address bar looks right, but the history entry the user was going back to has been overwritten, so history is out of step with what the user did. - `'computed'`: the router tracks history indexes and performs a forward traversal to return to the original entry, keeping history consistent. Apps with frequent unsaved-changes prompts on back navigation usually set `'computed'`. ## Mistakes seen in reviews - **Prompting on your own save.** The component saves, then calls `router.navigate(['/admin/settings'])`; if the dirty flag is still set, the guard asks "discard changes?" after a successful save. Clear the flag first. - **Guarding the wrong route.** The guard reads the instance of the component routed at *its* route; placed on a componentless parent, `component` is `null`. - **Doing work in the guard.** Auto-saving inside `canDeactivate` blocks navigation on a network call and hides failures; ask, and let the component save.

  • How would you replace the blocking confirm() in an Angular canDeactivate guard with a custom dialog?
    Put the dialog in an injectable service whose `open()` returns an `Observable<boolean>` or `Promise<boolean>`, call `inject(ConfirmDialog)` in the guard, and return that value. The guard accepts `MaybeAsync<GuardResult>`, so the router waits for the first emitted value; make sure closing the dialog any way (Escape, backdrop) emits `false` rather than never emitting.
  • Why can an Angular unsaved-changes guard leave browser history wrong after the user presses Back and chooses to stay?
    The browser changed the URL before the guard ran. With the default `canceledNavigationResolution: 'replace'`, the router restores the old URL with `replaceState`, overwriting the entry the user was returning to. Setting `withRouterConfig({ canceledNavigationResolution: 'computed' })` makes the router traverse forward to the original entry instead, keeping history consistent.

saying these in an interview costs you the question

  • A canDeactivate guard also fires when the user reloads or closes the tab.
  • canDeactivate runs after the target route's canActivate guards have approved.
  • The guard receives the component class, so it must look the instance up itself.
  • Returning false from canDeactivate destroys the editor and discards its state.
  • The guard should save the form automatically instead of asking the user.