Router Lifecycle Hooks 4.0

Send feedback

Milestone

At the moment, any user can navigate anywhere in the application anytime. That’s not always the right thing to do.

  • 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 OK to discard pending changes rather than save them.

You can provide router lifecycle hooks to handle these scenarios.

A router lifecycle hook is a boolean synchronous or asynchronous function; the returned boolean value affects the router’s navigation behavior:

  • true: navigation proceeds
  • false: the router cancels navigation and stays on the current view

Lifecycle hooks can also instruct the router to navigate to a different component.

The router lifecycle hooks supplement, and are distinct from, component lifecycle hooks.

Handling unsaved changes

Back in the “Heroes” workflow, the app accepts every change to a hero immediately without hesitation or validation.

In the real world, you might have to accumulate a user’s changes so that the app can, for example:

  • Validate across fields
  • Validate on the server
  • Hold changes in a pending state until the user confirms them as a group or cancels and reverts all changes

What should be done with unvalidated and unsaved changes when the user navigates away? You can’t just leave and risk losing the user’s changes — that would be a terrible user experience.

Let the user decide what to do. If the user cancels, the app can stay put and allow more changes. If the user approves, the app can save.

The app might still delay navigation until the save succeeds. If you let the user move to the next screen immediately and the save failed (perhaps the data are ruled invalid), you would have lost the context of the error.

The app can’t block while waiting for the server — that’s not possible in a browser. The app needs to stop the navigation while waiting, asynchronously, for the server to return with its answer.

For this, you need the CanDeactivate hook.

Save and cancel, without handling unsaved changes

The sample application doesn’t talk to a server. Fortunately, you have another way to demonstrate an asynchronous router hook.

Before defining a hook, you’ll need to make the following edits to the crisis details component so that user changes to the crisis name are temporary until saved (in contrast, HeroDetailComponent name changes will remain immediate).

Update CrisisDetailComponent:

  • Add a string class field called name to hold the crisis name while it is being edited.
  • Update the ngOnInit method so that name is initialized to the selected crisis name.

    lib/src/crisis_center/crisis_detail_component.dart (ngOnInit)

    Future<Null> ngOnInit() async { var _id = _routeParams.get('id'); var id = int.parse(_id ?? '', onError: (_) => null); if (id != null) crisis = await (_crisisService.getCrisis(id)); if (crisis != null) name = crisis.name; }
  • Add a save method which assigns name to the selected crisis before navigating back to the crisis list.

    lib/src/crisis_center/crisis_detail_component.dart (save)

    Future<Null> save() async { crisis.name = name; goBack(); }

As illustrated next, update the crisis details component template by

  • Renaming the Back button to Cancel.
  • Adding a Save button with a click event binding to the save method.
  • Changing the ngModel expression from crisis.name to name.

lib/src/crisis_center/crisis_detail_component.html (save and cancel)

<div> <label>name: </label> <input [(ngModel)]="name" placeholder="name" /> </div> <button (click)="goBack()">Cancel</button> <button (click)="save()">Save</button>

Refresh the browser and try out the new crisis details save and cancel features.

CanDeactivate hook to handle unsaved changes

What if the user tries to navigate away without saving or canceling? The user could push the browser back button or click the heroes link. Both actions trigger a navigation. Should the app save or cancel automatically?

It currently does neither. Instead you’ll want to ask the user to make that choice explicitly in a confirmation dialog box that waits asynchronously for the user’s answer.

You could wait for the user’s answer with synchronous, blocking code. The app will be more responsive, and can do other work, by waiting for the user’s answer asynchronously. Waiting for the user asynchronously is like waiting for the server asynchronously.

To implement this functionality you’ll need a dialog service; the following simple implementation will do.

lib/src/crisis_center/dialog_service.dart

import 'dart:async'; import 'dart:html'; import 'package:angular/angular.dart'; @Injectable() class DialogService { Future<bool> confirm(String message) async => window.confirm(message ?? 'Ok?'); }

