Routing is a significant aspect of any SPA application, and protecting these routes is often necessary. We may want to guard our routes for permission access or to prevent users from exiting a route by mistake if a form has not been submitted correctly.
Angular provides a set of built-in guards that can be easily used for various use cases.
In this article, I will demonstrate and explain each of the built-in guards provided and show you how to use them with some common examples.
CanActivate
This is the most widely used guard. The canActivate
guard is a route guard that allows or denies access to a route based on the logic specified in the guard. The method implemented in the guard returns a boolean
, a UrlTree
, a Promise<boolean | UrlTree>
or an Observable<boolean | UrlTree>
.
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
When the guard is used on a route, the router will call the method defined in the guard before navigating to the route. If the method returns true
, the navigation will proceed. If it returns false
, the navigation will be canceled and the user will stay on the current route. If the method returns a promise or an observable, the router will wait for it to resolve before proceeding with the navigation. If the method return a UrlTree
, the navigation will be canceled and the new navigation will be executed.
Example:
@Injectable({ providedIn: 'root' })
export class PermissionsService {
private user = getUser();
isAdmin(isAdmin: boolean) {
return isAdmin ? user.isAdmin : false;
}
}
@Injectable({ providedIn: 'root' })
export class IsAdminGuard implements CanActivate {
private permission = inject(PermissionsService);
canActivate(route: ActivatedRouteSnapshot) {
const isAdmin: boolean = route.data?.['isAdmin'] ?? false;
return this.permission.isAdmin(isAdmin);
}
}
export const APP_ROUTES: [{
path: 'dashboard',
canActivate: [IsAdminGuard],
data: {
isAdmin: true,
},
loadComponent: () => import('./dashboard/admin.component'),
}]
This example illustrates the typical way of implementing a guard. We declare a class as a service that implements the CanActivate
interface. In this scenario, we are checking if the user is an admin, as this route can only be accessed by admin users.
It is possible to pass data
to our guard by setting properties inside the data property of the Route
object.
Warning: implementing guard as injectable services is going to be deprecated in v15.2 and removed in v17
Injectable Class and InjectionToken
-based guards are less configurable and reusable and require more boilerplate code. Additionally they cannot be inlined making them less powerful and more cumbersome.
Deprecate class and `InjectionToken` guards and resolvers #47924
Class and InjectionToken
-based guards and resolvers are not as configurable,
are less re-usable, require more boilerplate, cannot be defined inline with the route,
and require more in-depth knowledge of Angular features (Injectable
/providers).
In short, they're less powerful and more cumbersome.
In addition, continued support increases the API surface which in turn increases bundle size, code complexity, the learning curve and API surface to teach, maintenance cost, and cognitive load (needing to grok several different types of information in a single place).
Lastly, supporting only the CanXFn
types for guards and ResolveFn
type
for resolvers in the Route
interface will enable better code
completion and integration with TypeScript. For example, when writing an
inline functional resolver today, the function is typed as any
and
does not provide completions for the ResolveFn
parameters. By
restricting the type to only ResolveFn
, in the example below
TypeScript would be able to correctly identify the route
parameter as
ActivatedRouteSnapshot
and when authoring the inline route, the
language service would be able to autocomplete the function parameters.
const userRoute: Route = {
path: 'user/:id',
resolve: {
"user": (route) => inject(UserService).getUser(route.params['id']);
}
};
Importantly, this deprecation only affects the support for class and
InjectionToken
guards at the Route
definition. Injectable
classes
and InjectionToken
providers are not being deprecated in the general
sense. Functional guards are robust enough to even support the existing
class-based guards through a transform:
function mapToCanMatch(providers: Array<Type<{canMatch: CanMatchFn}>>): CanMatchFn[] {
return providers.map(provider => (...params) => inject(provider).canMatch(...params));
}
const route = {
path: 'admin',
canMatch: mapToCanMatch([AdminGuard]),
};
With regards to tests, because of the ability to map Injectable
classes to guard functions as outlined above, nothing needs to change
if projects prefer testing guards the way they do today. Functional
guards can also be written in a way that's either testable with
runInContext
or by passing mock implementations of dependencies.
For example:
export function myGuardWithMockableDeps(
dep1 = inject(MyService),
dep2 = inject(MyService2),
dep3 = inject(MyService3),
) { }
const route = {
path: 'admin',
canActivate: [() => myGuardWithMockableDeps()]
}
// test file
const guardResultWithMockDeps = myGuardWithMockableDeps(mockService1, mockService2, mockService3);
const guardResultWithRealDeps = TestBed.inject(EnvironmentInjector).runInContext(myGuardWithMockableDeps);
If you still want to do it this way or for backward compatibility, you will need to create a function to inject your service, like this:
function mapToActivate(providers: Array<Type<{canActivate: CanActivateFn}>>): CanActivateFn[] {
return providers.map(provider => (...params) => inject(provider).canActivate(...params));
}
const route = {
path: 'admin',
canActivate: mapToActivate([IsAdminGuard]),
};
The new way:
@Injectable({ providedIn: 'root' })
export class PermissionsService {
isAdmin(isAdmin: boolean) {
return isAdmin;
}
}
export const canActivate = (isAdmin: boolean, permissionService = inject(PermissionsService)) => permissionService.isAdmin(isAdmin);
export const APP_ROUTES: [{
path: 'dashboard',
canActivate: [() => canActivate(true)],
loadComponent: () => import('./dashboard/admin.component'),
}]
It feels better, doesn't it ? With less boilerplate and more explicit code (We don't need to set some properties on the Route
data attribute that are often forgotten about in the previous approach).
For the purpose of this article, all others examples will be implemented using the new approach.
CanMatch
The CanMatch
guard is a new feature that was introduced in Angular v14.2. It will activate the route and load the lazy-loaded component if all guards return true, otherwise it will navigate to the next route with the same name.
Warning: It's important to note that ONE route has to match otherwise you will encounter an error in your console.
ERROR Error: Uncaught (in promise): Error: NG04002: Cannot match any routes.
URL Segment: 'dashboard'
Example:
@Injectable({ providedIn: 'root' })
export class PermissionService {
isAllowed(permissions: Permission[]) {
const user = ...
return permissions.includes(user.permission);
}
}
export type Permission = 'ADMIN' | 'USER' | 'MANAGER';
export const canMatch = (permissions: Permission[], permissionService = inject(PermissionsService)) =>
permissionService.isAllowed(permissions);
export const APP_ROUTES: [
{
path: 'dashboard',
canMatch: [() => canMatch(['ADMIN'])],
loadComponent: () => import('./dashboard/admin.component'),
},
{
path: 'dashboard',
canMatch: [() => canMatch(['MANAGER'])],
loadComponent: () => import('./dashboard/manager.component'),
},
{
path: 'dashboard',
loadComponent: () => import('./dashboard/everyone.component'),
}
]
If we want to navigate to the /dashboard
route, it will first check if user has the ADMIN
permission, if it does, the router will load the AdminComponent
otherwise the router will continue to the next possible route, and so on. We can set a fallback route specifically for this dashboard
navigator, but we can also set a catch-all routes **
which will catch all failed navigations:
{
path: '**',
loadComponent: () => import('./not-found.component'),
}
Note: This guard can also return a UrlTree
, which will cancel the previous navigation and trigger a new navigation.
@Injectable({ providedIn: 'root' })
export class PermissionService {
constructor(private router: Router) {}
isAllowed(permissions: Permission[]) {
if(!user) {
return this.router.parseUrl('no-user');
}
// check permissions
}
}
CanActivateChild
This is quite similar to CanActivate
and is often misunderstood.
To help clarify the differences, here is an example:
export const APP_ROUTES = [
{
path: 'dashboard',
canActivate: [() => true],
canActivateChild: [() => true],
loadComponent: () => import('./dashboard/no-user.component'),
loadChildren: () => import('./child-routes').then((m) => m.CHILDREN_ROUTE),
}
]
// inside child-routes
export const CHILDREN_ROUTE = [
{
path: 'manager',
loadComponent: () => import('./dashboard/manager.component'),
},
{
path: 'client',
loadComponent: () => import('./dashboard/client.component'),
},
];
The key differences between the two are:
If we navigate from the root
/
to/dashboard/manager
route, bothCanActivate
andCanActivateChild
guards will be executed. However if we navigate between child components (from/dashboard/manager
to/dashboard/client
), onlyCanActivateChild
will be executed.CanActivate
is only executed when the parent component is not yet created.If we navigate only to the parent component, only
CanActivate
will be executedIf we navigate to one child, and one of the guard inside
CanActivateChild
return false, the entire route is canceled and the parent will not be created.CanActivate
is executed beforeCanActivateChild
. IfCanActivate
return false,CanActivateChild
will not be executed.We can replace
CanActivateChild
whichCanActivate
on every child route.
CanDeactivate
The CanDeactivate
guard is used to control the navigation away from a route. It allows you to prevent the user from leaving a route or a component until some condition is met, or to prompt the user for confirmation before navigating away.
Note: It's commonly used when working with forms to prevent the user from navigating away if the form has been modified but not yet submitted. We can display a modal dialog with a warning message to notify the user of any unsaved changes.
Example:
export interface DeactivationGuarded {
canDeactivate(): Observable<boolean> | Promise<boolean> | boolean;
}
@Component({
standalone: true,
imports: [RouterLink, ButtonComponent],
template: `<button app-button routerLink="/">Logout</button>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class NoUserDashboardComponent implements DeactivationGuarded {
canDeactivate(): boolean | Observable<boolean> | Promise<boolean> {
return false;
}
}
export const APP_ROUTES = [
{
path: 'dashboard',
canDeactivate: [(comp: DeactivationGuarded) => comp.canDeactivate()],
loadComponent: () => import('./dashboard/no-user.component'),
}
]
-
CanDeactivate
takes the component associated with the route and injects it into the function.
Note: Like any guard, it can return a UrlTree
to navigate to trigger a new navigation to a different route.
CanLoad
CanLoad
is a guard that is often used with CanActivate
. It loads the lazy-loaded component is the guard returns true.
Note: This guard has been deprecated in Angular v.15.1 and has been replaced by CanMatch
Tips/ Tricks
Chaining guards
A guard property is of type Array
which means multiple guards can be chained to a specific route.
- All guards will be executed in the specified order
- If one guard return false, the navigation will be cancelled
- If the first guard returns false, the other guards in the array will not be executed
- If one guard returns a
UrlTree
, the following guards will not be executed and the navigation will be rerouted.
export const APP_ROUTES = [
{
path: 'dashboard',
canActivate: [() => true, () => false, () => true],
loadComponent: () => import('./dashboard/no-user.component'),
}
]
In this example dashboard
route will be canceled, and the last function will not be executed
Routing to children component depending on parameters
Given the following example:
export const CHILDREN_ROUTE = [
{
path: '',
pathMatch: 'full',
redirectTo: 'compA'
},
{
path: 'compA',
loadComponent: () => import('./dashboard/comp-a.component'),
},
{
path: 'compB',
loadComponent: () => import('./dashboard/comp-b.component'),
},
];
export const APP_ROUTES = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard/no-user.component'),
loadChildren: () => import('./child_routes').then((m) => m.CHILDREN_ROUTE),
}
]
When we navigate to the dashboard
, we will be redirected to dashboard/compA
. But if we need to redirect to either compA
or compB
depending on an external parameter (such as user permission or if a specific action has been taken, … ), we can "trick" our routing with a guard that returns a UrlTree
to redirect to the correct URL based on certain conditions.
export const redirectTo = (router = inject(Router), userStore = inject(UserStore)) => {
return userStore.hasDoneAction$.pipe(
mergeMap((hasDoneAction) =>
iif(
() => hasDoneAction,
of(router.createUrlTree(['dashboard', 'compA'])),
of(router.createUrlTree(['dashboard', 'compB']))
)
)
);
};
export const CHILDREN_ROUTE: Route[] = [
{
path: '',
pathMatch: 'full',
children: [],
canActivate: [() => redirectTo()],
},
{
path: 'compA',
loadComponent: () => import('./dashboard/comp-a.component'),
},
{
path: 'compB',
loadComponent: () => import('./dashboard/comp-b.component'),
},
];
Note: The children
property with an empty array must be provided because each route definition must include at least one property among component
, loadComponent
, redirectTo
, children
or loadChildren
That's it for this article! You should now have no excuses not to guard your route properly.
I hope you learned new Angular concept. If you liked it, you can find me on Twitter or Github.
👉 If you want to accelerate your Angular and Nx learning journey, come and check out Angular challenges.
Top comments (0)