Router Lifecycle Hooks (DRAFT) 6.0


If you notice any issues with this page, please report them.

Milestone

At the moment, any user can navigate anywhere in the app 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 function. The returned boolean value affects the router’s navigation behavior either canceling navigation (and staying on the current view) or allowing navigation to proceed.

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

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

You’re already familiar with the OnActivate hook that was introduced in an earlier milestone. You’ll learn about other hooks below.

Handling unsaved crisis name changes

The app currently accepts every change to a hero name immediately without delay or validation. More often, you’d 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 unsaved changes when the user navigates away? Just ignoring changes offers a bad user experience.

Let the user decide what to do. If the user cancels navigation, then the app can stay put and allow more changes. If the user approves, then 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, 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 CanNavigate hook.

Add Save and Cancel buttons

The sample app 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 component so that user changes to the crisis name are temporary until saved (in contrast, HeroComponent name changes will remain immediate).

Update CrisisComponent:

  • Add a string name field to hold the crisis name while it is being edited.
  • Initialize name in the onActivate() hook.

    lib/src/crisis/crisis_component.dart (onActivate)

    void onActivate(_, RouterState current) async {
      final id = getId(current.parameters);
      if (id == null) return null;
      crisis = await (_crisisService.get(id));
      name = crisis?.name;
    }
  • Add a save() method which assigns name to the selected crisis before navigating back to the crisis list.

    lib/src/crisis/crisis_component.dart (save)

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

Update the crisis component template by doing the following:

  • 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/crisis_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>

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

CanNavigate 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 ask the user to make that choice explicitly in a confirmation dialog. To implement this functionality you’ll need a dialog service; the following simple implementation will do.

lib/src/crisis/dialog_service.dart

import 'dart:async';
import 'dart:html';

class DialogService {
  Future<bool> confirm(String message) async =>
      window.confirm(message ?? 'Ok?');
}

Add DialogService to the CrisisListComponent providers list so that the service is available to all components in the crises component subtree:

lib/src/crisis/crisis_list_component.dart (providers)

providers: [
  ClassProvider(CrisisService),
  ClassProvider(DialogService),
],

Next, implement a router CanNavigate lifecycle hook in CrisisComponent:

  • Add the router CanNavigate 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 canNavigate() lifecycle hook:

lib/src/crisis/crisis_component.dart (canNavigate)

Future<bool> canNavigate() async {
  return crisis?.name == name ||
      await _dialogService.confirm('Discard changes?');
}

Since the router calls the canNavigate() method when necessary, so you don’t need to worry about the different ways that the user can navigate away.

CanDeactivate hook

If you can decide whether it’s ok to navigate away from a component solely based on the component’s state, then use CanNavigate. Sometimes you need to know the next router state: for example, if the current and next route haven’t changed, but navigation was triggered for another reason such as an added query parameter. In such cases, use the CanDeactivate hook to selectively prevent deactivation.

The crisis component doesn’t need a canDeactivate() lifecycle method, but if it had one, the method signature would be like this:

lib/src/crisis/crisis_component.dart (canDeactivate)

Future<bool> canDeactivate(RouterState current, RouterState next) async {
  ...
}

The canNavigate() method is more efficient to use because the router doesn’t need to precompute the (potential) next router state. Favor using CanNavigate over CanDeactivate when possible.

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. You’ve been using the OnActivate hook in the hero list and the crisis list components.

Add OnDeactivate to the list of classes implemented by CrisisComponent. Add the following onDeactivate() hook, and add print statements to the contructor and activate hook as shown:

lib/src/crisis/crisis_component.dart (excerpt)

CrisisComponent(this._crisisService, this._router, this._dialogService) {
  print('CrisisComponent: created');
}
// ···
void onActivate(_, RouterState current) async {
  print('CrisisComponent: onActivate: ${_?.toUrl()} -> ${current?.toUrl()}');
  // ···
}
// ···
void onDeactivate(RouterState current, _) {
  print('CrisisComponent: onDeactivate: ${current?.toUrl()} -> ${_?.toUrl()}');
}
// ···
Future<bool> canNavigate() async {
  print('CrisisComponent: canNavigate');
  return crisis?.name == name ||
      await _dialogService.confirm('Discard changes?');
}

open_in_browser Refresh the browser and open the JavaScript console. Click the crises tab 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:

CrisisComponent: created
CrisisComponent: onActivate: crises -> crises/1
CrisisComponent: canNavigate
CrisisComponent: onDeactivate: crises/1 -> crises/2
CrisisComponent: created
CrisisComponent: onActivate: crises/1 -> crises/2
CrisisComponent: canNavigate
CrisisComponent: onDeactivate: crises/2 -> crises/3
CrisisComponent: created
CrisisComponent: onActivate: crises/2 -> crises/3

When a component implements the OnDeactivate interface, the router calls the component’s onDeactivate() 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 interface

Add CanReuse as a mixin so that it implements the CanReuse interface, and uses the canReuse() hook implementation that always returns true.

lib/src/crisis/crisis_component.dart (CanReuse)

class CrisisComponent extends Object
    with CanReuse
    implements CanNavigate, OnActivate, OnDeactivate {
  // ···
}

open_in_browser Refresh the browser and click the crises tab, then select a few crises. You’ll notice that crisis components still get created after each crisis selection. This is because a component is reusable only if its parent is reusable.

Add CanReuse as a mixin to the crisis list component:

