Beyond the Framework

Lesson 1 of 5

Navigator, Routes and Deep Links

"Every day is a journey, and the journey itself is home." — Matsuo Bashō, The Narrow Road to the Deep North (1694)

Navigation is the first place where a Flutter app stops being a self-contained widget tree and starts having to agree with the outside world. Until now, every chapter has concerned state the app owns entirely. A route stack is different: the operating system has opinions about it, the back button belongs to the platform, and on the web the address bar is a live, user-editable view of it.

That is the real subject of this chapter. Navigator.push is easy and you already know it. What is worth understanding is the shift from navigation as a sequence of actions to navigation as a value you can name — because every hard requirement in this area, deep links included, turns out to be a demand that your navigation be the second kind.

The stack, and what stays alive

Navigator is a stack of Route objects, and pushing one is exactly what it sounds like. What is less obvious is what happens to the page underneath, and the answer matters for both correctness and performance.

Measured, with pages that log their own lifecycle, pushing B on top of A and then popping:

after push B: [B initState, B build]
after pop:    [B dispose]

Two absences carry the information.

A did not rebuild when it was covered. It is still mounted, its State intact, its scroll positions and controllers alive. The new route was placed above it in an Overlay, and Flutter did not disturb it.

A did not rebuild when B popped, either. Returning to a page does not re-run its build. If you expected a screen to refresh itself when the user comes back — a list that should show a newly-created item — nothing will happen unless you arrange it, because from A's point of view nothing occurred.

That second point is the source of a very common bug report: "the list doesn't update after I add an item". The fix is to await the push and act on the result, which the next section covers.

The default is configurable. MaterialPageRoute takes maintainState, and it governs the route it is set on — whether that route's subtree survives being covered by another. Measured, covering a middle route with a third:

MID maintainState=true,  after covering it: [TOP initState, TOP build]
MID maintainState=false, after covering it: [TOP initState, TOP build, MID dispose]

With maintainState: false, the covered route is disposed and will be rebuilt from scratch when revealed. That is worth doing for a memory-heavy screen you do not mind rebuilding, and worth avoiding for anything holding scroll position or unsaved input.

The trade is easiest to feel with something stateful on the covered page. A scroll position and a counter survive one setting and are lost under the other:

Flutter — Try it Yourself
Loading editor…

Edit here, then open the pad to run it on dartpad.dev.

Flutter — Try it Yourself
Loading editor…

Edit here, then open the pad to run it on dartpad.dev.

An analogy

Imperative navigation is giving directions: turn left, go two blocks, take the third door. It works perfectly when someone is walking beside you, and it fails completely the moment they need to arrive without having made the journey — which is what a deep link is. You cannot give a taxi driver a link to "the third door after two blocks from wherever I happened to be standing"; you have to give them an address. That is the whole of the Navigator 1.0 to 2.0 story. The push stack is a list of turns taken, and a URL is an address, and the reason deep linking is awkward with imperative navigation is not that the API is poor but that a list of turns does not contain enough information to reconstruct where you ended up for someone who never started from your origin.

Replacing rather than stacking

Not every transition should deepen the stack. Finishing a login should not leave the login screen behind the app for the back button to reveal, and pushReplacement is the operation for that. Measured:

at root, canPop=false
after push, canPop=true
pushReplacement: [C initState, C build, B dispose]
canPop still=true

Two details worth noting. The replacement is built before the old route is disposed — C initState, C build, then B dispose — so the new page is ready before the old one goes, and a transition can run between them. And canPop remained true, because replacing the top of a three-deep stack still leaves something beneath it; pushReplacement swaps one entry, it does not flatten the stack.

canPop itself is the honest way to decide whether to draw a back affordance. At the root it is false, which is why a root page should not show one — and why Scaffold omits the automatic back arrow there without being told to.

When you do want the stack flattened — after login, or after completing a wizard — pushAndRemoveUntil takes a predicate and pops everything that fails it, so (route) => false clears the stack entirely.

A push is a Future

Navigator.push returns a Future that completes when that route is popped, carrying whatever the pop passed back. Measured:

before pop, result=null
after pop,  result=chosen value

This is the mechanism behind the "list doesn't refresh" problem above. Since the underlying page does not rebuild on return, the way to react is to await the push and do something with the answer:

final created = await Navigator.of(context).push<Item>(
  MaterialPageRoute(builder: (_) => const NewItemPage()),
);
if (!mounted) return;          // the guard from the previous chapter
if (created != null) setState(() => items.add(created));

Two details are worth naming. The result is nullable — a user who presses the system back button pops with no value, so null means "dismissed" and you must handle it. And the mounted check is the same one the forms chapter measured: awaiting means the widget may be gone by the time control returns.

Flutter — Try it Yourself
Loading editor…

Edit here, then open the pad to run it on dartpad.dev.

Navigation as a value

Everything so far is Navigator 1.0: navigation as a sequence of imperative calls. It is genuinely good for what it does, and for a self-contained app it is often all you need.

It has one structural limitation, and deep linking is where you meet it. A push stack records what happened. A deep link asks the app to reconstruct a state it never navigated to — a user taps myapp://users/42 in an email, and the app must come up showing user 42, with a sensible back stack behind it, having performed none of the pushes that would normally have produced that screen.

You can solve this imperatively, and many apps do, by parsing the incoming link at startup and replaying a synthetic sequence of pushes. It works, and it accumulates a second source of truth: the link parser and the push sites must agree about what screens exist, and they drift.

The alternative is to make the route stack a function of a location. Given a string like /users/42?tab=posts, a pure function produces the list of pages that should be on the stack. Navigating means changing the location; the stack follows. Now a deep link is not a special case at all — it is simply a location that happened to arrive from outside rather than from a tap.

That inversion is what Navigator 2.0 and the Router API provide, and what every routing package is built on. The arithmetic is small enough to model directly:

Dart — Try it Yourself
Loading editor…

Edit here, then open the pad to run it on dartpad.dev.

The interesting line in that output is /users/42 producing Home > UserList > UserDetail. Nobody visited UserList. The back stack was constructed from the location, which is exactly what a user arriving from an email needs: pressing back should take them somewhere sensible, not out of the app.

A little history

Navigator 2.0 arrived in Flutter 1.22 (2020) and is one of the few genuinely unpopular APIs the team has shipped. The design was sound — a RouterDelegate that builds a Navigator from application state, a RouteInformationParser that converts URLs to and from that state — but writing one by hand required implementing several abstract classes, wiring notifyListeners correctly, and handling a restoration path most apps did not need. The community response was almost immediate and almost unanimous: packages appeared to hide it, and go_router eventually became the recommended default, later moving under the Flutter organisation itself. What I find instructive is that the underlying model was never the problem and has not changed. The mistake was shipping a mechanism as though it were an API — exactly the gap the state-management Part described, except that here the Flutter team eventually adopted one of the community's wrappers rather than leaving the gap open. That is a rare and healthy correction: the abstraction was right, the ergonomics were wrong, and the fix was ergonomics.

go_router in practice

go_router implements that inversion with a route table. Measured, a router whose initialLocation is a deep link with both a path parameter and a query parameter:

deep-linked straight to: "user 42 tab=posts"
after go("/"):           "home"
Router in the tree?      true
Navigator in the tree?   true
unknown path shows:      [Home, Page Not Found]

Three things in that output.

The deep link needed no special handling. initialLocation and a user tap go through the same code path, which is the entire point of the inversion — there is no separate "handle incoming link" branch to drift out of sync.

It does not replace Navigator. Both Router and Navigator are in the tree. go_router is a RouterDelegate that builds a Navigator from the current location, so everything from the first half of this chapter still applies underneath: pages are routes, pushes are still a stack, results still come back through futures.

Unknown paths have a default. Rather than throwing, it renders a built-in error page, which you can replace with errorBuilder.

Flutter — Try it Yourself
Loading editor…

Edit here, then open the pad to run it on dartpad.dev.

From my notebook

I wrote a maintainState: false test, ran it, saw the covered page survive, and was about to record that the flag did nothing — which would have been a confident, published falsehood. The mistake was that I had set the flag on the route being pushed rather than on the route being covered. maintainState governs the route it is set on: whether that route's own subtree survives when something else covers it. Setting it on the newcomer is meaningless, because nothing is covering the newcomer. Re-running with a three-deep stack gave the clean result — MID dispose appearing only in the false case. What I keep noticing is that my measurement errors are almost never arithmetic; they are nearly always about which object the question is addressed to. The previous chapter's cursor measurement watched the field instead of the controller, and this one set a property on the wrong route. A null result is worth one more look at the wiring before it is worth a conclusion.

The big question

Why did Flutter ship an imperative Navigator first and a declarative one second, when the declarative model is so clearly the better fit for a framework whose entire premise is that UI is a function of state? The honest answer is that mobile did not require it and the web did. A phone app has no address bar, no bookmarks, no browser back button, and its deep links can be handled at startup as a special case. Under those conditions a push stack is not a compromise, it is a good design: simple, obvious, and directly mirroring what the user experiences. The declarative model's advantages only become load-bearing when something outside the app can name a location — which is to say, on the web, and in the deep-linked, app-clip, share-sheet world that mobile became later. So Flutter's sequence is less a mistake than a record of which platform it was built for first. What I take from it is a caution about the phrase "X is a function of state" as a design principle: it was always true of navigation, and it remained genuinely unnecessary to act on for years, because the cost of an inconsistency is zero until something outside your program can observe it.

In the real world

The production version of this chapter is the app that ships without deep links and adds them in year two. By then navigation is imperative and scattered — pushes written inline at forty call sites, a few pushNamed strings, three places that build the same screen with slightly different arguments. Adding deep links means writing a parser that can produce all of those screens, and the parser immediately becomes a second, incomplete map of the app: it knows about the screens somebody remembered, and the drift begins with the next feature. The teams who escape this are the ones who treat the location as the source of truth from the start, even while the app has no links at all, because then every screen is already reachable by name and adding link handling is configuration rather than archaeology. The second recurring incident is subtler and comes from the measurement at the top of this chapter: a screen that never refreshes after the user returns to it. It is reported as a caching bug roughly every time, because "I added an item and the list is stale" sounds like caching, and the actual cause is that the covered page's build simply never ran again.

Common Mistakes

  1. Expecting the page underneath to rebuild. Measured, a covered page neither rebuilds when covered nor when revealed. Await the push and act on its result.

  2. Ignoring the null result. A system back press pops with no value, so push<T>() completes with null. That is a normal outcome, not an error case.

  3. Forgetting mounted after an awaited push. The awaiting widget may be gone when control returns — the same guard the forms chapter measured.

  4. Setting maintainState on the wrong route. It governs whether that route survives being covered. Setting it on the route you are pushing does nothing.

  5. Using maintainState: false on a screen holding scroll position or input. It will be disposed when covered and rebuilt from scratch, losing both.

  6. Treating deep links as a separate code path. A parser that replays pushes becomes a second map of the app and drifts. Make the stack a function of a location and links stop being special.

  7. Assuming a router package replaces Navigator. Measured, go_router puts a Router and a Navigator in the tree; it drives the same stack declaratively.

  8. Deep-linking to a screen with no back stack. Constructing a sensible stack — Home > UserList > UserDetail for /users/42 — is the point of deriving it, and it is what stops back throwing the user out of the app.

  9. Scattering navigation strings across call sites. Route paths belong in one table. Forty inline pushes are forty places to update when a screen moves.

  10. Reaching for Navigator 2.0 by hand. The raw RouterDelegate API is a mechanism, not an ergonomic API; that is why go_router exists and why Flutter adopted it.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