Add DialogService to the CrisisCenterComponent providers list so that the service is available to all components in the crisis center component subtree.

Next, to implement a router CanDeactivate lifecycle hook in CrisisDetailComponent:

  • Add the router CanDeactivate interface to the class’s list of implemented interfaces.
  • Add a private field and constructor argument to hold an injected instance of a DialogService — remember to import it.
  • Add the following routerCanDeactivate lifecycle hook method:

lib/src/crisis_center/crisis_detail_component.dart (routerCanDeactivate)

@override FutureOr<bool> routerCanDeactivate(next, prev) => crisis == null || crisis.name == name ? true as FutureOr<bool> : _dialogService.confirm('Discard changes?');

Notice that the routerCanDeactivate method can return synchronously; it returns true immediately if there is no crisis or there are no pending changes. But it can also return a Future<bool> and the router will await its result.

Two critical points

  • The router hook is optional: a component class can implement the appropriate hook interface or not.
  • The router calls the hook method so you don’t need to worry about the different ways that the user can navigate away. That’s the router’s job. You simply write this method and let the router take it from there.

OnActivate and OnDeactivate interfaces

Each route is handled by a component instance. Generally, when the router processes a route change request, it performs the following actions:

  • It deactivates and then destroys the component instance handling the current route, if any.
  • It instantiates the component class registered to handle the new route instruction, and then activates the new instance.

Lifecycle hooks exist for both component activation and deactivation. To implement these hooks for crisis details, add both OnActivate and OnDeactivate to the list of classes implemented by CrisisDetailComponent. Also add these trivial lifecycle method implementations:

lib/src/crisis_center/crisis_detail_component.dart (excerpt)

@override void routerOnActivate(next, prev) { print('Activating ${next.routeName} ${next.urlPath}'); } @override void routerOnDeactivate(next, prev) { print('Deactivating ${prev.routeName} ${prev.urlPath}'); }

Refresh the browser and open the JavaScript console. Now visit the crisis center and select each of the first three crises, one at a time. Finally, select Cancel in the crisis detail view. You should see the following sequence of messages:

Activating CrisisDetail 1 Deactivating CrisisDetail 1 Activating CrisisDetail 2 Deactivating CrisisDetail 2 Activating CrisisDetail 3 Deactivating CrisisDetail 3

When a component implements the OnDeactivate interface, the router calls the component’s routerOnDeactivate method before the instance is destroyed. This gives component instances an opportunity to perform tasks like cleanup and resource deallocation before being deactivated.

Given the nature of a crisis detail component’s responsibilities, it seems wasteful to create a new instance each time. A single instance could handle all crisis detail route instructions.

To tell the router that a component instance might be reusable, use the CanReuse lifecycle hook.

CanReuse and OnReuse interfaces

Add CanReuse to the list of implemented interfaces along with the following simple hook implementation:

lib/src/crisis_center/crisis_detail_component.dart (routerCanReuse)

@override FutureOr<bool> routerCanReuse(next, prev) => true;

Refresh the browser and visit the crisis center, then select a few crises. You’ll notice that the crisis details view doesn’t get updated. This is because the selected crisis gets set once when Angular invokes the ngOnInit method. Now that the CrisisDetailComponent instance is being reused, it needs to know about changes in route parameters.

That is what the OnReuse lifecycle hook is for. Add it to the list of interfaces implemented by the component and define the following hook:

lib/src/crisis_center/crisis_detail_component.dart (routerOnReuse)

@override Future<Null> routerOnReuse(ComponentInstruction next, prev) => _setCrisis(next.params['id']);

The hook extracts the crisis id from the next route instruction parameters. The new _setCrisis method is defined from a refactoring of ngOnInit:

lib/src/crisis_center/crisis_detail_component.dart (ngOnInit)

Future<Null> ngOnInit() => _setCrisis(_routeParams.get('id')); Future<Null> _setCrisis(String idAsString) async { var id = int.parse(idAsString ?? '', onError: (_) => null); if (id != null) crisis = await (_crisisService.getCrisis(id)); if (crisis != null) name = crisis.name; }

