back_stack 0.6.1 copy "back_stack: ^0.6.1" to clipboard
back_stack: ^0.6.1 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.

pub package license ci docs

back_stack demo

A whole app in 20 lines #

// Destinations are plain Dart types — real, compiler-checked arguments.
abstract class AppKey extends NavKey { const AppKey(); }
class Home    extends AppKey { const Home(); }
class Product extends AppKey { const Product(this.id); final int id; }

// One line per screen.
final entries = NavEntries<AppKey>()
  ..on<Home>((context, key) => const HomeScreen())
  ..on<Product>((context, key) => ProductScreen(id: key.id));

// The back stack is a list you own.
final stack = NavStack<AppKey>.of(const Home());

void main() => runApp(BackStackApp<AppKey>(stack: stack, entries: entries));

Navigate by changing the list — from anywhere, no context gymnastics:

stack.push(const Product(42));    // forward
stack.pop();                      // back
stack.replaceAll([const Home()]); // reset a flow (e.g. after login)
// or from a widget: BackStack.of<AppKey>(context).push(const Product(42));

That's the entire model. System back, predictive back, and the hardware button already flow into the list. Everything below is one parameter on BackStackApp — the wiring is internal.

Install #

dependencies:
  back_stack: ^0.6.0

Declare each URL once, both directions. Deep links, the address bar, browser back/forward, shareable links, and state restoration all derive from this single table — they can never drift apart:

final links = NavLinks<AppKey>()
  ..on<Home>('/', decode: (m) => const Home())
  ..on<Product>('/products/:id',
      decode: (m) => Product(m.integer('id')!),
      encode: (key) => {'id': key.id},
      parents: (key) => const [Home()])   // Back from a deep link goes Home
  ..notFound((uri) => const [Home(), NotFoundScreen()]);

BackStackApp<AppKey>(stack: stack, entries: entries, links: links);

:id is a path parameter, extra encode entries become query parameters, *rest catches the tail. A malformed or unknown link lands on your notFound stack — never a crash. links.linkFor(Product(42)) gives you the share URL.

On the web, every history entry also carries a full-stack snapshot, so browser back/forward restores the entire typed stack — including in-app depth the URL can't express — not just the URL's projection.

Need to fetch before deciding what a link shows? Add onLinkAsync — newer links supersede in-flight ones automatically:

BackStackApp<AppKey>(..., links: links,
  onLinkAsync: (uri) async =>
      await docExists(uri) ? null : const [Home(), NotFoundScreen()]);

Survives process death — already on #

With links set, the entire typed stack (not just the URL) is saved and restored across Android process death. There is nothing to enable. Screens without a URL are skipped; to restore those too, pass a restoreWith codec.

What's restored is the stack — every destination, in order, with its typed arguments. State inside a screen (half-typed text, scroll position) follows Flutter's own restoration APIs (RestorableProperty and friends), as in any Flutter app.

Tabs — one widget #

Bottom navigation with a live back stack per tab, back handled innermost-first, re-tap pops to root:

BackStackTabsApp<AppKey>(
  tabs: [
    NavStack<AppKey>.of(const Feed()),
    NavStack<AppKey>.of(const Search()),
    NavStack<AppKey>.of(const Profile()),
  ],
  entries: entries,
  destinations: const [
    NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
    NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
    NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
  ],
  links: links, // optional: links land in the right tab automatically
);

Want your own bar or scaffold? Pass shell: instead of destinations:.

Await a screen's result #

final color = await stack.pushForResult<Color>(const ColorPicker());
// on the picker: stack.pop(Colors.indigo);

The future never hangs — if the screen leaves any other way (back gesture, replace, redirect) it resolves null. A mistyped result fails at the pop call site in debug, not as a cast error later.

Auth gating — loop-proof, sync or async #

// Sync rule: a pure function, applied once per change. No redirect loops.
stack.redirect = (proposed) =>
    proposed.any((k) => k is Account) && !isLoggedIn ? [const Login()] : proposed;

// Async rule (session check, permission call): one attach, nothing else.
// While the check runs the stack stays put — the protected screen is never
// flashed (drive a scrim from gate.resolving). A throwing check fails closed.
final gate = AsyncRedirect<AppKey>(
  gates: (proposed) => proposed.any((k) => k is Admin),
  check: (proposed) async =>
      await session.isAdmin() ? null : [const Login()],
)..attach(stack);
// after login/logout: gate.invalidate();

Blocking leaving a screen: stack.popGuard (sync), stack.popGuardAsync + tryPop() (async), or ConfirmPopScope around the screen for the system back gesture.

Dialogs, sheets & transitions — per destination #

A destination can carry its own presentation. No pageBuilder switch:

entries.on<ConfirmDelete>(
  (context, key) => const ConfirmDeleteContent(),
  page: (context, key, child, pageKey) =>
      DialogPage<void>(key: pageKey, builder: (_) => AlertDialog(content: child)),
);

DialogPage, SheetPage, and TransitionPage (Material shared-axis, fade through, slide, scale…) are included. Dialogs pushed this way are real stack entries: back dismisses them, the URL can track them.

On iOS, TransitionPage keeps the edge-swipe back gesture even with a custom transition — the drag drives your transition in reverse, like CupertinoPageRoute. Opt a page out with swipeBack: false.

Analytics — in your types #

NavStackObserver<AppKey>(stack,
  onScreen: (key) => analytics.logScreen('${key.runtimeType}'));

Why a list? #

Common pain (go_router / Navigator 2.0) back_stack
go vs push confusion One operation: mutate the list.
extra is Object?, breaks web refresh Typed destinations, URL table restores the real stack.
The stack is a black box stack.keys is plain observable data — inspect it, test it with a plain expect.
Redirect loops Redirects are pure functions applied once per change.
Tab state loss, shell ceremony One widget, per-tab stacks that never die.
Deep link + auth + cold start races Ordered internally; async gates hold navigation until resolved.
Restoration restores only the URL Full typed stack survives process death.

Testing is the same story — no harness, no mocks:

stack.push(const Product(1));
expect(stack.keys, [const Home(), const Product(1)]);

Going deeper #

The docs site covers the rest: custom codecs and RouterDelegate-level control, native link plugins (app_links cold-start ordering), adaptive layouts (NavListDetail, scenes), modular multi-package setups, the live BackStackInspector, known limitations, and the migration guide from go_router.

Example #

Ten runnable demos in example/: flutter run example/lib/main.dart (shop with a links table), tabs_app.dart (tabs in one widget), tabs.dart (tabs by hand), guarded.dart, results.dart, motion.dart, pokedex.dart (adaptive list-detail + Hero), modular_demo.dart, and more. Live web demo: androidpoet.github.io/back_stack/demo.

License #

MIT © AndroidPoet

5
likes
160
points
1.06k
downloads

Documentation

API reference

Publisher

verified publisherandroidpoet.dev

Weekly Downloads

You own the back stack. Navigation is a list you push and pop — type-safe, observable, no route graph.

Repository (GitHub)
View/report issues

Topics

#navigation #router #navigator #routing

License

MIT (license)

Dependencies

flutter

More

Packages that depend on back_stack