skip to content

With go_router, how do you build bottom tabs in a social app so each tab keeps its own navigation history?

level: seniorimportance: should knowfreq 48%

answer

  1. one Navigator per branch
  2. StatefulShellRoute.indexedStack with branches
  3. builder gets a StatefulNavigationShell
  4. goBranch restores the branch's last location
  5. inactive branches offstage, tickers muted

basics

~10 s

Use 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 lines
dart
import '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

for a junior

Recall that StatefulShellRoute.indexedStack gives each StatefulShellBranch its own Navigator and that the tab bar calls navigationShell.goBranch.

for a middle

Explain how goBranch restores a branch's last location, how initialLocation and preload behave, and why IndexedStack with Offstage keeps each tab's state.

for a senior

Show you weigh the memory kept by mounted branches, pause work in hidden tabs, and use navigatorContainerBuilder when switching needs animation.

for a principal

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.