When you're building Flutter applications that have multiple tabs or screens, one of the most common challenges you'll face is maintaining state across navigation without breaking the user experience. It becomes obvious when a user switches tabs and suddenly loses scroll position, form input, or previously loaded data.
This problem isn't caused by Flutter being inefficient. It's usually a result of how widgets are rebuilt during navigation.
A practical and often overlooked solution to this is to use the IndexedStack widget. It lets you switch between screens while keeping their state intact, which leads to smoother navigation and better performance.
This article takes a deeper look at how IndexedStack works, why it matters, and how to use it properly in real applications.
Table of Contents
Prerequisites
To follow along comfortably, you should already understand how Flutter widgets work, especially the difference between StatelessWidget and StatefulWidget.
You should also be familiar with Scaffold, BottomNavigationBar, and how Flutter rebuilds widgets when state changes.
Finally, a basic understanding of how the widget tree behaves will help you grasp the concepts more clearly.
The Real Problem with Tab Navigation
A common way to implement tab navigation looks like this:
body: _tabs[_currentIndex],
At first glance, this seems correct and works for simple cases. But under the hood, something important happens every time the index changes.
Flutter removes the current widget from the tree and builds a new one. This means the previous tab is destroyed and the new tab starts from scratch.
This leads to a number of issues. Scroll positions are lost. Text fields reset. Network requests may run again. The overall experience feels inconsistent and sometimes frustrating to users.
Visualizing the Default Behavior
Without any form of state preservation, switching tabs behaves like this:
User selects a new tab
Current tab is removed from memory
New tab is created again
At any point in time, only one tab exists in memory. Everything else is discarded.
Understanding IndexedStack
IndexedStack changes this behavior completely. Instead of rebuilding widgets, it keeps all of them alive and only changes which one is visible.
Internally, it stores all its children and uses an index to decide which one should be shown.
Here's a simple mental model of how it works:
IndexedStack
├── Tab 0
├── Tab 1
├── Tab 2
└── Tab 3
Only one tab is visible
All tabs remain in memory
This means that when you switch tabs, nothing is destroyed. The UI simply switches visibility.
Why IndexedStack Improves User Experience
The most immediate benefit is that state is preserved. If a user scrolls halfway down a list in one tab, switches to another, and comes back, the scroll position remains exactly where they left it.
The same applies to form inputs, animations, and any UI state that would normally reset.
Another benefit is performance stability. Since widgets aren't rebuilt repeatedly, the application avoids unnecessary work. This is especially important when tabs contain heavy UI or expensive operations such as API calls.
Building a Task Manager Example
To make this more practical, let's look at a task manager application with four tabs. These tabs represent Today, Upcoming, Completed, and Settings.
Below is a full implementation using IndexedStack:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Task Manager',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const TaskManagerScreen(),
);
}
}
class TaskManagerScreen extends StatefulWidget {
const TaskManagerScreen({super.key});
@override
State<TaskManagerScreen> createState() => _TaskManagerScreenState();
}
class _TaskManagerScreenState extends State<TaskManagerScreen> {
int _currentIndex = 0;
final List<Widget> _tabs = [
TodayTasksTab(),
UpcomingTasksTab(),
CompletedTasksTab(),
SettingsTab(),
];
void _onTabTapped(int index) {
setState(() {
_currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Task Manager'),
),
body: IndexedStack(
index: _currentIndex,
children: _tabs,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: _onTabTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.today),
label: 'Today',
),
BottomNavigationBarItem(
icon: Icon(Icons.upcoming),
label: 'Upcoming',
),
BottomNavigationBarItem(
icon: Icon(Icons.done),
label: 'Completed',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
class TodayTasksTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: 50,
itemBuilder: (context, index) {
return ListTile(title: Text('Today Task $index'));
},
);
}
}
class UpcomingTasksTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(child: Text('Upcoming Tasks'));
}
}
class CompletedTasksTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(child: Text('Completed Tasks'));
}
}
class SettingsTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(child: Text('Settings'));
}
}
This Flutter application starts by running MyApp, which sets up a MaterialApp with a title, theme, and the TaskManagerScreen as the home screen. There, a stateful widget manages the currently selected tab index and uses an IndexedStack to display one of four tab screens while keeping all of them alive in memory.
A BottomNavigationBar allows the user to switch between tabs, and each tab is implemented as a separate stateless widget that renders its own content (such as a scrollable list for today’s tasks or simple text views for the other sections).
Handling Independent Navigation Per Tab
One limitation you'll quickly run into is this: while IndexedStack preserves the state of each tab, it doesn't automatically give each tab its own navigation stack.
In real applications, each tab often needs its own internal navigation. For example, in a task manager, the “Today” tab might navigate to a task details screen, while the “Settings” tab navigates to preferences screens. These navigation flows shouldn't interfere with each other.
To solve this, you can combine IndexedStack with a separate Navigator for each tab.
Conceptual Structure
IndexedStack
├── Navigator (Tab 0)
│ ├── Screen A
│ └── Screen B
├── Navigator (Tab 1)
├── Navigator (Tab 2)
└── Navigator (Tab 3)
Each tab now manages its own navigation history independently.
Implementation
class TaskManagerScreen extends StatefulWidget {
const TaskManagerScreen({super.key});
@override
State<TaskManagerScreen> createState() => _TaskManagerScreenState();
}
class _TaskManagerScreenState extends State<TaskManagerScreen> {
int _currentIndex = 0;
final _navigatorKeys = List.generate(
4,
(index) => GlobalKey<NavigatorState>(),
);
void _onTabTapped(int index) {
if (_currentIndex == index) {
_navigatorKeys[index]
.currentState
?.popUntil((route) => route.isFirst);
} else {
setState(() {
_currentIndex = index;
});
}
}
Widget _buildNavigator(int index, Widget child) {
return Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (routeSettings) {
return MaterialPageRoute(
builder: (_) => child,
);
},
);
}
@override
Widget build(BuildContext context) {
final tabs = [
_buildNavigator(0, const TodayTasksTab()),
_buildNavigator(1, const UpcomingTasksTab()),
_buildNavigator(2, const CompletedTasksTab()),
_buildNavigator(3, const SettingsTab()),
];
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: tabs,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: _onTabTapped,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.today), label: 'Today'),
BottomNavigationBarItem(icon: Icon(Icons.upcoming), label: 'Upcoming'),
BottomNavigationBarItem(icon: Icon(Icons.done), label: 'Completed'),
BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
],
),
);
}
}
This implementation of TaskManagerScreen uses a stateful widget to manage tab navigation by maintaining the current tab index and a separate Navigator for each tab through unique GlobalKeys. This allows each tab to have its own independent navigation stack.
The _onTabTapped method either switches tabs or resets the current tab’s navigation to its root if tapped again. The IndexedStack ensures all tab navigators remain alive in memory while only the selected one is visible, resulting in preserved state and seamless navigation across tabs.
What This Solves
Each tab now behaves like a mini app. Navigation inside one tab doesn't affect another tab. When a user switches tabs and comes back, they return to exactly where they left off, including nested screens.
This is the pattern used in production apps like banking apps, social platforms, and dashboards.
Combining IndexedStack with State Management
Another mistake developers make is relying on IndexedStack as a full state management solution. But it's not that.
IndexedStack preserves widget state, but it doesn't manage business logic or shared data.
For scalable applications, you should still use a proper state management solution such as BLoC, Provider, or Riverpod.
Example with BLoC
Each tab can listen to its own stream of data while still being preserved in memory.
class TodayTasksTab extends StatelessWidget {
const TodayTasksTab({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<List<String>>(
stream: getTasksStream(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final tasks = snapshot.data!;
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
return ListTile(title: Text(tasks[index]));
},
);
},
);
}
}
Because the tab isn't rebuilt, the stream subscription remains stable and doesn't restart unnecessarily.
Performance Considerations
You need to be deliberate here. IndexedStack keeps everything alive, which means memory usage grows with each tab.
Internal Behavior
All children are built once
All remain mounted
Only visibility changes
This is efficient for interaction but not always for memory.
When This Becomes a Problem
If each tab contains heavy widgets like large lists, images, or complex animations, memory usage can increase significantly.
In extreme cases, this can lead to frame drops or even app crashes on low-end devices.
Practical Strategy
Use IndexedStack for a small number of core tabs. Usually between three and five is reasonable.
If you find yourself adding many more screens, reconsider your navigation structure instead of forcing everything into a single stack.
Common Mistakes
One common mistake is assuming IndexedStack delays building widgets. It doesn't. All children are built immediately.
Another mistake is mixing IndexedStack with logic that expects rebuilds. Since widgets persist, some lifecycle methods may not behave as expected.
Developers also sometimes forget that memory is being retained, which leads to subtle performance issues later (as we just discussed).
Mental Model That Will Save You Time
Think of IndexedStack as a visibility switch, not a navigation system.
Navigator → controls screen transitions
IndexedStack → controls visibility of persistent screens
State management → controls data and logic
Once you separate these concerns, your architecture becomes much clearer and easier to scale.
Visual Comparison
To really understand the difference, compare both approaches.
Without IndexedStack:
Switch Tab
→ Destroy current screen
→ Rebuild new screen
→ Lose state
With IndexedStack:
Switch Tab
→ Keep all screens alive
→ Only change visibility
→ State remains intact
Important Trade-off
It's important to remember that IndexedStack keeps all children in memory at the same time.
Again, this is usually fine for a small number of tabs, but if each tab contains heavy widgets or large data sets, memory usage can increase.
So the decision isn't just about convenience. It's about choosing the right tool for the right scenario.
If your tabs are lightweight and require state preservation, IndexedStack is a strong choice. If your tabs are heavy and rarely revisited, rebuilding them might actually be better.
So to summarize:
IndexedStackis ideal when each tab has its own independent state and the user is expected to switch between them frequently. It is especially useful in dashboards, task managers, finance apps, and social apps where continuity matters.If your application has a large number of screens or each screen consumes significant memory, keeping everything alive can become inefficient. In such cases, using navigation with proper state management solutions like BLoC, Provider, or Riverpod may be a better approach.
Conclusion
IndexedStack is simple on the surface, but its real power shows up in complex applications where user experience matters. It eliminates unnecessary rebuilds, preserves UI state, and creates a smoother interaction model.
But make sure you use it intentionally. It's not a replacement for navigation or state management, but a complementary tool.
If you combine it correctly with nested navigation and proper state management, you get an architecture that feels seamless to users and remains maintainable as your app grows.