skip to content

In Flutter's Router API, how does a BackButtonDispatcher route the Android back button, and when do you need a ChildBackButtonDispatcher?

level: middleimportance: nice to knowfreq 16%

answer

  1. not the browser's back button
  2. root dispatcher listens for popRoute
  3. delegate.popRoute, then maybePop
  4. false means the app may close
  5. nested Router: child dispatcher, takePriority

basics

~10 s

RootBackButtonDispatcher, the default for MaterialApp.router, receives Android back presses and calls the delegate's popRoute, usually maybePop on its navigator. A nested Router needs a ChildBackButtonDispatcher that calls takePriority to get presses first.

solid answer

~40 s

The system back button reaches Flutter as a `popRoute` message. `MaterialApp.router` creates a `RootBackButtonDispatcher`, a `WidgetsBindingObserver` that receives it and asks the Router, which calls `RouterDelegate.popRoute`. With `PopNavigatorRouterDelegateMixin`, `popRoute` calls `maybePop` on the navigator behind `navigatorKey`, so the top page pops, and `Page.canPop` or a `PopScope` can refuse. If it returns `false`, the press is unhandled and on Android the app can close. iOS and desktop have no back button, so nothing is dispatched there, and the browser's back button on the web is not a back-button event at all: it arrives as new route information through the provider. When a `Router` is nested, say for a docs section with its own stack, give it `ChildBackButtonDispatcher(Router.of(context).backButtonDispatcher!)` and call `takePriority()` so the inner router handles back first.

code

dart · 31 lines
dart
import 'package:flutter/material.dart';

class SectionPane extends StatefulWidget {
  const SectionPane({super.key, required this.delegate});
  final RouterDelegate<Object> delegate;

  @override
  State<SectionPane> createState() => _SectionPaneState();
}

class _SectionPaneState extends State<SectionPane> {
  ChildBackButtonDispatcher? _dispatcher;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final BackButtonDispatcher? parent = Router.of(context).backButtonDispatcher;
    if (_dispatcher == null && parent != null) {
      _dispatcher = ChildBackButtonDispatcher(parent);
    }
    _dispatcher?.takePriority();
  }

  @override
  Widget build(BuildContext context) {
    return Router<Object>(
      routerDelegate: widget.delegate,
      backButtonDispatcher: _dispatcher,
    );
  }
}

go deeper

for a junior

Recall that the Android back button reaches the router through a root back-button dispatcher that asks the delegate to pop.

for a middle

Explain popRoute, PopNavigatorRouterDelegateMixin's maybePop, what false means, and how browser back differs from the back button.

for a senior

Show how nested routers share back handling with child dispatchers and takePriority, and how to avoid trapping or prematurely exiting users.

for a principal

Decide whether a nested Router is worth its back-handling complexity versus a single router with richer state.

## Three different "back" events Flutter apps receive "go back" requests from three sources, and the Router API handles them in different places: | Source | Delivered as | Handled by | |---|---|---| | Android system back button or gesture | `popRoute` on the navigation channel | `BackButtonDispatcher` → `RouterDelegate.popRoute` | | Browser back or forward button (web) | new `RouteInformation` from the provider | parser → `RouterDelegate.setNewRoutePath` | | In-app back arrow in an `AppBar` | `Navigator.maybePop` on the nearest navigator | the navigator directly | The `BackButtonDispatcher` only concerns the first row. The Router's own documentation notes that platforms without a back button, such as iOS and desktop, never send the notification. ## The root dispatcher `MaterialApp.router` (through `WidgetsApp`) creates a **`RootBackButtonDispatcher`** by default. It is a `WidgetsBindingObserver`, so it receives `didPopRoute` when the system back button is pressed, and invokes the callback the Router registered. The Router then calls: 1. **`RouterDelegate.popRoute()`**, which returns `Future<bool>`: `true` means handled. 2. With **`PopNavigatorRouterDelegateMixin`**, `popRoute` is implemented as `navigatorKey.currentState?.maybePop()`, or `false` if there is no navigator. 3. `maybePop` respects `Page.canPop` and `PopScope`: a page that cannot pop reports the attempt and stays; a single remaining page bubbles, returning `false`. 4. When the result is `false`, the press is unhandled, and on Android the system may close the app. A custom `popRoute` can do something else first, such as closing an open side panel stored in delegate state, and return `true` so the app stays. ## Nested routers and ChildBackButtonDispatcher Some apps nest a second `Router`, for example a documentation app whose reading pane keeps its own stack of sections. Only one of them should react to a back press, and it should be the innermost one that can pop. 1. The inner router gets a **`ChildBackButtonDispatcher`** built from the parent dispatcher, found with `Router.of(context).backButtonDispatcher`. 2. Calling **`takePriority()`** on the child tells the parent to defer to it; among several children, the latest to call `takePriority` wins. 3. On a back press, the root asks the child first; if the child's delegate returns `false`, the root's own delegate handles it. 4. When the child's router is disposed and removes its callback, the child tells the parent to forget it. ```dart final BackButtonDispatcher parent = Router.of(context).backButtonDispatcher!; final ChildBackButtonDispatcher child = ChildBackButtonDispatcher(parent)..takePriority(); Router<SectionPath>( routerDelegate: sectionDelegate, backButtonDispatcher: child, ); ``` Nested routers normally pass no provider and parser: only the top router handles route information. ## BackButtonListener for one-off cases For a single widget that wants first refusal on the back button, say a search field that should clear its query before the page pops, **`BackButtonListener`** registers a callback with the nearest router's dispatcher and takes priority while it is in the tree. It needs an ancestor `Router` with a root dispatcher, such as the one `MaterialApp.router` creates, and it only applies on platforms with a back button. ## Platform summary | Platform | System back button | What reaches the dispatcher | |---|---|---| | Android | yes (button or gesture) | every press, via `popRoute` | | iOS | no | nothing; the swipe-back gesture belongs to the page transition | | Desktop | no | nothing | | Web | browser buttons only | nothing; history changes arrive as route information | This is why back-handling logic placed only in `popRoute` must never be the sole way to leave a screen, and why web behaviour is tested through route information rather than the dispatcher. ## Common mistakes - Expecting a `BackButtonDispatcher` to see browser back on the web; that path is the provider and `setNewRoutePath`. - Implementing `popRoute` to always return `true`, which traps the user on Android. - Nesting a `Router` without a child dispatcher, so the root pops the outer stack while the inner stack still has pages. - Forgetting `takePriority()`, which leaves the child registered but never consulted first.

  • What happens on Android when the root delegate's popRoute returns false?
    The press is reported as unhandled. The binding lets other observers try, and if none handles it the system back action proceeds, which typically closes or backgrounds the app. That is the expected outcome on the first page.
  • Why does a nested Router usually get no RouteInformationProvider or parser?
    Only one router should read and report route information, normally the top-level one from `MaterialApp.router`. A nested router builds its pages from state the top router already parsed, so it only needs a delegate and, for back handling, a child dispatcher.

saying these in an interview costs you the question

  • Thinks the browser back button goes through BackButtonDispatcher
  • Returns true from popRoute unconditionally to prevent exits
  • Nests a Router without a ChildBackButtonDispatcher
  • Expects iOS to deliver back-button events to the dispatcher
  • Creates a ChildBackButtonDispatcher but never calls takePriority