How do you return a value from a pushed page?
Model answer — Navigator.push<T> returns a Future<T?> that completes when that route is popped, and Navigator.pop(value) supplies the value. Await the push to receive it. The result is nullable because a system back press pops without one, and you should check mounted after the await before calling setState.
Intermediate
A user adds an item on a second screen, returns, and the list is stale. Why?
Model answer — Because the covered page never rebuilt. Measured, pushing a route neither rebuilds nor disposes the page beneath it, and popping back does not rebuild it either — its State was intact the whole time and nothing invalidated it. The fix is to await the push and update state from the returned value, rather than expecting the framework to refresh a page that, from its own perspective, never changed.
Advanced
Why is deep linking awkward with imperative navigation, and what changes with a declarative router?
Model answer — A push stack records the sequence of actions taken, which is not enough to reconstruct a destination for someone who never performed them — so deep links become a parser that replays synthetic pushes, creating a second map of the app that drifts from the real call sites. A declarative router inverts it: the stack is a pure function of a location, so /users/42 yields a whole stack including a constructed back stack, and a link is just a location that arrived from outside rather than from a tap. Packages like go_router implement this over the Router API and still build an ordinary Navigator underneath.
micro1Practical: return a value from a page and refresh the list correctly.GoogleDeepest — stack-as-function-of-location, and why 2.0 exists.MetaWeb parity: address bar, history, and shareable state.AmazonDiagnosis: the stale-list-after-return report, and deep-link drift.

Hands-on

Hands-on

Build the router. Implement parse, splitting a location into segments and query; stackFor, the pure function from location to page stack including a constructed back stack; locationFor, the inverse used by the address bar; popTo, what remains after a pop; and survivesCover, the maintainState rule. The checks run either way — a stub scores zero, so a passing run means the model is real.

Dart — Try it Yourself
Loading editor…

Edit here, then open the pad to run it on dartpad.dev.

Summary

  • Navigator is a stack of routes, and the page underneath a push stays mounted with its State intact.
  • A covered page does not rebuild — neither when covered nor when revealed. "The list is stale after I come back" is this, not caching.
  • push<T>() returns a Future<T?> completing on pop with the popped value; null means dismissed.
  • Check mounted after awaiting a push before calling setState.
  • maintainState governs the route it is set on — whether that route's subtree survives being covered. Measured, false disposes it.
  • Imperative navigation records a sequence, which is not enough to reconstruct a destination for someone who never walked it.
  • Declarative routing makes the stack a function of a location, so a deep link is just a location that arrived from outside.
  • A derived back stack is constructed, not replayed — /users/42 yields Home > UserList > UserDetail, so back behaves sensibly.
  • go_router does not replace Navigator — measured, both a Router and a Navigator are in the tree.
  • Keep route paths in one table. Scattered inline pushes are the thing that makes deep links archaeology later.

Next: Platform Channels — the single mechanism by which every native call, every plugin and every piece of platform data crosses out of Dart and back.

Editorial reflection

The part of this chapter I find most worth sitting with is that Navigator 2.0's model was right and its API was unpopular enough that the community routed around it — and Flutter's eventual response was to adopt one of the wrappers rather than defend the original. That is a more unusual outcome than it sounds. The reflex when an abstraction is rejected is to argue that users have misunderstood it, and often they have; here the team appears to have concluded that a mechanism shipped without ergonomics is an unfinished product, which is the same diagnosis this volume reached independently about InheritedWidget and the state-management ecosystem. The difference is that navigation got resolved and state management did not, and I do not think that is because navigation is simpler. It is because there was one obviously-correct model to converge on. Where a genuine plurality of good designs exists, a framework that ships only the mechanism gets a permanent debate; where there is one right answer, it eventually gets a default. Knowing which situation you are in seems like the hard part, and largely only visible afterwards.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.