skip to content

With go_router 18, how does the top-level onEnter callback differ from redirect, and when would you choose it?

level: seniorimportance: nice to knowfreq 18%

answer

  1. added in go_router 16.3.0
  2. runs before any redirect
  3. once per navigation, literal target
  4. Allow, Block.stop, Block.then
  5. blocked cold-start link raises an exception

basics

~20 s

onEnter runs once per navigation before any redirect, sees both the current and the next GoRouterState, and returns Allow or Block, so it can cancel a navigation and keep the user where they are; redirect only lets a navigation through or sends it elsewhere.

solid answer

~40 s

`onEnter`, added in go_router 16.3.0, has the type `FutureOr<OnEnterResult> Function(BuildContext, GoRouterState current, GoRouterState next, GoRouter)`. It runs first, exactly once per navigation, against the literal target; if it returns `Allow`, the top-level `redirect` and then route-level redirects run as before. The result is a sealed type: `const Allow()` lets the navigation proceed, `const Block.stop()` cancels it and keeps the current page, and `Block.then(() => router.go(...))` cancels it and runs a follow-up. Both can carry a `then` callback that runs after the decision. I choose it when the answer is "not now" rather than "somewhere else", for example ignoring an incoming link while a bank transfer is being confirmed. Because it never sees locations produced by redirects, a rule about a redirect-only destination still belongs in `redirect`.

code

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

class TransferState extends ChangeNotifier {
  bool confirming = false;
}

final TransferState transfer = TransferState();

final GoRouter router = GoRouter(
  onEnter: (BuildContext context, GoRouterState current, GoRouterState next, GoRouter router) {
    // Ignore any navigation away while a transfer confirmation is open.
    if (transfer.confirming && next.uri.path != current.uri.path) {
      return const Block.stop();
    }
    return const Allow();
  },
  onException: (BuildContext context, GoRouterState state, GoRouter router) {
    if (state.error is BlockedInitialNavigationException) {
      router.go('/accounts');
    }
  },
  routes: <RouteBase>[
    GoRoute(path: '/', redirect: (context, state) => '/accounts'),
    GoRoute(path: '/accounts', builder: (context, state) => const Placeholder()),
  ],
);

go deeper

for a junior

Recall that onEnter returns Allow or Block, runs before redirects, and can cancel a navigation while keeping the current page.

for a middle

Explain the order onEnter, top-level redirect, route redirects, the difference between Block.stop and Block.then, and why onEnter sees only the literal target.

for a senior

Show where each guard belongs in production: blocking in-flight flows with onEnter, session rules in redirect, and recovering a blocked cold-start link through onException.

for a principal

Weigh adding a second interception layer against the cost of two guard models the team must keep consistent, and decide which rules justify it.

## Why a second hook exists Before go_router 16.3.0, the only interception point was `redirect`, which can **change the destination** but has no result that says "cancel this navigation and leave the user where they are". It also receives only the target state, not the page the user is on. `onEnter` adds that missing shape: a guard that compares **current and next** and decides **whether** the navigation happens at all. Its signature is: ```dart typedef OnEnter = FutureOr<OnEnterResult> Function( BuildContext context, GoRouterState currentState, GoRouterState nextState, GoRouter goRouter, ); ``` ## The result types `OnEnterResult` is a **sealed class** with two final subclasses: - `const Allow()`: the navigation proceeds. An optional `then` callback runs after it is committed; errors thrown there are reported through `FlutterError.reportError` and do not undo the navigation. - `const Block.stop()`: a hard stop. The navigation is cancelled, the router keeps the current configuration, and the redirection history is reset so the next attempt is evaluated fresh. - `Block.then(callback)`: cancel, then run the callback, typically `router.go('/login')`. It keeps the redirection history so chained guards can still detect loops. Even an empty closure counts as chaining, so a hard stop should use `Block.stop()`. `then` callbacks are deferred to a microtask so a `router.go` inside them does not re-enter the parse that is still running. ## Order of operations 1. `onEnter` runs **once**, against the literal URI being navigated to (a deep link, a `go` or `push` call, a browser back or forward). 2. If allowed, the top-level `redirect` runs, and again on each new location it produces. 3. Route-level `GoRoute.redirect` callbacks run for the matched routes. Because step 1 never re-runs for intermediate locations, `nextState.uri.path` never equals a path reached only through a redirect. With a route `/` that redirects to `/a`, an `onEnter` check for `/a` does not fire when the user navigates to `/`. Guards for redirect-only destinations stay in `redirect`. ## Compared with redirect | | `onEnter` | top-level `redirect` | |---|---|---| | Added | go_router 16.3.0 | original API, still supported | | Sees | current and next state, the router | next state only | | Returns | `Allow` or `Block` | a location, or `null` | | Cancel and keep the current page | built in (`Block.stop()`) | not directly; it only picks the target | | Runs per navigation | once, on the literal target | on every hop of the chain | The go_router source calls the top-level `redirect` the "legacy" callback, but it is **not deprecated**; both can be set, and `onEnter` simply goes first. ## Blocking a cold-start deep link When the app is launched by a link and `onEnter` blocks it, there may be no previous page to keep. go_router then produces an error for that navigation with a `BlockedInitialNavigationException`, a `GoException` subtype added in go_router 17.4.0. An `onException` handler can recognise the type and recover, for example by sending the user to a holding page that keeps the link for later, instead of showing a generic error. ## Pitfalls - **Guarding a redirect-only target.** `onEnter` sees the literal location the navigation asked for, so a check against a path that users only reach through a redirect never matches. - **Empty `Block.then`.** Any `then` callback, even `() {}`, makes the block a chaining block that keeps redirect history; use `Block.stop()` for a plain cancel. - **Treating `then` as a rollback point.** For `Allow`, `then` runs after the navigation is committed, and an exception there is reported, not used to undo it. - **Blocking the first navigation without a plan.** A cold-start link that is blocked has nothing to fall back to; without an `onException` that recognises `BlockedInitialNavigationException`, the user sees the error screen. - **Two guard models for one rule.** Splitting the same session rule between `onEnter` and `redirect` invites them to disagree; give each rule one home. ## When to reach for it in a banking app - A transfer confirmation is open and an incoming link would navigate away: return `Block.stop()` while the confirmation is pending. - A signed-out user opens a link to a protected page and the app wants to stash the target before showing login: return `Block.then(() => router.go('/login'))` after storing `nextState.uri`. - Everything else, especially "signed out means go to login", remains a plain `redirect`, which also handles the redirect-only destinations `onEnter` cannot see.

  • With go_router 18, why does an onEnter check for /accounts not fire when users reach it through a '/' to '/accounts' redirect?
    `onEnter` is evaluated once per navigation against the literal target, before any redirect, and is not re-invoked as redirects resolve intermediate locations. Navigating to `/` shows `/` as `nextState`, so the check must live in a redirect, which sees each hop.
  • With go_router 18, what happens when onEnter blocks the first deep link at cold start?
    There is no earlier configuration to restore, so go_router builds an error for that navigation carrying a `BlockedInitialNavigationException`, added in 17.4.0. An `onException` handler can detect that type and recover, for example by routing to a holding page that remembers the link.
  • With go_router 18, is redirect deprecated now that onEnter exists?
    No. The source calls the top-level redirect the legacy callback, yet it remains supported and not deprecated. Both can be configured together: `onEnter` runs first and may block; if it allows, the top-level redirect and route-level redirects run as before.

saying these in an interview costs you the question

  • Believes onEnter replaced redirect and redirect was removed in go_router 18.
  • Expects onEnter to see every intermediate location a redirect produces.
  • Returns Block.then with an empty closure expecting a hard stop.
  • Thinks an error thrown in Allow's then callback rolls the navigation back.
  • Returns a location string from onEnter as if it were redirect.