Skip to main content

Multi-page shells

A multi-page shell is any screen that hosts several sibling pages the user switches between without leaving the screen: a bottom navigation bar, top tabs, a navigation rail, a segmented control, a wizard. The shell owns one piece of state - which page is selected - and switches between page contents.

Two rules make this composable, and neither bends:

  1. The shell is a Screen / State / View triple. It owns the selected-page state and any shell-level cross-cutting effects.
  2. Every inner page is the same full triple. Not a monolithic HookWidget, not a StatefulWidget, not an inline StreamBuilder. Being embedded in a PageView or IndexedStack, having a parent shell, or being "simple" does not exempt a page from the pattern.

Everything else - int index versus an enum, IndexedStack versus PageView versus TabBarView, local versus global selection - is a per-project choice. The composition rule is the constant.

The shell state owns the selection

Identify pages with an enum rather than a bare index: it is refactor-safe, and it can carry per-page metadata (the builder, an icon, a label, a role gate). The shell state holds the current page and an onPageChanged action; the hook is a single useState.

enum HomePage {
home(_HomePageContent.new, Icons.home),
search(_SearchPageContent.new, Icons.search),
profile(_ProfilePageContent.new, Icons.person);

final Widget Function() builder;
final IconData icon;

const HomePage(this.builder, this.icon);
}
class HomeScreenState {
final HomePage currentPage;
final void Function(HomePage) onPageChanged;

const HomeScreenState({
required this.currentPage,
required this.onPageChanged,
});
}

HomeScreenState useHomeScreenState() {
final pageState = useState(HomePage.home);

return HomeScreenState(
currentPage: pageState.value,
onPageChanged: (page) => pageState.value = page,
);
}

Switching pages in the View

The simplest container is an IndexedStack: it builds and keeps every page mounted, so scroll positions and half-filled forms survive a switch. The navigation bar is a stateless widget that renders state.currentPage and calls state.onPageChanged - it tracks no index of its own.

class HomeScreenView extends StatelessWidget {
final HomeScreenState state;

const HomeScreenView({required this.state});


Widget build(BuildContext context) {
return Scaffold(
// IndexedStack keeps every page mounted, preserving scroll and form state.
body: IndexedStack(
index: state.currentPage.index,
children: [for (final page in HomePage.values) page.builder()],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: state.currentPage.index,
onTap: (index) => state.onPageChanged(HomePage.values[index]),
items: [
for (final page in HomePage.values)
BottomNavigationBarItem(icon: Icon(page.icon), label: page.name),
],
),
);
}
}

The View never computes selection logic. It reads state.currentPage, renders the pages, and forwards taps to state.onPageChanged. The enum keeps magic index numbers (if (index == 2)) out of the code entirely.

Swipeable and tabbed shells

A PageView (swipe) or a TabBarView (Material tabs) needs a controller, and a controller's lifecycle does not compose with hook rebuilds. The two controller wrappers own that lifecycle for you, syncing the controller to a MutableValue<int> in both directions. Expose an int view over the enum selection with MutableValue.computed:

// Expose an int view over the enum selection, for controller-backed containers.
extension HomeScreenStatePageIndex on HomeScreenState {
MutableValue<int> get pageIndex => MutableValue.computed(
() => currentPage.index,
(it) => onPageChanged(HomePage.values[it]),
);
}

PageControllerWrapper owns the PageController - creation, disposal, the not-yet-attached (hasClients) case, and the two-way sync. Its default transition is jumpToPage; pass onTransition to animate instead:

class SwipeableHomeView extends StatelessWidget {
final HomeScreenState state;

const SwipeableHomeView({required this.state});


Widget build(BuildContext context) {
// PageControllerWrapper owns the PageController: creation, disposal, and
// two-way sync with state.pageIndex. The default transition is jumpToPage;
// pass onTransition to animate instead.
return Scaffold(
body: PageControllerWrapper(
index: state.pageIndex,
onTransition: (controller, index) => controller.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
builder: (controller) => PageView(
controller: controller,
children: [for (final page in HomePage.values) page.builder()],
),
),
);
}
}

TabControllerWrapper is the equivalent for Material tabs. It requires a length, manages the TickerProvider, and defaults its transition to animateTo to drive TabBar's indicator animation:

class TabbedHomeView extends StatelessWidget {
final HomeScreenState state;

const TabbedHomeView({required this.state});


Widget build(BuildContext context) {
// TabControllerWrapper needs length and stays synced with state.pageIndex.
// Its default transition is animateTo, driving TabBar's indicator animation.
return TabControllerWrapper(
length: HomePage.values.length,
index: state.pageIndex,
builder: (controller) => Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: controller,
tabs: [for (final page in HomePage.values) Tab(icon: Icon(page.icon))],
),
),
body: TabBarView(
controller: controller,
children: [for (final page in HomePage.values) page.builder()],
),
),
);
}
}

Use swipe only when it is the intended UX: PageView and TabBarView mount pages lazily as they scroll into view and dispose them when they leave (unless a page opts into AutomaticKeepAliveClientMixin), whereas IndexedStack keeps them all alive. If a page holds expensive or input-bearing state you want preserved, prefer IndexedStack.

Every inner page is a full triple

This is the rule that decays first under deadline pressure, so it is worth stating plainly: an inner page is a regular screen. There is no "lightweight page" tier. Each page gets its own pages/<name>/<name>_page.dart (pure wiring), state/<name>_page_state.dart (the State class and hook, all logic), and view/<name>_page_view.dart (a stateless View taking only state). The single difference from a top-level screen is that an embedded page may omit the route / buildRoute statics, since it is never pushed.

Inside a page, global state is read through useProvided<X>() in that page's own state hook - never by reaching up into the shell's State, never through an ancestor context lookup. If a page needs to cause a shell-level effect (jump to another tab), the selection moves into a global state both consult; the page does not walk the widget tree to find the shell.

Red flags that the rule is being violated:

  • A *Page widget is the only file in its folder, with no state/ or view/ siblings.
  • An inner page's build is longer than a handful of lines.
  • StreamBuilder, FutureBuilder, useProvided, or useInjected appears in an inner page's View.
  • Any fetch, derivation, or business logic is written inline in the page widget.

Patterns and pitfalls

  • Selection lives in shell state by default. Move currentPage into a global state only when deep links target specific tabs, the user configures tab order at runtime, or other screens jump to a tab. Then the shell reads it via useProvided and the rest is identical.
  • Compute visible pages in the state, not the View. When feature flags or a user role gate a tab, derive a visiblePages list in the hook and index into it - do not hardcode every page in the View while a flag exists.
  • Shell-level cross-cutting effects belong to the shell hook. Deep-link handling, notification routing, first-launch onboarding - these are the shell's concern, not any single page's. When there is more than one, split them into sub-hooks the shell hook composes.
caution

Do not let the navigation bar become a StatefulWidget that tracks its own selected index with setState, and do not call controller.animateToPage(...) from an onTap in the View. Selection is shell state. The nav widget is stateless, receives state, and calls state.onPageChanged - the wrapper drives the controller from there.

See also