Preloading data with Angular router

How to load data that is required for displaying a page with Angular 8 router.

Autorin
Lilla Fésüs
Datum
3. Januar 2022
Lesedauer
15 Minuten

Sourcecode: https://github.com/LillaFesues/myFarm
Angular 8

Have you ever looked at your code and thought maybe it wasn’t the right way to implement it? I once heard that if you look at your code from two years ago and see no problems with it, then you haven’t learned anything. Well, my code wasn’t even that old as I started to wonder if there is another way to solve a particular problem. I took the opportunity to re-examine my work during our Fusonic Mega Hackathon 2020 and try out other solutions.

Problem

Due to our privacy policy I needed to anonymize the problem description and came up with an example that covers the key requirements. Since I live in a rather rural state of Austria, Vorarlberg, it was obvious to choose an example that has something to do with farming. So here we go, let me introduce you: myFarm.

dashboard

The application helps farmers to keep track of their animals over time. On the dashboard they see which animals and how many of them do they have in each season. On the details page they see each animal listed with their names.

Challenges

There are both technical and conceptual challenges that we need to tackle. Let’s start with the latter.

Conceptual

  • One farmer can have none, one or many farms.
  • The animals are tracked by season: winter, spring, summer and autumn.
  • The seasons are not necessarily the same across the farms. For example, a farm can be already inactive and have seasons between 2017 and 2018, while another can be a new and still running one with seasons between 2019 and now.
  • If the user changes the selection on the details page, go back to the dashboard, because we cannot guarantee valid data anymore. For example, if the user had chickens in 2019 winter and is looking at their details, and switches to 2019 spring, it is not guaranteed anymore that he still had any chickens in that season at all.

Technical

On the first visit

  • The user must be able to enter the root URL and land on the dashboard
    • with the first farm selected
    • with the farm’s first season selected
  • The user must be able to enter the id of a farm and land on the dashboard
    • with the farm from URL selected
    • with the farm’s first season selected
    • if the farm’s id is invalid redirect to error page
  • The user must be able to enter the id of both a farm and a season and land on the dashboard
    • with the farm from URL selected
    • with the season from the URL selected
    • if either the farm’s or the season’s id is invalid then redirect to error page

On the visits after

  • The last selected farm’s id and the last selected season’s id must be saved
  • The user must be able to enter the root URL and land on the dashboard
    • with the last visited farm selected or if it is not valid anymore then with the first farm selected
    • with the last visited season selected or if it is not valid anymore then with the farm’s first season selected
  • The user must be able to enter the id of a farm and land on the dashboard
    • with the farm from URL selected
    • if the farm from the URL is the last visited farm, then with the last season selected, otherwise - or if the last visited season does not exist anymore - with the farm’s first season selected
    • if the farm’s id is invalid redirect to error page
  • The user must be able to enter the id of both a farm and a season and land on the dashboard
    • with the farm from URL selected
    • with the season from the URL selected
    • if either the farm’s or the season’s id is invalid then redirect to error page
Whaat?!

Here again with examples.

On the first visit

  • / -> /1/11/dashboard
  • /2 -> /2/21/dashboard
  • /2/22 -> /2/22/dashboard
  • /999/999 -> /error

On the visits after

Last selected farm: 2 and season: 22.

  • / -> /2/22/dashboard
  • / -> /1/11/dashboard - if last selected farm is not available anymore
  • / -> /2/21/dashboard - if last selected season is not available anymore
  • /2 -> /2/22/dashboard
  • /2 -> /2/21/dashboard - if last selected season is not available anymore
  • /1 -> /1/11/dashboard
  • /2/21 -> /2/21/dashboard
  • /999/999 -> /error
Ok still. What?!

Here again with a flow chart.

flowchart

Current solution

We now use guards and query parameters in order to achieve all of this, but are query parameters really the right choice for something required? Let’s see what alternatives do we have?

Research

Before jumping headfirst into the solution, I researched briefly and read the Angular - Routing guide very carefully. Although I recommend with all my heart you do the same, if you are not interested in the basics, you can jump directly to the solution.

I collected some relevant parts, hints and tips from the guide that helped me to come up with the solution so you can screen through them quickly.

The order of the routes in the configuration matters and this is by design. The router uses a first-match wins strategy when matching routes, so more specific routes should be placed above less specific routes.

When subscribing to an observable in a component, you almost always arrange to unsubscribe when the component is destroyed.

There are a few exceptional observables where this is not necessary. The ActivatedRoute observables are among the exceptions.

The ActivatedRoute and its observables are insulated from the Router itself. The Router destroys a routed component when it is no longer needed and the injected ActivatedRoute dies with it.

Feel free to unsubscribe anyway. It is harmless and never a bad practice.

If you were using a real world API, there might be some delay before the data to display is returned from the server. You don’t want to display a blank component while waiting for the data.

It’s preferable to pre-fetch data from the server so it’s ready the moment the route is activated. This also allows you to handle errors before routing to the component. There’s no point in navigating to a crisis detail for an id that doesn’t have a record. It’d be better to send the user back to the CrisisList that shows only valid crisis centers.

In summary, you want to delay rendering the routed component until all necessary data have been fetched.

You need a resolver.

The Router guards require an observable to complete, meaning it has emitted all of its values. You use the take operator with an argument of 1 to ensure that the Observable completes after retrieving the first value from the Observable returned by the getCrisis method.

Solution

Sourcecode: https://github.com/LillaFesues/myFarm
Angular 8

Alright, I need a resolver. A resolver is a data provider that can be used with the router to resolve data during navigation. It allows me to handle errors before routing to the component and otherwise it makes the data available for the component immediately on initialization. Within the resolver I can check which farms and seasons the user has and redirect to the correct route. Let’s see how this is done in detail.

The AppComponent knows about three lazy loaded modules, FarmsModule, ProfileModule and ErrorModule. We’ve learned that the order of the routes is important, so the empty route comes last.

src/app/app-routing.module.ts

const routes: Routes = [ { path: 'profile', loadChildren: () => import('./features/profile/profile.module').then(m => m.ProfileModule) }, { path: 'error', loadChildren: () => import('./error/error.module').then(m => m.ErrorModule) }, { path: '', loadChildren: () => import('./features/farms/farms.module').then(m => m.FarmsModule) } ]; @NgModule({ imports: [ RouterModule.forRoot(routes, { enableTracing: environment.enableTracing }) ], exports: [ RouterModule ] }) export class AppRoutingModule { }
Sidenote about the enableTracing parameter

If you need to see what events are happening during the navigation lifecycle, there is the enableTracing option as part of the router’s default configuration. This outputs each router event that took place during each navigation lifecycle to the browser console. This should only be used for debugging purposes. You set the enableTracing: true option in the object passed as the second argument to the RouterModule.forRoot() method.

 

FarmsModule

We ignore ProfileModule and ErrorModule for now and focus on the FarmsModule. The FarmsModule has the following routing definition:

src/app/features/farms/farms-routing.module.ts

const routes: Routes = [ { path: ':farmId/:seasonId/:animalType/details', component: FarmsComponent, loadChildren: () => import('./details/details.module').then(m => m.DetailsModule), resolve: { farmsAndSeasons: FarmsAndSeasonsResolver } }, { path: ':farmId/:seasonId/dashboard', component: FarmsComponent, loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule), resolve: { farmsAndSeasons: FarmsAndSeasonsResolver } }, { path: ':farmId/:seasonId', canActivate: [ FarmsAndSeasonsGuard ], component: FarmsComponent }, { path: ':farmId', canActivate: [ FarmsAndSeasonsGuard ], children: [] }, { path: '', canActivate: [ FarmsAndSeasonsGuard ], children: [] } ]; @NgModule({ imports: [ RouterModule.forChild(routes) ], exports: [ RouterModule ] }) export class FarmsRoutingModule { }

'', ':farmId' and ':farmId/:seasonId' are fulfilling the redirect purpose based on the logic I’ve described in the problem section, and the first components are rendered on ':farmId/:seasonId/dashboard' and ':farmId/:seasonId/:animalType/details'. We see, that latter routes have a FarmsAndSeasonsResolver. This resolver ensures, that the data is loaded and the ids are valid before the component is routed, otherwise it redirects to the error page.

Sidenote on guard vs. resolver

You see that the redirecting routes do not have a resolver, but a guard instead. I must say, that my guard and resolver are very similar: they request the data and check if the ids are valid. The only difference is that the guard redirects to /:farmsId/:seasonId/dashboard in a truthy case and returns true, while the resolver does only redirect in an erroneous case and returns the data directly.

src/app/features/farms/farms-and-seasons.resolver.ts

@Injectable({ providedIn: 'root' }) export class FarmsAndSeasonsResolver implements Resolve<any> { constructor(private router: Router, private farmsAndSeasonsService: FarmsAndSeasonsDataService) { } public resolve(route: ActivatedRouteSnapshot): Observable<any> { const farmId = route.paramMap.get('farmId') ? +route.paramMap.get('farmId') : null; const seasonId = route.paramMap.get('seasonId') ? +route.paramMap.get('seasonId') : null; return this.farmsAndSeasonsService.getFarmsAndSeasons(farmId, seasonId) .pipe( take(1), mergeMap(farmsAndSeasons => { if (farmsAndSeasons == null) { this.router.navigate([ environment.hasFarm ? 'error' : 'empty' ]); return EMPTY; } return of(farmsAndSeasons); }) ); } }
@Injectable({ providedIn: 'root' }) export class FarmsAndSeasonsGuard implements CanActivate { constructor( private farmAndSeasonService: FarmsAndSeasonsDataService, private router: Router ) { } public canActivate(route: ActivatedRouteSnapshot, _): Observable<boolean> { const farmId = route.paramMap.get('farmId') ? +route.paramMap.get('farmId') : null; const seasonId = route.paramMap.get('seasonId') ? +route.paramMap.get('seasonId') : null; return this.farmAndSeasonService.getFarmsAndSeasons(farmId, seasonId) .pipe( take(1), map(farmsAndSeasons => { if (farmsAndSeasons == null) { this.router.navigate([ environment.hasFarm ? 'error' : 'empty' ]); return false; } this.router.navigate([ farmsAndSeasons.selectedFarmId, farmsAndSeasons.selectedSeasonId, 'dashboard' ]); return true; }) ); } }

The redirecting routes do not need the router to keep the data in memory and we only want to redirect to the right route. A resolver would be an overkill, and furthermore, we would need to check on which route we are currently. Are we on dashboard or details do not redirect, otherwise go to dashboard. Is it really worth avoiding code repetition just to bring more complexity into our resolver?

What it definitely needs is some kind of caching in our data service, so we won’t fire another API request in our resolver once we’ve been successfully redirected from our guard.

 

DashboardModule

The lazy loaded DashboardModule has its own resolver too, and it needs to load the dashboard data for the previously correctly evaluated farmId and seasonId. It is not anymore as spectacular as the FarmsModule or the FarmsAndSeasonsResolver.

src/app/features/farms/dashboard/dashboard-routing.module.ts

const routes: Routes = [ { path: '', component: DashboardComponent, resolve: { data: DashboardResolver } } ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) export class DashboardRoutingModule { }
@Injectable({ providedIn: 'root' }) export class DashboardResolver implements Resolve<any> { constructor(private homeDataService: DashboardDataService) { } public resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> | Observable<never> { const farmId = +route.paramMap.get('farmId'); const seasonId = +route.paramMap.get('seasonId'); return this.homeDataService.getDashboardData(farmId, seasonId).pipe(take(1)); } }

DetailsModule

The DetailsModule is sightly more interesting than the DashboardModule, because its resolver needs to additionally check if animalType is valid.

src/app/features/farms/details/details-routing.module.ts

2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const routes: Routes = [ { path: '', component: DetailsComponent, resolve: { data: DetailsResolver } } ]; @NgModule({ imports: [ RouterModule.forChild(routes) ], exports: [ RouterModule ] }) export class DetailsRoutingModule { }
@Injectable({ providedIn: 'root' }) export class DetailsResolver implements Resolve<any> { constructor(private router: Router, private detailsDataService: DetailsDataService) { } public resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> | Observable<never> { const farmId = +route.paramMap.get('farmId'); const seasonId = +route.paramMap.get('seasonId'); const animalType = AnimalTypes[route.paramMap.get('animalType')]; if (!animalType) { this.router.navigate([ 'error' ]); return EMPTY; } return this.detailsDataService.getDetailsData(farmId, seasonId, animalType) .pipe(take(1)); } }

The almost finished app

With some Material Design Lite components, data services and decent mock data we already have a working application that satisfies the requirements and handles errors. You can find the code here.

Loading

We almost forgot about one crucial aspect in web development: slow internet connection. The app must have some kind of feedback for the user that it is working on something. One way is to implement a loading spinner in the app.component that is displayed whenever a navigation is in progress.

src/app/app.component.ts

@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: [ './app.component.scss' ] }) export class AppComponent { public loading = false; constructor(private router: Router) { this.router.events.subscribe(event => { switch (true) { case event instanceof NavigationStart: { this.loading = true; break; } case event instanceof NavigationEnd: case event instanceof NavigationCancel: case event instanceof NavigationError: { this.loading = false; break; } } }); } }
... <div *ngIf="loading" class="overlay"> <app-spinner></app-spinner> </div>

The downside

Since we don’t activate the route before the data has arrived, the loading spinner is shown on the previous route.

Sidenote on how to simulate slow network

My environment.ts has a delay parameter that is used in the data services to delay the returning of the mock data. Since we do not make any real API requests, Chrome Developer Tool’s Network tab to simulate slow network is of no use for us.

 

Let’s see how navigation looks like from the profile page to the dashboard with some delay.

There is unfortunately one big issue with user experience. With the original guards and query parameter solution, we are able to display a shimmering animation in the place of the divs and spans that are going to be rendered. The structure is only known in the routed component, which is now not loaded until the data has arrived, and we can only display a global loading animation. On one hand the global loading takes a lot of logic and css away from the routed components, but on the other hand, your designer may not be happy about this restriction.

Conclusion

Sourcecode: https://github.com/LillaFesues/myFarm
Angular 8

The resolver solution feels clean, everything is encapsulated, small and straightforward, but it introduced a huge user experience restriction, that we may not be able to ignore. You will need to choose the proper tool for the proper job and evaluate the advantages and disadvantages of any solution.

Thank you!

I would like to thank Fusonic at this point for the awesome Mega Hackathon 2020 that enabled me to work on something a customer would rarely pay for and learn a lot during my trial and error experimenting.

Mehr davon?

Future-of-Work_Titelbild
Organisationskultur
Future of Work
13. Januar 2022 | 6 Min.
Tech-Jahresrückblick Titelbilder
News
Der Fusonic-Tech-Jahresrückblick 2021
23. Dezember 2021 | 3 Min.

Kontaktformular

*Pflichtfeld
*Pflichtfeld
*Pflichtfeld
*Pflichtfeld

Wir schützen deine Daten

Wir bewahren deine persönlichen Daten sicher auf und geben sie nicht an Dritte weiter. Mehr dazu erfährst du in unseren Datenschutzbestimmungen.