Data fetching in meta frameworks
All frontend javascript meta frameworks have it these days: a mechanism for data fetching. They come in many forms, but mostly as a
convention-based named export
, for example the loader export from Remix:
import { json } from '@remix-run/node';
export const loader = async () => {
return json({ myData: 'is the best' });
};
Loaders are executed on the server and fetch the data for a route, potentially in parallel, to make it available for the template of the
route component. In Remix you would use the useLoaderData
hook to get access to
the fetched data:
import { useLoaderData } from "@remix-run/react";
export default function SomeRoute() {
const data = useLoaderData<typeof loader>();
// access myData using data.myData
...
}
Some other examples of meta frameworks that provide a data fetching mechanism are:
A data fetching feature is a must have for projects that need server-side rendering. Not all projects have that need though, so maybe SEO is not that important for you. Sometimes a good old SPA is good enough, i.e. for internal applications. Adding a meta framework only adds unnecessary complexity since all of a sudden you have a server to care about and manage.
Bringing loaders to the SPA 🛀
The Remix team brought the loader
concept to the Single Page Application with React Router's
loader. A loader in Remix is (almost) equivalent to a loader in React Router. In comparison,
React Router doesn't use file-based routing but a router config:
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Root, { rootLoader } from './routes/root';
import Team, { teamLoader } from './routes/team';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
loader: rootLoader,
children: [
{
path: 'team',
element: <Team />,
loader: teamLoader,
},
],
},
]);
ReactDOM.createRoot(document.getElementById('root')).render(<RouterProvider router={router} />);
Another difference is that React Router does not provide a type-safe API (yet) using a generic argument, so a typecast will be needed to make Typescript happy. There is an ongoing discussion about that.
React Router uses the same mechanism for using the data as Remix, via the
useLoaderData
hook:
import { useLoaderData } from 'react-router-dom';
export default function Team() {
const team = useLoaderData();
// ...
}
The loader
concept brings a big win in terms of separation of concerns, maintainability, readability and preventing
those pesky race condition bugs when using useEffect
in the
component itself.
There are other alternatives to do data fetching like React Query or Redux (which
each come with their own additional complexity). But the loader
concept is simple and easy to understand.
Angular must be missing a loader 👼
At first sight the Angular router (@angular/router
) doesn't promote a loader
concept similar to React
Router. There is no evident 'data fetching' section in the docs. That doesn't mean it can't be done using the tools at hand. Let's use the
'Hello World' tutorial of angular.io Tour of Heroes as a showcase application to apply
solutions on.
Tour of Heroes
The setup is pretty simple, it's an application with the goal of showing/managing some heroes. It has a couple of components, let's go over them shortly.
DashboardComponent
This is the homepage of the application. When the user navigates to the root he'll be redirected here. A subset of the heroes (top heroes) are displayed here and the user can search.
HeroesComponent
All heroes are shown here. We can navigate to a hero as well.
HeroDetailComponent
This shows a single hero. We can update the hero's name as well.
Routing setup
The routing setup is pretty straightforward:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HeroesComponent } from './heroes/heroes.component';
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'heroes', component: HeroesComponent },
{ path: 'detail/:id', component: HeroDetailComponent },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
No lazy loading, just one route config for the whole application. Can't get much simpler.
Data fetching strategy
The data is fetched in the component themselves. This is done similarly in all components. For example, this is the HeroDetailComponent
:
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent implements OnInit {
hero: Hero | undefined;
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
) {}
ngOnInit(): void {
this.getHero();
}
// 🔽🔽🔽 data fetching done here 🔽🔽🔽
getHero(): void {
const id = parseInt(this.route.snapshot.paramMap.get('id')!, 10);
this.heroService.getHero(id).subscribe((hero) => (this.hero = hero));
}
// ...
}
For the rest of this blog post I'll focus on this component to apply possible solutions on.
Tour of Heroes in review
For experienced Angular developers it might not be a shock that the Tour of Heroes is not exactly world class code that should be used in
production. It is not promoted as such. But it can still be useful for this blog post, to see how we would improve and potentially use the
concept of loaders in Angular. Let's take a detailed look at the HeroDetailComponent
.
HeroDetailComponent
A couple of things come to mind when looking at both the code and the UX. From a UX perspective, we have:
- A jumpy experience. When the user navigates to this route, the UI changes twice. This happens because the old route component is being destroyed, then the new one is being rendered (partly), and then the UI is updated again after the hero has loaded.
- A missing 404 experience. When the user navigates to URL for a hero that does not exist, there is no redirect or 'whoops not found' experience shown.
From a code perspective we have:
- No separation of concerns. Data fetching logic is mixed with navigation and form logic.
- Conditional logic. It could be the case the hero it not loaded yet. Or the fetching could be done but the hero does not exist. This also leads to code duplication for any 'loading' logic, every component needs it, again.
Bringing loaders to Angular
So it's obvious there's a lot to improve. Let's explore potential solutions and see what might be the closest thing to a loader
in
Angular.
Well, let me just call it out: it's Guards. It might seem a bit hidden in plain sight, but on the Common Routing Tasks page of angular.io, there's a section devoted to guards. You can find it under the heading Preventing unauthorized access, which is a heavy understatement: guards can be used for much more than just authorization checks.
Guards introduction
At the end of the section mentioned above is a link to the Tour of Heroes router tutorial, called: Route guards. I'm just going to borrow their explanation of route guards:
At the moment, any user can navigate anywhere in the application any time, but sometimes you need to control access to different parts of your application for various reasons, some of which might include the following:
- Perhaps the user is not authorized to navigate to the target component
- Maybe the user must login (authenticate) first
- Maybe you should fetch some data before you display the target component
- You might want to save pending changes before leaving a component
- You might ask the user if it's okay to discard pending changes rather than save them
You add guards to the route configuration to handle these scenarios.
From this list it becomes clear: guards are not only useful for authorization. For the sake of this blog post, bullet point #3 sounds very interesting.
Maybe you should fetch some data before you display the target component
Actually, it sounds precisely like a loader. That's what we're looking for!
Guard types
Now, which one to pick... These are the possible guard types:
- canActivate
- canActivateChild
- canDeactivate
- canLoad
- canMatch
- resolve
Let's explain them:
- CanActivateFn is called before a route is activated.
- CanActivateChildFn does the same as CanActivateFn but for any of the route's children.
- CanDeactivateFn is called whenever a user exits a route, so is unfit for data fetching before navigation.
- CanLoadFn is called before a lazy loaded route component or module is loaded.
- CanMatchFn is called before a route is matched.
- ResolveFn is called before a route is activated. But this one is different than
CanActivateFn
, because it's purpose is exclusively for data fetching.
CanMatchFn
vs CanLoadFn
vs CanActivateFn
Since in Angular version 14.1 the CanMatchFn was added, the CanLoadFn
, CanActivateFn
and
CanActivateChildFn
seems to be less useful for data fetching, because with CanMatchFn
you can prevent a route from being matched at all.
And routes that are not going to be matched will not be loaded or activated. So CanMatchFn
is more powerful than the CanLoadFn
and
CanActivate(Child)Fn
because it's evaluated sooner.
But its downside is that the route
parameter is not yet a fully qualified ActivatedRouteSnapshot
, so getting url params is not easy
(see explanation). So therefore, CanMatchFn
is currently not a
good fit for data fetching.
CanLoadFn
is also not a good fit for data fetching, because it's only called for lazy loaded components or modules, not for every
route.
Let's look at the canActivate and resolve guards more in detail and how they could be used for data fetching.
canActivate
A canActivate guard comes in two forms. The first is the deprecated CanActivate
interface.
The second is it's newer equivalent, the CanActivateFn
.
A guard can only return two types of values, a boolean
or a UrlTree
. When returning true
, the navigation succeeds. When returning
false
, navigation is cancelled (as if nothing happened). When returning a UrlTree
, the router will navigate to that UrlTree
instead.
You could see it as a redirect. More often than not, the value you need to determine the result for the guard to return is coming from
either a HTTP request or some data store. So, in order to make the guard asynchronous, you can return an Observable<boolean | UrlTree>
or
a Promise<boolean | UrlTree>
as well.
Now that we've cleared that up, let's look at how we would solve the Tour of Heroes issues with CanActivateFn
.
// hero-loader.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { BehaviorSubject, map } from 'rxjs';
import { Hero } from './hero';
import { HeroService } from './hero.service';
declare global {
var hero: BehaviorSubject<null | Hero>;
}
window.hero = new BehaviorSubject<Hero | null>(null);
export const heroLoader: CanActivateFn = (route) => {
const id = Number(route.paramMap.get('id')!);
const router = inject(Router);
const heroService = inject(HeroService);
return heroService.getHero(id).pipe(
map((hero) => {
window.hero.next(hero);
return Boolean(hero) || router.createUrlTree(['/not-found']);
}),
);
};
Here we define a loader function which implements the CanActivateFn
interface. It mainly comprises of three parts:
- get some data from the route (the
id
param value) - use it to fetch the hero and save it in a global variable for later use (here
Window
) - handle the edge case (i.e. navigation to a 'Not found' page in case the hero can't be found)
Then, we use this function in the router config:
+ import { heroLoader } from './hero-loader';
const routes: Routes = [
// ...
{
path: 'detail/:id',
component: HeroDetailComponent,
// 🔽🔽🔽 canActivate config added here 🔽🔽🔽
+ canActivate: [heroLoader],
},
// ...
];
And remove the data fetching logic from the HeroDetailComponent
:
import { Component } from '@angular/core';
import { Location } from '@angular/common';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent {
hero$ = window.hero!;
constructor(
private heroService: HeroService,
private location: Location,
) {}
goBack(): void {
this.location.back();
}
save(hero: Hero): void {
this.heroService.updateHero(hero).subscribe(() => this.goBack());
}
}
Now that the hero property is an Observable<Hero | null>
instead of Hero | undefined
we have to change the template a bit:
<!-- need to use async pipe 🔽 -->
<div *ngIf="hero$ | async as hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="hero.name" placeholder="Hero name" />
</div>
<button type="button" (click)="goBack()">go back</button>
<!-- and pass the hero here 🔽 -->
<button type="button" (click)="save(hero)">save</button>
</div>
See a working example on Stackblitz here.
resolve
Last on the list, but not least is the resolve guard. A resolve guard comes in two forms. The first is the deprecated
Resolve
interface. The second is it's newer equivalent, the
ResolveFn
. Resolve guards are a little different in usage and API than the other guards. Let's
review how a resolve might solve our data fetching issues in the Tour of Heroes. Looking at some code:
// hero-resolver.ts
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn, Router } from '@angular/router';
import { EMPTY, of } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { Hero } from './hero';
import { HeroService } from './hero.service';
export const heroResolver: ResolveFn<Hero> = (route: ActivatedRouteSnapshot) => {
const id = Number(route.paramMap.get('id')!);
const router = inject(Router);
const heroService = inject(HeroService);
return heroService.getHero(id).pipe(
mergeMap((hero) => {
if (hero) {
return of(hero);
} else {
// hero not found
router.navigate(['/not-found']);
return EMPTY;
}
}),
);
};
Here we define a function which implements the ResolveFn
interface. It mainly comprises of three parts:
- get some data from the route (the
id
param value) - use it to fetch the hero and return it
- handle the edge case (i.e. navigation to a 'Not found' page in case the hero can't be found)
Then, we use this function in the router config:
+ import { heroResolver } from './hero-resolver';
const routes: Routes = [
// ...
{
path: 'detail/:id',
component: HeroDetailComponent,
// 🔽🔽🔽 resolve config added here 🔽🔽🔽
+ resolve: { hero: heroResolver },
},
// ...
];
And remove the data fetching logic from the HeroDetailComponent
:
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { Observable, map } from 'rxjs';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css'],
})
export class HeroDetailComponent {
hero$: Observable<Hero> = this.route.data.pipe(map((data) => data['hero']));
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
private location: Location,
) {}
goBack(): void {
this.location.back();
}
save(hero: Hero): void {
this.heroService.updateHero(hero).subscribe(() => this.goBack());
}
}
Typecasting
Note we still have to pluck the hero from the route data. The plucking requires a type annotation or a typecast because by default any
property on Data
resolves to any
:
type Data = {
[key: string | symbol]: any;
};
This typecasting issue is also there in Remix. In Remix we also have to help typescript to infer the type of the loader using
useLoaderData<typeof loader>()
.
Now that the hero property is an Observable<Hero>
instead of Hero | undefined
we have to change the template a bit:
<!-- need to use async pipe 🔽 -->
<div *ngIf="hero$ | async as hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="hero.name" placeholder="Hero name" />
</div>
<button type="button" (click)="goBack()">go back</button>
<!-- and pass the hero here 🔽 -->
<button type="button" (click)="save(hero)">save</button>
</div>
See a working example on Stackblitz here.
Solutions in review
General guard data fetching experience
After applying the resolve or canActivate guard there's a change in UX:
- When we click on a hero from the dashboard we stay on the same page.
- In the background the hero is fetched.
- When fetching is done, we see the
HeroDetailComponent
instantly.
To improve the UX we could introduce a global loading indicator using the logic explained in this tutorial by Todd Motto. This brings additional benefits, because we only have to build the loading logic once instead of into each template of several other components!
canActivate guard
While having the possibility to do a redirect using UrlTree
is great, canActivate is not perfect. The ugly part is the extra 'state'
management we have to do. It's not even actual state, it's more of a temporary cache which we have to keep up to date. If there were more
instances of the same component using the same hero variable, we'd have to resort to a more sophisticated state management solution like
NgRx to organize it, because it can get messy quickly.
resolve guard
Compared to the canActivate guard, a resolve guard solves the extra state management we have to do. But a resolve guard also has downsides
compared to a canActivate guard. In case the user navigates to a hero that can't be found, the edge case logic kicks in, leading to a
router.navigate
. Doing that from a resolve guard triggers a NavigationCancel
event. There's
a long-running open github issue about this, but in a nutshell a NavigationCancel
event
confuses the router. It also messes up the loading indicator logic mentioned above, possibly leading to a slight flicker of the loader
(depending on how it's implemented).
Conclusion
In this article we've seen how to use the canActivate
and resolve
guards to fetch data for a component, Remix loader style. We've seen
they both have their up- and downsides, but in general they can both get the job done.
While being pretty feature-complete, the Angular team is still actively working on the router. For example see this issue, saying the architecture is problematic (in 2021). I still expect to see more improvements in the future. But for now, resolve or canActivate guards are the closest thing we have to a loader in SPA Angular land.