lib/src/crisis/crisis_list_component.dart (CanReuse)

class CrisisListComponent extends Object
    with CanReuse
    implements OnActivate, OnDeactivate {
  // ···
}

open_in_browser Refresh the browser and select a few 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 clicking the Heroes tab.

App code

After these changes, the folder structure looks like this:

  • router_example
    • lib
      • app_component.dart
      • src
        • crisis
          • crises_component.{css,dart,html}
          • crisis.dart
          • crisis_list_component.dart
          • crisis_component.{css,dart,html}
          • crisis_service.dart
          • dialog_service.dart (new)
          • mock_crises.dart
        • hero
          • hero.dart
          • hero_component.{css,dart,html}
          • hero_service.dart
          • hero_list_component.{css,dart,html}
          • mock_heroes.dart
    • web
      • index.html
      • main.dart
      • styles.css

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

|import 'dart:async'; | |import 'package:angular/angular.dart'; |import 'package:angular_router/angular_router.dart'; | |import '../instance_logger.dart'; |import 'crisis.dart'; |import 'crisis_service.dart'; |import 'dialog_service.dart'; |import 'routes.dart'; | |@Component( | selector: 'my-crises', | templateUrl: 'crisis_list_component.html', | styleUrls: ['crisis_list_component.css'], | directives: [coreDirectives, RouterOutlet], | providers: [ | ClassProvider(CrisisService), | ClassProvider(DialogService), | ], | exports: [RoutePaths, Routes], |) |class CrisisListComponent extends Object | with CanReuse, InstanceLogger | implements OnActivate, OnDeactivate { | final CrisisService _crisisService; | final Router _router; | List<Crisis> crises; | Crisis selected; | String get loggerPrefix => null; // 'CrisisListComponent'; | | CrisisListComponent(this._crisisService, this._router) { | log('created'); | } | | Future<void> _getCrises() async { | crises = await _crisisService.getAll(); | } | | @override | void onActivate(_, RouterState current) async { | log('onActivate: ${_?.toUrl()} -> ${current?.toUrl()}; ' | 'selected.id = ${selected?.id}'); | await _getCrises(); | selected = _selectHero(current); | log('onActivate: set selected.id = ${selected?.id}'); | } | | @override | void onDeactivate(RouterState current, RouterState next) { | log('onDeactivate: ${current?.toUrl()} -> ${next?.toUrl()}'); | } | | Crisis _selectHero(RouterState routerState) { | final id = getId(routerState.parameters); | return id == null | ? null | : crises.firstWhere((e) => e.id == id, orElse: () => null); | } | | void onSelect(Crisis crisis) async { | log('onSelect requested for id = ${crisis?.id}'); | final result = await _gotoDetail(crisis.id); | if (result == NavigationResult.SUCCESS) { | selected = crisis; | } | log('onSelect _gotoDetail navigation $result; ' | 'selected.id = ${selected?.id}'); | } | | String _crisisUrl(int id) => | RoutePaths.crisis.toUrl(parameters: {idParam: '$id'}); | | Future<NavigationResult> _gotoDetail(int id) => | _router.navigate(_crisisUrl(id)); |} |import 'dart:async'; | |import 'package:angular/angular.dart'; |import 'package:angular_forms/angular_forms.dart'; |import 'package:angular_router/angular_router.dart'; | |import '../instance_logger.dart'; |import 'crisis.dart'; |import 'crisis_service.dart'; |import 'dialog_service.dart'; |import 'route_paths.dart'; | |@Component( | selector: 'my-crisis', | templateUrl: 'crisis_component.html', | styleUrls: ['crisis_component.css'], | directives: [coreDirectives, formDirectives], |) |class CrisisComponent extends Object | with CanReuse, InstanceLogger | implements CanDeactivate, CanNavigate, OnActivate, OnDeactivate { | Crisis crisis; | String name; | final CrisisService _crisisService; | final Router _router; | final DialogService _dialogService; | String get loggerPrefix => 'CrisisComponent'; | | CrisisComponent(this._crisisService, this._router, this._dialogService) { | log('created'); | } | | @override | void onActivate(_, RouterState current) async { | log('onActivate: ${_?.toUrl()} -> ${current?.toUrl()}'); | final id = getId(current.parameters); | if (id == null) return null; | crisis = await (_crisisService.get(id)); | name = crisis?.name; | log('onActivate: set name = $name'); | } | | @override | void onDeactivate(RouterState current, _) { | log('onDeactivate: ${current?.toUrl()} -> ${_?.toUrl()}'); | } | | Future<void> save() async { | log('save: $name (was ${crisis?.name}'); | crisis?.name = name; | goBack(); | } | | Future<NavigationResult> goBack() => | _router.navigate(RoutePaths.home.toUrl()); | | @override | Future<bool> canNavigate() async { | log('canNavigate: ${crisis?.name} == $name?'); | return crisis?.name == name || | await _dialogService.confirm('Discard changes?'); | } | | @override | // For illustration purposes only; this method is not used in the component. | Future<bool> canDeactivate(RouterState current, RouterState next) async { | log('canDeactivate: ${current?.toUrl()} -> ${next?.toUrl()}'); | return true; | } |} |<div *ngIf="crisis != null"> | <h2>{{crisis.name}}</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'; class DialogService { Future<bool> confirm(String message) async => window.confirm(message ?? 'Ok?'); }