In Flutter's Navigator.pages API, how do Page objects become routes, why do page keys matter, and what must onDidRemovePage do?
answer
- pages are to routes as widgets to elements
- canUpdate: runtimeType and key
- no key, same type: updated in place
- a page left in the list comes back
- onPopPage deprecated for onDidRemovePage
basics
~20 sEach Page creates a route via createRoute, and the Navigator diffs the new pages list against the old one by runtimeType and key. Give each distinct screen a unique key, and remove the popped page from your state in onDidRemovePage.
solid answer
~50 s`Navigator.pages` is an immutable list of `Page` objects, such as `MaterialPage`, that describe the stack. The Navigator treats them like widgets: a page's `createRoute` builds its route once, and on each rebuild the new list is matched against the old one with `canUpdate`, which compares `runtimeType` and `key`. A matched page updates its existing route, keeping its state; an unmatched page creates a new route with a transition. So two article pages without keys match each other and the second article silently replaces the first's content in place, with no transition. `onDidRemovePage` is called when a page-backed route is removed imperatively, by a back press or `Navigator.pop`; it must remove that page from the delegate's state, because a page still in the list on the next rebuild is treated as a page to show. It replaced `onPopPage`, deprecated after v3.16.0-17.0.pre.
code
dart · 40 linesimport 'package:flutter/material.dart';
class DocsNavigator extends StatefulWidget {
const DocsNavigator({super.key});
@override
State<DocsNavigator> createState() => _DocsNavigatorState();
}
class _DocsNavigatorState extends State<DocsNavigator> {
String? _slug;
@override
Widget build(BuildContext context) {
final String? slug = _slug;
return Navigator(
pages: [
MaterialPage<void>(
key: const ValueKey<String>('home'),
child: Scaffold(
body: ListTile(
title: const Text('Layout'),
onTap: () => setState(() => _slug = 'layout'),
),
),
),
if (slug != null)
MaterialPage<void>(
key: ValueKey<String>('doc:$slug'), // one key per article
child: Scaffold(appBar: AppBar(title: Text(slug))),
),
],
onDidRemovePage: (Page<Object?> page) {
if (page.key != const ValueKey<String>('home')) {
setState(() => _slug = null); // otherwise the page comes back
}
},
);
}
}go deeper
Recall that the pages list describes the stack, each Page creates a route, and popped pages must be removed from your state.
Explain canUpdate by runtimeType and key, what happens to two unkeyed pages of the same type, and when onDidRemovePage fires.
Diagnose missing transitions, leaked state between articles and pages that bounce back, and key pages by content identity.
Weigh deriving the stack from state against imperative calls for a large app, including who owns page identity and keys.
## Pages describe, routes live In the declarative API, the `Navigator` receives a list of **`Page`** objects in its `pages` parameter. A `Page` is a lightweight, immutable description; `MaterialPage` and `CupertinoPage` are the common ones. From each page the Navigator creates a **route** with `Page.createRoute`, and the route is the long-lived object that holds the screen's state, animation and overlay entries. The relationship mirrors widgets and elements: | Widgets | Pages | |---|---| | immutable `Widget` | immutable `Page` | | long-lived `Element` / `State` | long-lived `Route` | | `Widget.canUpdate` (runtimeType + key) | `Page.canUpdate` (runtimeType + key) | | rebuild with a new widget tree | rebuild with a new `pages` list | ## How a new list is reconciled On every rebuild the Navigator compares the new `pages` list with the one it already has: 1. A new page that `canUpdate` an existing page **updates** that page's route in place: same route, same `State`, no push transition. 2. A new page with no match **creates** a new route, which animates in. 3. An old page with no match in the new list is **removed**, animating out. Which transitions play is decided by the Navigator's `transitionDelegate`, `DefaultTransitionDelegate` by default. ## Why keys matter `canUpdate` returns true when `runtimeType` and `key` are equal. Two `MaterialPage`s with no key have the same type and a `null` key, so they match. In a documentation app: - going from `[home, article(layout)]` to `[home, article(animation)]` with no keys **updates** the layout route to show animation content. There is no transition, and any `State` (scroll position, expanded sections) carries over from the wrong article; - with `ValueKey('doc:layout')` and `ValueKey('doc:animation')`, the pages do not match, the layout route is removed and a new animation route is pushed, with the expected transition and fresh state. Rule of thumb: key each page by the identity of what it shows, such as the article slug, not by its position. ## onDidRemovePage: keeping state and stack in agreement The pages list is derived from the delegate's state, but some removals start **outside** the delegate: the Android back button, the app-bar back arrow, or code calling `Navigator.pop`. The Navigator then removes the route itself and calls **`onDidRemovePage(page)`**. The callback must update the state so the next list no longer contains that page: ```dart Navigator( pages: pages, onDidRemovePage: (Page<Object?> page) { if (page.key != const ValueKey<String>('home')) { _slug = null; notifyListeners(); } }, ) ``` If the state is not updated, the next rebuild still lists the article, and the Navigator treats it as a new page to show: it slides back in. Details from the source: - `onDidRemovePage` is called for **imperative** removals (pop, replacement), not when your own new list simply leaves a page out. - When using `pages`, exactly one of `onDidRemovePage` or the deprecated `onPopPage` must be provided, and `pages` must not be empty; otherwise the Navigator reports an error. - `onPopPage` was deprecated after v3.16.0-17.0.pre. It asked the app to call `route.didPop` and return whether the pop succeeded; `onDidRemovePage` is simpler because the pop has already happened. ## Blocking a pop at the page level `Page` also has `canPop` (default `true`) and an `onPopInvoked` callback. Setting `canPop: false` on a page blocks back-button pops of its route, and on the first page it prevents the app from exiting through back. It does not stop the browser's back button, which arrives as new route information, not as a pop. ## Common mistakes - Omitting keys and wondering why switching articles has no animation and keeps the old scroll offset. - Keying pages by list index, which makes every page match its predecessor at that index. - Forgetting to clear state in `onDidRemovePage`, so a popped page bounces back. - Building a new `List` of pages with the same content but new keys each build (for example `UniqueKey()`), which recreates every route on every rebuild.
- Why does a popped page slide back in if onDidRemovePage does nothing?The Navigator has already removed the route, but the delegate's state still says the article is open. On the next rebuild the article page is in the list again, has no existing route to match, and is treated as a new page to show, so it is pushed with a transition.
- Does Page.canPop: false stop the browser's back button on the web?No. `canPop` blocks pops, such as the Android back button reaching the route through the delegate. The browser's back button is not a pop: the platform delivers new route information, the parser produces a configuration, and the delegate's `setNewRoutePath` decides what to show.
saying these in an interview costs you the question
- Thinks page keys are optional decoration with no behavioural effect
- Uses UniqueKey for pages, recreating every route each build
- Expects onDidRemovePage for pages the app removed from its own list
- Still writes onPopPage as the current callback
- Believes the Navigator removes popped pages from the app's state itself