Refresh the browser and select a few crisis center crises. The view details should now reflect the selected crisis. Also notice how the OnDeactivate hook is called only if you navigate away from a crisis detail view — such as by clicking crisis detail view Cancel button, or selecting the Heroes view.

Application code

After these changes, the folder structure looks like this:

  • router_example
    • lib
      • app_component.dart
      • src
        • crisis_center
          • crises_component.{css,dart,html}
          • crisis.dart
          • crisis_center_component.dart
          • crisis_detail_component.{css,dart,html}
          • crisis_service.dart
          • dialog_service.dart (new)
          • mock_crises.dart
        • heroes
          • hero.dart
          • hero_detail_component.{css,dart,html}
          • hero_service.dart
          • heroes_component.{css,dart,html}
          • mock_heroes.dart
    • web
      • index.html
      • main.dart
      • styles.css

Here are key files for this version of the sample application:

import 'package:angular/angular.dart'; import 'package:angular_router/angular_router.dart'; import 'crisis_service.dart'; import 'crises_component.dart'; import 'dialog_service.dart'; @Component( selector: 'crisis-center', template: ''' <router-outlet></router-outlet> ''', directives: const [ROUTER_DIRECTIVES], providers: const [CrisisService, DialogService]) @RouteConfig(const [ const Route( path: '/...', name: 'Crises', component: CrisesComponent, useAsDefault: true) ]) class CrisisCenterComponent {} import 'dart:async'; import 'package:angular/angular.dart'; import 'package:angular_forms/angular_forms.dart'; import 'package:angular_router/angular_router.dart'; import 'crisis.dart'; import 'crisis_service.dart'; import 'dialog_service.dart'; @Component( selector: 'crisis-detail', templateUrl: 'crisis_detail_component.html', styleUrls: const ['crisis_detail_component.css'], directives: const [CORE_DIRECTIVES, formDirectives], ) class CrisisDetailComponent implements CanDeactivate, CanReuse, OnActivate, OnDeactivate, OnInit, OnReuse { Crisis crisis; String name; final CrisisService _crisisService; final Router _router; final RouteParams _routeParams; final DialogService _dialogService; CrisisDetailComponent(this._crisisService, this._router, this._routeParams, this._dialogService); Future<Null> ngOnInit() => _setCrisis(_routeParams.get('id')); Future<Null> _setCrisis(String idAsString) async { var id = int.parse(idAsString ?? '', onError: (_) => null); if (id != null) crisis = await (_crisisService.getCrisis(id)); if (crisis != null) name = crisis.name; } Future<Null> save() async { crisis.name = name; goBack(); } Future goBack() => _router.navigate([ 'CrisesHome', crisis == null ? {} : {'id': crisis.id.toString()} ]); // TODO: remove cast of true once there is a fix for https://github.com/dart-lang/sdk/issues/25368 @override FutureOr<bool> routerCanDeactivate(next, prev) => crisis == null || crisis.name == name ? true as FutureOr<bool> : _dialogService.confirm('Discard changes?'); @override FutureOr<bool> routerCanReuse(next, prev) => true; @override Future<Null> routerOnReuse(ComponentInstruction next, prev) => _setCrisis(next.params['id']); @override void routerOnActivate(next, prev) { print('Activating ${next.routeName} ${next.urlPath}'); } @override void routerOnDeactivate(next, prev) { print('Deactivating ${prev.routeName} ${prev.urlPath}'); } } <div *ngIf="crisis != null"> <h2>{{crisis.name}} details!</h2> <div> <label>id: </label>{{crisis.id}}</div> <div> <label>name: </label> <input [(ngModel)]="name" placeholder="name" /> </div> <button (click)="goBack()">Cancel</button> <button (click)="save()">Save</button> </div> import 'dart:async'; import 'dart:html'; import 'package:angular/angular.dart'; @Injectable() class DialogService { Future<bool> confirm(String message) async => window.confirm(message ?? 'Ok?'); }