DEV Community

Jared Robertson
Jared Robertson

Posted on

Angular Query in route guards

Short version

Use queryClient.fetchQuery(...), which returns the data as a promise:

const queryClient = injectQueryClient();
const data = await queryClient.fetchQuery({
  queryKey: ['my-key'],
  queryFn: () => {},
  staleTime: >0,
});
return data;
Enter fullscreen mode Exit fullscreen mode

Set the staleTime to a nonzero value so the route guard does not refetch every time.
Do not use injectQuery(...) or injectQueryClient().getQuery(...).

The problem

This is a real scenario I recently encountered where we were calling the same permissions endpoint multiple times on every page load.
When a user navigates to the admin dashboard, we have a route guard that fetches the admin permissions and returns true if they are an admin. Once on the page, it would fetch the admin permissions again to determine what links to show. When a user clicked on any of those links, it would fetch the admin permissions again before finally taking them to their destination.

Angular Query to the rescue

To solve this, I moved the endpoint into a queryOptions() object in the service:

// Old endpoint
getPermissions() {
  return this.http.get<Permission[]>('/api/admin-permissions');
}

// New query options
permissionOptions() {
  return queryOptions({
    queryKey: ['admin-permissions'],
    queryFn: () => {
      return lastValueFrom(
        this.http.get<Permission[]>('/api/admin-permissions'),
      );
    },
    staleTime: 30_000,
  });
}
Enter fullscreen mode Exit fullscreen mode

I set a staleTime value of 30 seconds, which gives the admin plenty of time to go to the admin dashboard, find the link they want, and click it. They should be able to do all of that without having to refetch the permissions.
The page uses injectQuery(...), which works well. There are additional complications with route guards, however.

Route guard requirements

The route guard must return an async value, since we might have the permissions cached or we might need to fetch them. Also, we cannot emit a value until the permissions have been fetched. Additionally, if we have the permissions, but the data is stale, we should refetch.
Fortunately, Angular Query has the fetchQuery function, which is designed to solve this problem. From the fetchQuery documentation:

If the query exists and the data is not invalidated or older than the given staleTime, then the data from the cache will be returned. Otherwise it will try to fetch the latest data.

Here is how the route guard looked before:

export const adminDashboardGuard: CanMatchFn = () => {
  const adminService = inject(AdminService);
  const permission: Permission = 'view:admin-dashboard';

  return adminService
    .getPermissions()
    .pipe(map((permissions) => permissions.includes(permission)));
};
Enter fullscreen mode Exit fullscreen mode

And here is how it looks with fetchQuery:

export const adminDashboardGuard: CanMatchFn = async () => {
  const queryClient = injectQueryClient();
  const adminService = inject(AdminService);
  const permission: Permission = 'view:admin-dashboard';

  const permissions = await queryClient.fetchQuery(
    adminService.permissionOptions(),
  );
  return permissions.includes(permission);
};
Enter fullscreen mode Exit fullscreen mode

Make the guard async, inject the query client, and call queryClient.fetchQuery() with the newly created permissionOption(). It returns a promise that we await, and then we return whether or not the admin has the view:admin-dashboard permission.
That's it!

What will not work

We cannot use injectQuery(...).data() because it returns undefined until the data has been fetched. Additionally, if the data is stale, it will emit the stale data before emitting the fetched data. injectQuery(...) works great for components, not for route guards.
We also cannot use injectQueryClient().getQueryData(...) because it is not asynchronous. If the data exists, it will return it. If the data has not been fetched, it will return undefined (or maybe null).

Top comments (0)