With go_router, how do you build bottom tabs in a social app so each tab keeps its own navigation history?
answer
- one Navigator per branch
- StatefulShellRoute.indexedStack with branches
- builder gets a StatefulNavigationShell
- goBranch restores the branch's last location
- inactive branches offstage, tickers muted
basics
~10 sUse StatefulShellRoute.indexedStack with one StatefulShellBranch per tab: each branch gets its own Navigator kept alive in an IndexedStack, and the tab bar calls navigationShell.goBranch(index), which restores that branch's last location instead of rebuilding it.
solid answer
~40 s`StatefulShellRoute.indexedStack` takes a list of `StatefulShellBranch`es, one per tab, each with its own `routes` and optionally a `navigatorKey`, `initialLocation` and `preload`. go_router builds a separate Navigator per branch and keeps them in an `IndexedStack`, with inactive branches wrapped in `Offstage` and `TickerMode(enabled: false)`, so the Feed tab's open post, scroll position and text input survive a trip to Profile. The builder receives a `StatefulNavigationShell`, which I use as the `Scaffold` body and whose `currentIndex` drives the bar. Tapping a tab calls `navigationShell.goBranch(index)`, which restores that branch's last match list, or its initial location on first visit; passing `initialLocation: index == navigationShell.currentIndex` makes re-tapping the active tab return to its root. The cost is memory: every visited branch stays mounted.
code
dart · 55 linesimport 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final GoRouter router = GoRouter(
initialLocation: '/feed',
routes: <RouteBase>[
StatefulShellRoute.indexedStack(
builder: (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (int index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
destinations: const <Widget>[
NavigationDestination(icon: Icon(Icons.home), label: 'Feed'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
},
branches: <StatefulShellBranch>[
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(
path: '/feed',
builder: (BuildContext context, GoRouterState state) => const Text('Feed'),
routes: <RouteBase>[
GoRoute(
path: 'post/:postId',
builder: (BuildContext context, GoRouterState state) =>
Text('Post ${state.pathParameters['postId']}'),
),
],
),
],
),
StatefulShellBranch(
preload: true,
routes: <RouteBase>[
GoRoute(path: '/search', builder: (BuildContext context, GoRouterState state) => const Text('Search')),
],
),
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(path: '/profile', builder: (BuildContext context, GoRouterState state) => const Text('Profile')),
],
),
],
),
],
);go deeper
Recall that StatefulShellRoute.indexedStack gives each StatefulShellBranch its own Navigator and that the tab bar calls navigationShell.goBranch.
Explain how goBranch restores a branch's last location, how initialLocation and preload behave, and why IndexedStack with Offstage keeps each tab's state.
Show you weigh the memory kept by mounted branches, pause work in hidden tabs, and use navigatorContainerBuilder when switching needs animation.
Decide which sections deserve persistent stacks, balancing user expectations of each tab against memory on low-end devices and restoration complexity.
## The requirement A social app has three bottom tabs: **Feed**, **Search** and **Profile**. A user opens a post in Feed, scrolls its comments, switches to Search to look something up, and returns to Feed. They expect the post, the comment scroll position and a half-typed reply to still be there. A plain `ShellRoute` cannot do this, because all tabs share one nested Navigator and switching tabs replaces its stack. ## StatefulShellRoute and its branches go_router's `StatefulShellRoute` builds **one Navigator per branch**, creating parallel navigation trees: - `StatefulShellBranch({required routes, navigatorKey, initialLocation, restorationScopeId, observers, preload = false})` describes one tab. Its routes are placed on the branch's own Navigator. - `initialLocation` is used the first time the branch is shown; without it, go_router uses the location of the branch's first descendant `GoRoute`. - `preload: true` (go_router 14.5.0) builds the branch the first time the shell is visited instead of on first tap; the source warns to use it sparingly because of the resource cost. - Navigator keys must be unique across branches, and a branch must not reuse an ancestor's key; both are asserted. The `StatefulShellRoute.indexedStack` constructor supplies the container that holds the branch Navigators. Its builder has the signature `(BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell)`. ## StatefulNavigationShell `StatefulNavigationShell` is the widget that manages the branch Navigators. In the builder it plays three roles: 1. It is the **body** of the shell: put it where page content appears. 2. `navigationShell.currentIndex` is the active branch index, which drives the bar's selected item. 3. `navigationShell.goBranch(int index, {bool initialLocation = false})` switches branches. `goBranch` restores the branch's **last match list** through the router, so the URL becomes that branch's last location and its stack reappears exactly as left. If the branch has never been visited, or `initialLocation` is `true`, it navigates to the branch's initial location instead. The pattern `goBranch(index, initialLocation: index == navigationShell.currentIndex)` gives the familiar behaviour of re-tapping the active tab to return to its root. ## A tap, step by step 1. The user is on `/feed/post/7` in the Feed branch and taps Search. 2. `onDestinationSelected(1)` calls `navigationShell.goBranch(1)`. 3. Search has a saved match list from an earlier visit, so go_router restores it; the location becomes, say, `/search?q=flutter`. 4. The shell rebuilds with `currentIndex` 1. The Feed branch Navigator is still mounted, now offstage, holding Feed and the post page. 5. The user taps Feed. `goBranch(0)` restores `/feed/post/7`, and the post reappears with its scroll offset, because its `State` never left the tree. 6. The user taps Feed again. With `initialLocation: index == navigationShell.currentIndex` this passes `true`, so the branch goes to its initial location `/feed` and the post page is removed. ## What keeps the state alive | Mechanism | Effect | |---|---| | one Navigator per branch | each tab has an independent page stack | | `IndexedStack` container | all loaded branches stay mounted, only one is shown | | `Offstage` on inactive branches | hidden branches are not painted or hit-tested | | `TickerMode(enabled: false)` on inactive branches | their animations stop ticking | | per-branch saved match list | `goBranch` can restore the last location | Because hidden branches stay mounted, their `State` objects, `ScrollController`s and text fields survive. That is also the **cost**: every visited branch keeps its widgets and resources in memory, and work such as a stream subscription in a hidden tab keeps running unless the page pauses it. ## Other routes into a branch `context.go('/feed/post/7')` from anywhere also activates the Feed branch, because the location matches a route inside it; the other branches keep their state. Deep links therefore land in the right tab with the right stack beneath, built from the route tree. ## When indexedStack is not enough The default container switches branches without animation. For a swipeable or animated switch, the general `StatefulShellRoute` constructor takes a `navigatorContainerBuilder` that receives the `StatefulNavigationShell` and the list of branch Navigator widgets, so you can lay them out in your own animated container. ## Restoration `restorationScopeId` on the `StatefulShellRoute` and on each branch lets the branch stacks survive process death through Flutter state restoration; go_router asserts that the shell has one whenever any branch sets one.
- With go_router, what does goBranch do when the target branch has never been visited?It navigates to the branch's initial location: `StatefulShellBranch.initialLocation` if set, otherwise the location of the branch's first descendant `GoRoute`. On later visits it restores the branch's saved match list, unless `initialLocation: true` is passed.
- With go_router, why can a hidden tab still consume resources under StatefulShellRoute.indexedStack?Loaded branches stay mounted in an `IndexedStack`; inactive ones are only `Offstage` with tickers disabled. Their State objects, controllers and subscriptions live on, so a feed that polls or listens to a stream keeps doing so unless the page pauses it.
- With go_router, when would you use StatefulShellBranch preload?When the first switch to a tab must feel instant, for example a Search tab whose initial page should already be built. `preload: true` builds the branch at the initial location the first time the shell is visited; the source advises using it sparingly because it spends memory and startup work up front.
Each branch is a separate notebook left open on the desk at the page you were reading; the tab bar only decides which notebook is on top. A ShellRoute, by contrast, is one notebook you flip back to page one every time you change subject.
saying these in an interview costs you the question
- Switches tabs with context.go to a hard-coded root, discarding the branch stack.
- Believes hidden branches are disposed and rebuilt on each tab switch.
- Reuses one GlobalKey for two branch Navigators.
- Expects StatefulShellRoute.indexedStack to animate between tabs by default.
- Stores the selected index in a separate widget instead of navigationShell.currentIndex.