When you’re building filter applications that have multiple tabs or screens, one of the most common challenges you’ll face is maintaining state in navigation without breaking the user experience. This becomes evident when a user switches tabs and suddenly loses scroll position, form input, or pre-filled data.
This problem is not caused by a malfunctioning filter. This is usually a result of how the widgets are re-arranged during navigation.
A practical and often overlooked solution to this is to use IndexedStack Widget This lets you switch between screens while maintaining their state, leading to smoother navigation and better performance.
This article takes a closer look at how. IndexedStack works, why it matters, and how to use it properly in real applications.
Table of Contents
Conditions
To get along comfortably, you should first understand how filter widgets work, especially the differences between them. StatelessWidget And StatefulWidget.
You should also know. Scaffold, BottomNavigationBarand how the filter recreates the widgets when the state changes.
Finally, a basic understanding of widget tree behavior will help you understand the concepts more clearly.
The real problem with tab navigation
A typical 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.
Flicking removes the current widget from the tree and creates a new one. This means the previous tab is destroyed and the new tab starts from scratch.
This leads to several problems. Scroll positions are lost. Text fields were rearranged. Network requests can be restarted. The overall experience feels inconsistent and sometimes frustrating for users.
Presuming default behavior
Without any form of state protection, switching tabs behaves like this:
User selects a new tab
Current tab is removed from memory
New tab is created again

At any given time, only one tab exists in memory. Everything else is discarded.
Understanding IndexedStack
IndexedStack This behavior changes completely. Instead of recreating the widgets, it keeps them all alive and only changes what’s visible.
Internally, it stores all its children and uses the index to decide which should be displayed.
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 nothing gets destroyed when you switch tabs. UI changes visibility easily.
Why IndexedStack Improves User Experience
The most immediate benefit is that the state remains secure. 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 typically resets.
Another benefit is performance stability. Since widgets are not recreated frequently, the application avoids unnecessary work. This is especially important when tabs contain heavy UI or expensive operations such as API calls.
Creating a task manager instance
To make it more practical, let’s look at a task manager application with four tabs. These tabs represent Today, Upcoming, Complete, and Settings.
Below is a complete 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 createState() => _TaskManagerScreenState();
}
class _TaskManagerScreenState extends State {
int _currentIndex = 0;
final List _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'));
}
}
It starts by running the filter application. MyAppwhich sets up a MaterialApp With title, theme and TaskManagerScreen As home screen. There, a state widget manages the currently selected tab index and uses a IndexedStack Displaying one of the four tab screens while keeping them all alive in memory.
Oh BottomNavigationBar Allows the user to switch between tabs, and each tab is implemented as a separate stateless widget that presents its own content (such as a scrollable list for today’s tasks or plain text views for other sections).
Handling independent navigation per tab
A range in which you will run faster is: 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 go to task details screens, while the “Settings” tab might go to preferences screens. These navigation flows should not interfere with each other.
To solve this, you can collect 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 navigation history independently.
Implementation
class TaskManagerScreen extends StatefulWidget {
const TaskManagerScreen({super.key});
@override
State createState() => _TaskManagerScreenState();
}
class _TaskManagerScreenState extends State {
int _currentIndex = 0;
final _navigatorKeys = List.generate(
4,
(index) => GlobalKey(),
);
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. Navigator For each tab by unique GlobalKeys This allows each tab to have its own independent navigation stack.
gave _onTabTapped The method either switches tabs or resets the current tab’s navigation to its root if tapped again. gave IndexedStack Ensures that all tab navigators live in memory while only the selected one is visible, resulting in a safe state and smooth navigation across all tabs.
What does it solve?
Each tab now behaves like a mini app. Navigation within one tab does not affect other tabs. When the user switches tabs and returns, they return to exactly where they left off, including nested screens.
This is the pattern used in production apps such as banking apps, social platforms, and dashboards.
Combining IndexedStack with State Management
Another mistake developers make is over-reliance. IndexedStack As a complete state management solution. But it is not so.
IndexedStack Preserves widget state, but does not manage business logic or shared data.
For scalable applications, you should still use an appropriate state management solution such as BLoC, Provider, or Riverpod.
Example with BLOC
Each tab can listen to its own stream of data while being stored in memory.
class TodayTasksTab extends StatelessWidget {
const TodayTasksTab({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder>(
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)));
},
);
},
);
}
}
Since the tab is not recreated, the stream subscription remains stable and is not restarted unnecessarily.
Performance considerations
You need to be intentional here. IndexedStack Keeps everything alive, which means memory usage increases with each tab.
Internal behavior
All children are built once
All remain mounted
Only visibility changes
This is effective for communication but not always for memory.
When it becomes a problem.
If each tab contains heavy widgets such as 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 devices.
Practical strategy
use IndexedStack For a small number of basic tabs. Usually between three and five is reasonable.
If you find yourself adding a lot of screens, rethink your navigation structure instead of putting everything in a single stack.
Common mistakes
A common mistake is to assume. IndexedStack Delay in creating widgets. It doesn’t happen. All children are built immediately.
Another mistake is confusion. IndexedStack With logic that expects rebuilds. Because widgets are persistent, some lifecycle methods may not behave as expected.
Developers sometimes also forget that memory is being persisted, which leads to subtle performance issues later (as we just discussed).
A mental model that will save you time.
think 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 isolate these concerns, your architecture becomes clearer and easier to scale.
Visual comparison
To really understand the difference, compare the two methods.
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-offs
This is important to remember. IndexedStack Holds all children in memory at the same time.
Again, this is usually fine for a small number of tabs, but if each tab has heavy widgets or large datasets, memory usage can increase.
So the decision is not just about convenience. It’s all about choosing the right tool for the right scenario.
If your tabs are lightweight and require state protection, IndexedStack is a strong choice. If your tabs are bulky and rarely revisited, it may actually be better to recreate them.
So to summarize:
IndexedStackThis is ideal when each tab has its own independent state and the user is expected to switch between them frequently. This is especially useful in dashboards, task managers, finance apps, and social apps where consistency matters.If your application has a large number of screens or each screen consumes significant memory, keeping everything alive can be inefficient. In such cases, using navigation with a suitable state management solution such as BLOC, Provider, or Riverpod may be a better approach.
The result
IndexedStack Simple on the surface, but its real power appears in complex applications where user experience matters. This eliminates unnecessary reconstruction, preserves UI state, and creates a smooth interaction model.
But make sure you use it deliberately. It is not a replacement for navigation or state management, but a complementary tool.
If you combine this with nested navigation and proper state management, you get an architecture that feels seamless to users and remains consistent as your app grows.