In my previous blog post I wrote about how to use the concept of loaders in Angular. Since then, Angular v16 was released and it brought a new feature which simplifies the code needed, so it's time for an update.
New routing feature in Angular 16
It's already six months ago since Angular v16 was released (time flies). One of the new features is the ability to bind router data to
component @Input()
s. First you configure the route:
const routes = [
{
path: 'about',
loadComponent: () => import('./about'),
resolve: { contact: () => getContact() },
},
];
Then you can use the contact
data in the component:
@Component(...)
export class About {
// The value of "contact" is passed to the contact input
@Input() contact?: string;
}
The feature can be enabled by calling withComponentInputBinding()
as part of
provideRouter
:
// typically done in the main.ts file
provideRouter(routes, withComponentInputBinding());
Less code needed
Since we can now directly bind data from the router to a component, it takes less code to 'inject' the data in the component. An example:
// Before
@Component(...)
export class About {
contact = this.route.data.pipe(map(data => data.contact as Contact));
// data.contact is of type any, casting or parsing needed 🫠
constructor(private route: ActivatedRoute) {}
}
// After
@Component(...)
export class About {
@Input() contact?: Contact; // ⬅️ still type assignment needed
}
Children benefit too
Also, any child component can bind the data via an @Input()
as well, without prop-drilling. Gotcha: it has to use the same name and you
have to enable paramsInheritanceStrategy
with value 'always' in the router config:
provideRouter(
routes,
withComponentInputBinding(),
withRouterConfig({ paramsInheritanceStrategy: 'always' }), // ⬅️ this
);
Setting things straight
This blog post will try to be the best example of bringing the concept of Remix loaders to Angular. But before I do that, I have to set
something straight. In the previous blog post, I wrote that the resolve
function is not so great. I said:
Doing a
router.navigate
from a resolve guard triggers aNavigationCancel
event. There's a long-running open github issue about this, but in a nutshell aNavigationCancel
event confuses the router. It also messes up the loading indicator logic, possibly leading to a slight flicker of the loader.
This is not entirely true. A NavigationCancel
does not confuse the router. I actually meant to say: "an additional NavigationCancel
event confuses the router". The additional NavigationCancel
event will be triggered by the router.navigate
done in the resolve. But
doing a router.navigate
can be prevented in the resolve by returning an Observable
that never completes. I found out by reading the
documentation of the NavigationCancellationCode
enum, which is living on the code
property of a NavigationCancel
event:
// the enum
enum NavigationCancellationCode {
Redirect
SupersededByNewNavigation
NoDataFromResolver // ⬅️ this is the case we aim for
GuardRejected
}
The docs of the NoDataFromResolver
value say:
A navigation failed because one of the resolvers completed without emitting a value.
So if we don't return a value from a resolver,
the loading indicator logic will work as expected and only a
single NavigationCancel
event will be triggered. This does however mean the redirect logic is no longer in the guard. To still have the
app redirect to a 404 page when the resolver returns no data, we can add a side-effect to the app component:
@Component(...)
export class AppComponent implements OnInit {
#router = inject(Router);
ngOnInit() {
this.#router.events.subscribe((event) => {
if (event instanceof NavigationCancel && event.code === NavigationCancellationCode.NoDataFromResolver) {
this.#router.navigateByUrl('/404', { replaceUrl: true });
}
});
}
}
As you can see, we use the code
property of the NavigationCancel
event to check if the resolver returned no data. If so, we navigate to
the 404 page and replace the url. We replace the url because otherwise the browser back button will just trigger the 404 redirect again,
seemingly doing nothing. This small piece of code will handle all cases where resolve guards emit nothing, for the whole app, instead of
having to redirect to 404 in every resolve guard.
My conclusion is that ResolveFn
is not so bad after all. It prevents us from having to do additional state management outside of any route
guard, which means less code. So let's put it to good use.
Remixing the loader into Angular
To have a proper example of how to use the concept of Remix loaders in Angular, I created a small app. You can find the source code on github: JoepKockelkorn/loaders-in-angular.
It's a simple books app where the user can view an overview of books and when they're an admin of a book, they can also toggle the availability on a detail page.
These are the possible routes:
/
/login
/books
/books/:id
/books/:id/general
/books/:id/admin
/404
This is the behavior:
- All the routes under
/books
are protected - The
/books
route shows a list of books, the user can click on a book to go to/books/:id
- The
/books/:id
route shows some basic info on a book plus an outlet for two tabs:- general (this is the default)
- admin
- The
/books/:id/admin
route is extra protected by authorization, only users with theisAdmin
property of a book set totrue
can access it - Whenever the user tries to access a protected route, the app redirects to
/404
- Whenever a book is not found, the app redirects to
/404
- Whenever a route is not found, the app redirects to
/404
The code
Let's see how the /books
route uses the concept of a loader. This is the component file:
// books.component.ts
@Component({
...
template: `
<div *ngFor="let book of books" class="book">
<a [routerLink]="['./', book.id]">{{ book.title }}</a>
</div>
`
})
export default class BooksComponent {
@Input() books: Resolved<typeof loader> = [];
}
Unfortunately the loader can't be exported from the same file as the component, because that would eagerly load the component, even when
using loadComponent
in the router config. So I've put the loader in a separate file:
// books.loader.ts
export const loader = () => inject(BooksService).getBooks();
In the same way as in Remix, I've reused the type of the loader
function when declaring the type of the @Input()
. The Resolved
type is
a small utility type that I've put in a separate file:
// types.ts
import { ResolveFn } from '@angular/router';
export type Resolved<T> = T extends ResolveFn<infer R> ? R : never;
It's basically returning the type of the data that the loader function loads.
The router config looks like this:
import { loader as booksLoader } from './books.loader';
const routes = [
// ...other routes
{
path: 'books',
loadComponent: () => import('./books.component'),
resolve: { books: booksLoader },
},
];
The booksLoader
is passed to the resolve
property of the route config. There is some repetition in the router config and in the
component, both hard-reference the books
key. But that key could also be moved to a variable and imported in both places, so it's not a
big deal.
Now when the user navigates to /books
, the booksLoader
is called and the result is passed to the books
input of the BooksComponent
.
The component can then use the data to render the list of books. No state management needed. Nice.
The details
For the details page, I've created a BookDetailsComponent
and a loader
. The BookDetailsComponent
is a bit more complex than the
BooksComponent
because it has to handle the case the book is not found. This is the (simplified) component file:
// book-details.component.ts
@Component({
template: `
<h1>{{ book.title }}</h1>
<div>
<a [routerLink]="['./', 'general']">General</a>
<a *ngIf="book.isAdmin" [routerLink]="['./', 'admin']">Admin</a>
</div>
<router-outlet></router-outlet>
<a [routerLink]="['../', book.id + 1]">Try next book</a>
`,
})
export default class BookDetailsComponent {
@Input() book!: Resolved<typeof loader>;
}
This is the loader:
// book-details.loader.ts
export const loader = (route: ActivatedRouteSnapshot) => {
const bookId = route.paramMap.get('bookId');
return from(inject(BooksService).getBook(bookId!)).pipe(filter(Boolean));
};
Here we get a book by id from the BooksService
. We then transform the Promise<Book>
to an Observable<Book>
and filter out the
undefined
value so when the book is not found, the resolve will emit nothing. This will trigger the NavigationCancel
event, which will
lead to a redirect to the 404 page because of the listener in the AppComponent
as described under
Setting things straight.
The router config looks like this:
import { loader as bookLoader } from './book-details.loader';
const routes = [
// ...other routes
{
path: 'books/:bookId',
loadComponent: () => import('./book-details.component'),
runGuardsAndResolvers: 'always',
resolve: { book: bookLoader },
},
];
The runGuardsAndResolvers
property is set to 'always'
to make sure the resolve is always called when switching tabs. This is needed
because the bookId
param is not changed when switching tabs, so the router thinks it's the same route and doesn't call the resolve again.
Admin tab authorization guard
The admin tab should only be visible when the user is an admin of the book. This check is also done in a resolve:
// book-details-admin.loader.ts
import { loader as bookLoader } from './book-details.loader';
export const loader = (route: ActivatedRouteSnapshot) => {
return of(route.parent?.data).pipe(
map((data) => (data!['book'] as Resolved<typeof bookLoader>).isAdmin ?? false),
filter(Boolean),
);
};
To prevent refetching the book, we reuse the already fetched book from the parent route. We then check if the user is an admin of the book
or otherwise return false
and filter only truthy values using the filter
operator. This will trigger the NavigationCancel
event, which
will lead to a redirect to the 404 page because of the listener in the AppComponent
as described under
Setting things straight.
The router config looks like this:
import { loader as bookLoader } from './book-details.loader';
import { loader as adminLoader } from './book-details-admin.loader';
const routes = [
// ...other routes
{
path: 'books/:bookId',
loadComponent: () => import('./book-details.component'),
runGuardsAndResolvers: 'always',
resolve: { book: bookLoader },
// ⬇️ this is the new part
children: [
{ path: 'general', loadComponent: () => import('./book-details-general.component') },
{ path: 'admin', loadComponent: () => import('./book-details-admin.component'), resolve: { isAdmin: adminLoader } },
{ path: '', redirectTo: 'general', pathMatch: 'full' },
],
},
];
Here you can see the adminLoader
is passed to the resolve
property of the admin
child route. Also, the default route is set to
general
so the user will always see something when navigating to /books/:bookId
.
Mutating data
Need to reload data when the user changes something? Just redirect! If needed, to the same route. The gotcha here is to set the
runGuardsAndResolvers
property to 'always'
for each component in the router config, otherwise the resolve won't be called again.
Also, when redirecting to the same route you need to use the 'reload'
option for the onSameUrlNavigation
property of the router config,
otherwise the resolve won't be called again. This can be set globally in the provideRouter
function or per router.navigate
.
Reloading too much
Setting runGuardsAndResolvers
to 'always'
for each component in the router config will lead to the resolve being called on each
navigation (duh!). To prevent this, we could introduce the Ng Query library in the resolvers to have more
control over when to call the actual HttpClient or reuse the cache (and when to invalidate). But this depends heavily on your setup and is
pure optimization, so I won't go into detail here.
Conclusion
By using the withComponentInputBinding
setting of the router, data loading in a client-side rendered Angular app can become very similar
to Remix loaders. Together with the paramsInheritanceStrategy
setting set to 'always'
, it now becomes easy to resolve, inject and reuse
loaded data down the component tree. To implement a proper '404 not found' user experience, we can use the
NavigationCancellationCode.NoDataFromResolver
case by not returning anything from a resolve function.
Using resolve functions to load data prevents the need of a separate state management solution, which means less code and less dependencies
🥳. The gotcha is that the runGuardsAndResolvers
setting should probably be 'always'
everywhere, otherwise the resolve won't be called
upon navigation and a bug is introduced. But all in all, this is a small price to pay for the simplicity of the solution.