Tutorials

Build modular navigation tabs

Create a NavigationBar with independent Catalog and Profile Navigator stacks, retained state, and explicit branch activation.

v1.1.0Intermediate

Build modular navigation tabs

You will build a shell with Catalog and Profile. Open Product inside Catalog, switch to Profile, then return to Catalog. Product remains on top because each tab owns an independent Navigator stack.

The final Scope tree

text
root
└── shell
    ├── catalog-tab  [Catalog, Product]
    └── profile-tab  [Profile]
01

Give every Navigator stack a Scope ID

dart
const rootScopeId = NavigationScopeId('root');
const shellScopeId = NavigationScopeId('shell');
const catalogScopeId = NavigationScopeId('catalog-tab');
const profileScopeId = NavigationScopeId('profile-tab');

These IDs describe ownership, not tab order. The UI may reorder tabs without renaming Scopes.

02

Export one Module from each feature

dart
final catalogModule = NavigationModule(
  id: const NavigationModuleId('catalog'),
  routes: <NavigationRouteDefinitionBase>[catalogRoute, productRoute],
);
final profileModule = NavigationModule(
  id: const NavigationModuleId('profile'),
  routes: <NavigationRouteDefinitionBase>[profileRoute],
);

Feature modules contain route definitions. They do not know about NavigationBar or the final URL prefix.

03

Compose Scopes and Mounts in the app

dart
scopes: const <NavigationScopeDefinition>[
  NavigationScopeDefinition(id: rootScopeId),
  NavigationScopeDefinition(id: shellScopeId, parentId: rootScopeId),
  NavigationScopeDefinition(id: catalogScopeId, parentId: shellScopeId),
  NavigationScopeDefinition(id: profileScopeId, parentId: shellScopeId),
],
mounts: <NavigationMount>[
  shellMount,
  NavigationMount(
    id: const NavigationMountId('catalog-tab'),
    moduleId: catalogModule.id,
    scopeId: catalogScopeId,
    pathPrefix: '/catalog',
  ),
  NavigationMount(
    id: const NavigationMountId('profile-tab'),
    moduleId: profileModule.id,
    scopeId: profileScopeId,
    pathPrefix: '/profile',
  ),
],

Mounts are application choices. Another app can reuse the same Modules at other prefixes or Scopes.

04

Initialize each tab stack once

dart
Future<void> initializeBranches(BuildContext context) async {
  final navigation = context.readNavigation;
  await navigation.setStack(
    const <NavigationTarget<Object?>>[
      NavigationTarget<Object?>(
        destination: CatalogDestination(),
        mountId: NavigationMountId('catalog-tab'),
        scopeId: catalogScopeId,
      ),
    ],
    scopeId: catalogScopeId,
  );
  await navigation.setStack(
    const <NavigationTarget<Object?>>[
      NavigationTarget<Object?>(
        destination: ProfileDestination(),
        mountId: NavigationMountId('profile-tab'),
        scopeId: profileScopeId,
      ),
    ],
    scopeId: profileScopeId,
  );
  await navigation.activateBranch(catalogScopeId);
  await navigation.activateBranch(shellScopeId);
}

Call this once from didChangeDependencies, not from every build().

05

Render the active tab and wire NavigationBar

dart
@override
Widget build(BuildContext context) {
  final active = context.navigation.value
      .scope(shellScopeId).activeChildScopeId;
  final selectedIndex = active == profileScopeId ? 1 : 0;

  return Scaffold(
    body: const NavigationActiveOutlet(parentScopeId: shellScopeId),
    bottomNavigationBar: NavigationBar(
      selectedIndex: selectedIndex,
      onDestinationSelected: (index) {
        context.readNavigation.activateBranch(
          index == 0 ? catalogScopeId : profileScopeId,
        );
      },
      destinations: const <NavigationDestination>[
        NavigationDestination(icon: Icon(Icons.store), label: 'Catalog'),
        NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
      ],
    ),
  );
}

context.navigation subscribes the shell to Snapshot changes. readNavigation performs the action without adding another subscription.

06

Verify retained stacks

Push Product with scopeId: catalogScopeId, switch to Profile, and switch back. Do not call resetBranch during ordinary tab selection.

Use this only when state must be destroyed:

dart
await context.readNavigation.resetBranch(profileScopeId);

For example, reset private tabs after sign-out.