Router Lifecycle Hooks (DRAFT) 6.0
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 theonActivate()
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 assignsname
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 fromcrisis.name
toname
.
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
- crisis
- web
- index.html
- main.dart
- styles.css
- lib
Here are key files for this version of the sample app: