back_stack 0.2.4
back_stack: ^0.2.4 copied to clipboard
You own the back stack. Navigation is a list you push and pop — type-safe, observable, no route graph.
back_stack #
You own the back stack. Navigation is a List you push and pop — type-safe, observable, no route graph.

Push adds, pop removes, the UI follows. No go vs push. No route graph. No RouterDelegate ceremony. Destinations are plain Dart types, checked by the compiler.
// 1. Destinations are typed objects. Arguments are real, checked fields.
// Not sealed — each feature can live in its own file.
abstract class AppKey extends NavKey { const AppKey(); }
class Home extends AppKey { const Home(); }
class Product extends AppKey { const Product(this.id); final int id; }
// 2. Map each destination to its screen. Add a screen = one line; split these
// `..on<T>()` calls across feature files if you want (see example/multi_file).
final entries = NavEntries<AppKey>()
..on<Home>((context, key) => const HomeScreen())
..on<Product>((context, key) => ProductScreen(id: key.id));
// 3. The back stack is a list you own. NavDisplay watches it and follows.
final stack = NavStack<AppKey>.of(const Home());
NavDisplay<AppKey>(stack: stack, builder: entries.call);
// Navigate by changing the list:
stack.push(const Product(42)); // add
stack.pop(); // remove
stack.replaceAll([const Home()]); // reset the flow (e.g. after login)
stack.popUntil((k) => k is Home); // unwind
System back, the Android predictive-back gesture, and the hardware back button all flow into the list automatically — you never wire that up.
Install #
dependencies:
back_stack: ^0.2.4
Reach the stack from anywhere #
NavDisplay provides the stack to every screen below it — no passing it down by hand:
onTap: () => BackStack.of(context).push(const Product(42)),
It doesn't subscribe by default (right for event handlers); pass listen: true to rebuild on change.
Why #
| Common pain (go_router / Navigator 2.0) | back_stack |
|---|---|
go vs push confusion; differs web vs mobile |
One operation: mutate the list. |
extra is Object? — not type-safe |
Typed destinations, compiler-checked args. |
| Back stack is a black box | stack.keys is plain, observable data. |
Redirect loops (/login → /login → …) |
redirect is a pure function, applied once. Loop-proof. |
| 50-line route tables away from screens | One switch next to your screens. |
Features #
- Own the stack —
push/pop/replaceAll/popUntil/edit. It's just aList. - One
switch, or a modular map — the exhaustiveswitchis the default;NavEntries(..on<Home>(...)) registers destinations across feature files when oneswitchgets big. - Cross-cutting decorators —
NavEntryDecoratorwraps every screen (DI scope, providers, tracing) and calls back when an entry leaves the stack, so you can tear down a Bloc/controller scoped to a destination. - Results —
await stack.pushForResult<Color>(picker); complete it withpop(value). Never hangs. - Web & deep links — one
NavStackCodec(Uri ⇄ List) gives URL sync, browser back/forward, and you decide what a link materializes. - Auth gating —
redirect(pure transform) andguard(veto), applied once per change. Loop-proof. - Adaptive layout —
NavListDetailturns one stack into list-detail / panes on wide screens, a stack on phones. - Per-tab history —
MultiNavStackgives each bottom-nav tab its own persistent back stack. - Shared elements —
Herotransitions just work, including inside nested displays. - Custom transitions —
TransitionPage(fade / slideUp / scale),DialogPage,SheetPage. - Restoration —
RestorableBackStacksurvives process death without a URL. - Leak-safe — leaving the stack disposes the route; the whole suite runs under
leak_tracker.
Web URLs & deep links #
A codec translates the stack ⇄ a Uri; the stack stays the source of truth. It's just two switches, so write them inline with NavStackCodec.of — no class to declare:
final codec = NavStackCodec<AppKey>.of(
encode: (stack) => switch (stack.last) {
Home() => Uri(path: '/'),
Product(:final id) => Uri(path: '/products/$id'),
_ => Uri(path: '/'),
},
decode: (uri) {
final s = uri.pathSegments;
if (s.length == 2 && s[0] == 'products') {
final id = int.tryParse(s[1]);
if (id != null) return [const Home(), Product(id)]; // layer on Home, not replace
}
return [const Home()];
},
fallback: [const Home()], // shown for a malformed / unknown link
);
MaterialApp.router(
routerDelegate: NavStackRouterDelegate(
stack: NavStack.of(const Home()),
codec: codec,
builder: (context, key) => /* your screen */,
),
routeInformationParser: const NavStackRouteInformationParser(),
restorationScopeId: 'app', // survive process death
);
Prefer a class? Extend NavStackCodec and override encode/decode — same thing, reusable value.
Error handling — the one place a link can go wrong #
There is no errorBuilder and no "route not found" — destinations are typed and the builder switch is exhaustive, so in-app navigation can't reach an unknown screen; that error class is gone at compile time.
The only untyped input is a deep link from outside. decode there can throw (a junk int.parse) or return nothing. back_stack never crashes on it: if decode throws or returns empty, it uses fallbackFor instead — the fallback list above, or override fallbackFor to route to a dedicated NotFound() screen. So decode can parse optimistically without defensive guards.
Auth gating, without loops #
stack.redirect = (proposed) {
final guarded = proposed.any((k) => k is Account);
return (guarded && !isLoggedIn) ? [const Login()] : proposed;
};
A pure function applied once per change — it can't ping-pong like a URL-redirect engine.
Bottom nav with per-tab history — on the web too #
MultiNavStack keeps one back stack per tab; MultiNavDisplay renders them (pass lazy: true to build a tab only when first opened). For URL sync, deep links and browser back on a tabbed app, drive it from the Router with MultiNavStackRouterDelegate + a MultiNavStackCodec (the multi-tab siblings of NavStackRouterDelegate / NavStackCodec). To survive process death, wrap it in RestorableMultiNavStack — every tab's stack and the active tab come back.
Modular destinations & scoped cleanup #
The exhaustive switch stays the default — the compiler tells you when a destination is unhandled. When one switch grows past comfort, register destinations as a map instead, composed across feature files:
final entries = NavEntries<AppKey>()
..on<Home>((context, key) => const HomeScreen())
..on<Product>((context, key) => ProductScreen(id: key.id));
NavDisplay(stack: stack, builder: entries.call);
To wrap every screen — a DI scope, a provider, request tracing — and tear it down when the destination leaves the stack, pass a NavEntryDecorator:
NavDisplay(
stack: stack,
builder: entries.call,
decorators: [
NavEntryDecorator(
decorate: (context, key, child) =>
ProviderScope(overrides: [scopeFor(key)], child: child),
onRemoved: (key) => disposeScopeFor(key), // popped, replaced, or disposed
),
],
);
decorate runs on every build (first decorator is the outermost wrapper); onRemoved fires once when the entry is popped, replaced, or the whole display is disposed — the hook State.dispose can't give you for a non-widget object.
Known limitations #
Honest edges, so nothing surprises you:
- Custom
NavSceneHostscenes rebuild across the breakpoint.NavListDetailpreserves each pane'sStateacross the breakpoint (it gives every entry a stableGlobalKey, so screens are reparented, not rebuilt). A custom scene you write withNavSceneHost+ your ownNavSceneStrategydoesn't get that for free — wrap pane content in your own per-entryGlobalKeyif you need it, or hoist the transient state above the display. BackStack.of<K>is nearest-by-type. When a parent and a nested child stack share the same key typeK, a screen gets the innermost one. Give nested stacks distinctNavKeysubtypes (e.g.AppKeyvsWizardKey) — thenBackStack.of<AppKey>andBackStack.of<WizardKey>are unambiguous, and it's more type-safe anyway. (To reach aMultiNavStackhost from a tab screen — e.g. to switch tabs — useMultiBackStack.of(context).)Herodoesn't fly across a nested-NavDisplayboundary. EachNavDisplayowns its ownHeroController, so a shared-element flight works within one display, not between a parent screen and a child display's screen. This is structural to how Flutter'sHeromatches endpoints per-Navigator— every router (go_router included) has the same limit.- Custom
TransitionPages don't get the iOS edge-swipe-back (they're plainPageRoutes). UseCupertinoPagewhere you want the native swipe. redirectis synchronous by design (that's what makes it loop-proof). For async auth, pointrefreshListenableat your auth notifier and route to a loading screen; the redirect re-runs when auth resolves.
Example #
cd example && flutter run # the shop demo
cd example && flutter run -t lib/pokedex.dart # the Pokédex above
cd example && flutter run -t lib/showcase.dart # NavListDetail, one adaptive stack
cd example && flutter run -t lib/entries.dart # NavEntries + NavEntryDecorator
See doc/PHILOSOPHY.md for how each Flutter navigation leak and caveat is handled.
License #
MIT