Angular Guards: CanActivateChild, CanDeactivate, Resolve, canMatch and Preloading

Angular Tutorial: Beginner to Advanced · 9. Routing and navigation · 6 min read · Updated

A guard is a small function that answers a question about navigation: may the user enter, leave, or load this route? You already saw canActivate. Here are the other guards, plus resolve and preloading.

What you will learn

  • canActivateChild to protect a whole group of routes
  • canDeactivate to warn about unsaved changes
  • resolve to load data before the page shows
  • canMatch, the modern replacement for CanLoad
  • A custom preloading strategy

Guards are functions now

Modern guards are plain functions. Class-based guards and CanLoad are legacy. Inside a guard you can call inject().

  • canActivate: Question it answers: May I enter this route?
  • canActivateChild: Question it answers: May I enter any child of this route?
  • canDeactivate: Question it answers: May I leave this route?
  • canMatch: Question it answers: Should this route even be considered (and its code downloaded)?
  • resolve: Question it answers: Give me the data first

A guard returns true, false, a UrlTree (redirect) or an Observable or Promise of those.

One service for the examples

// src/app/guards2/session.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class Session {
  readonly user = signal<{ name: string; role: 'admin' | 'member' } | null>(null);
  login(role: 'admin' | 'member') {
    this.user.set({ name: 'Asha', role });
  }
  logout() {
    this.user.set(null);
  }
}

canActivateChild

Put it on a parent route and it runs for every child. One check protects the whole admin area:

// src/app/guards2/guards.ts
import { inject } from '@angular/core';
import { CanActivateChildFn, CanDeactivateFn, CanMatchFn, ResolveFn, Router } from '@angular/router';
import { Session } from './session';

export const adminOnly: CanActivateChildFn = () => {
  const user = inject(Session).user();
  // returning a UrlTree redirects instead of just blocking
  return user?.role === 'admin' ? true : inject(Router).parseUrl('/denied');
};

// canMatch replaces CanLoad: if it fails, the lazy chunk is never downloaded
export const memberOnly: CanMatchFn = () => inject(Session).user() !== null;

// canDeactivate: the component decides if it can be left
export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}
export const leaveGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
  !component.hasUnsavedChanges() || confirm('You have unsaved changes. Leave anyway?');

// resolve: fetch (or compute) data before the component is created
export interface Article {
  id: string;
  title: string;
}
export const articleResolver: ResolveFn<Article> = (route) => {
  const id = route.paramMap.get('id') ?? '0';
  return { id, title: 'Article ' + id }; // could also return an Observable from HttpClient
};

Using them in routes

// src/app/guards2/pages.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Article, HasUnsavedChanges } from './guards';

@Component({ selector: 'app-admin-home', template: '<h2>Admin home</h2>' })
export class AdminHome {}

@Component({ selector: 'app-denied', template: '<p>Access denied</p>' })
export class Denied {}

@Component({
  selector: 'app-article',
  template: '<h2>{{ article.title }}</h2>',
})
export class ArticlePage {
  // resolved data is stored under the key used in the route's resolve object
  article = inject(ActivatedRoute).snapshot.data['article'] as Article;
}

@Component({
  selector: 'app-editor',
  imports: [FormsModule],
  template: '<textarea [(ngModel)]="text"></textarea>',
})
export class Editor implements HasUnsavedChanges {
  text = '';
  saved = '';
  hasUnsavedChanges() {
    return this.text !== this.saved;
  }
}
// src/app/guards2/guards.routes.ts
import { Routes } from '@angular/router';
import { adminOnly, articleResolver, leaveGuard, memberOnly } from './guards';
import { AdminHome, ArticlePage, Denied, Editor } from './pages';

export const GUARD_ROUTES: Routes = [
  {
    path: 'admin',
    canActivateChild: [adminOnly],
    children: [{ path: '', component: AdminHome }],
  },
  { path: 'denied', component: Denied },
  { path: 'editor', component: Editor, canDeactivate: [leaveGuard] },
  { path: 'articles/:id', component: ArticlePage, resolve: { article: articleResolver } },
  {
    path: 'members',
    canMatch: [memberOnly],
    loadComponent: () => import('./pages').then((m) => m.AdminHome),
  },
  { path: 'members', component: Denied }, // used when canMatch says no
];

Notes:

  • Read resolved data from route.data (or snapshot.data). If you enable withComponentInputBinding() in provideRouter, it also arrives as an input with the same name.
  • canMatch can be used twice for the same path. The first route that matches wins, so you can show a different page to logged in and logged out users.
  • A resolver that is slow delays navigation. Show a loading bar using router events.

CanLoad is legacy

CanLoad (a class interface) only decided whether to download a lazy module. canMatch does the same and more, so use canMatch.

Custom preloading strategy

After the first screen, Angular can download lazy routes in the background. PreloadAllModules loads everything. A custom strategy lets you choose, using route data:

// src/app/guards2/selective-preload.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class SelectivePreload implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    return route.data?.['preload'] ? load() : of(null);
  }
}
// File: register it
provideRouter(routes, withPreloading(SelectivePreload));
// and mark the routes you want early:
{ path: 'reports', loadComponent: ..., data: { preload: true } }

Test it

// src/app/guards2/guards.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { GUARD_ROUTES } from './guards.routes';
import { Session } from './session';

describe('guards', () => {
  beforeEach(() => TestBed.configureTestingModule({ providers: [provideRouter(GUARD_ROUTES)] }));

  it('redirects non admins away from the admin area', async () => {
    TestBed.inject(Session).login('member');
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/admin');
    expect(TestBed.inject(Router).url).toBe('/denied');
  });

  it('lets admins in', async () => {
    TestBed.inject(Session).login('admin');
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/admin');
    expect(TestBed.inject(Router).url).toBe('/admin');
  });

  it('delivers resolved data', async () => {
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/articles/5');
    expect((harness.routeNativeElement as HTMLElement).textContent).toContain('Article 5');
  });

  it('blocks leaving an editor with unsaved text when the user cancels', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(false);
    const harness = await RouterTestingHarness.create();
    const editor = await harness.navigateByUrl('/editor');
    (editor as unknown as { text: string }).text = 'draft';
    await harness.navigateByUrl('/denied');
    expect(TestBed.inject(Router).url).toBe('/editor');
  });

  it('uses canMatch to pick a route', async () => {
    const harness = await RouterTestingHarness.create();
    await harness.navigateByUrl('/members');
    expect((harness.routeNativeElement as HTMLElement).textContent).toContain('Access denied');
  });
});

Common mistakes

  • Treating guards as security. They only improve the experience. The server must check permissions again.
  • Using CanLoad classes in new code.
  • Doing slow work in a resolver without any loading feedback.
  • Forgetting that a canDeactivate guard only runs for navigation inside the app. For closing the tab use the beforeunload event.

Try it yourself

  1. Add a canDeactivate guard to a reactive form that checks form.dirty.
  2. Mark one lazy route with data: { preload: true } and see it load early in the network tab.
  3. Replace the confirm() with your own dialog by returning an Observable<boolean>.

Quick check

  1. Which guard replaces CanLoad?
  2. What does returning a UrlTree do?
  3. Why can a resolver feel slow?

Next, continue with Angular Lazy Loading: Load Routes and Components on Demand.

Frequently asked questions

Should I still use class based guards?

No. Class based guards are deprecated. Use plain functions with inject().

When is a resolver better than loading in the component?

When the page makes no sense without the data and you prefer to stay on the old page until it is ready. Otherwise load in the component and show a loading state.