"I Scrolled Down, But It Reset to Top!"
I was building an Instagram-like feed. The user scrolled down to the 50th post. They switched to the 'Profile' tab for a second and came back to 'Feed'. The scroll position reset to 0px (Top).
The user gets annoyed and closes the app. I, the developer, feel unjust. "Isn't it natural for the screen to redraw when switching tabs?"
But to the user, what IS natural is "Remembering where I was."
Keeping every tab alive in memory (KeepAlive) makes the phone hot.
Is there a way to verify only the position lightweight?
The Principle: Widgets Die, Data Survives
Flutter has an invisible vault called PageStorage.
Even if a widget is removed from the screen and Disposed, tiny data (Scroll offset, Input text) can be checked into this vault.
When the widget is recreated, it retrieves the data and restores itself.
The key to this vault is PageStorageKey.
Solution 1: PageStorageKey (The Magic)
Just add a Unique Key to your ListView or SingleChildScrollView.
ListView.builder(
// 🔑 Key: Saves and Restores Scroll Position
key: PageStorageKey('my_feed_list'),
itemBuilder: ...
)
That's it. Really.
- User scrolls down.
- Tab switch destroys ListView -> Flutter saves Offset to
PageStorageusing 'my_feed_list' key. - Tab switch back recreates ListView -> Checks 'my_feed_list', finds offset, jumps there.
Solution 2: Structure of BottomNavigationBar
If PageStorageKey doesn't work, 9/10 times your navigation structure swaps pages entirely.
// ❌ Bad: Replaces body every time
Scaffold(
body: _pages[_currentIndex], // Destroys old widget
bottomNavigationBar: ...
)
In this structure, sharing key data might fail if the PageStorage bucket isn't shared properly.
Using IndexedStack is the definitive fix.
// ✅ Good: Stacks all pages
Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages, // All pages exist in tree (just hidden)
),
)
IndexedStack keeps all children loaded. It consumes more memory but guarantees state preservation.
Deep Dive: Manual Saving
Want to save text inputs or accordion expansion states?
Use PageStorageBucket manually.
class MyWidgetState extends State<MyWidget> {
bool _isExpanded = false;
@override
void initState() {
super.initState();
// 1. Read saved state
_isExpanded = PageStorage.of(context).readState(context) ?? false;
}
void toggle() {
setState(() {
_isExpanded = !_isExpanded;
// 2. Write state
PageStorage.of(context).writeState(context, _isExpanded);
});
}
}
Tip: KeepAlive vs. PageStorage?
-
AutomaticKeepAliveClientMixin: Keeps the entire Widget alive in RAM.
- Pros: Instant switch. No API reloading.
- Cons: High memory usage.
- Use for: Complex feeds, heavy data pages.
-
PageStorage / PageStorageKey: Kills widget, saves lightweight state only.
- Pros: Memory efficient. Widget rebuilds.
- Cons: Re-rendering cost. Network calls might re-trigger if not cached.
- Use for: Simple lists, preserving scroll, toggle states.
7. Deep Dive: Inside AutomaticKeepAliveClientMixin
Many devs treat AutomaticKeepAliveClientMixin as a magic spell.
Knowing how it works helps you save memory.
The Core: KeepAliveNotification
- When you call
super.build(context), the mixin bubbles up a KeepAliveNotification. - An ancestor (specifically
SliverMultiBoxAdaptorElementinside Lists) catches this notification. - The ancestor marks this child as "Keep Alive". Even if it scrolls off-screen, it won't be disposed.
Dynamic wantKeepAlive
@override
bool get wantKeepAlive => true;
You can change this dynamically!
If a widget isn't "important" anymore, return false and call updateKeepAlive() to release memory.
bool _isImportant = false;
void _toggleInternalState() {
setState(() => _isImportant = !_isImportant);
updateKeepAlive(); // 👈 Notify the ancestor to update status
}
@override
bool get wantKeepAlive => _isImportant;
Use this to keep only the crucial items alive in a massive list.
8. Architecture: Router-Based Preservation (GoRouter ShellRoute)
Modern apps usage Nested Navigation (ShellRoute) instead of manual BottomNavigationBar.
State loss happens here too.
GoRouter 7.0+ introduced StatefulShellRoute. This is the Gold Standard.
// router.dart
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return Scaffold(
body: navigationShell, // IndexedStack is automated here
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(index),
),
);
},
branches: [
StatefulShellBranch(routes: [GoRoute(path: '/feed', builder: ...)]),
StatefulShellBranch(routes: [GoRoute(path: '/profile', builder: ...)]),
],
)
It automatically wraps your branches in an IndexedStack.
No manual keys. No mixins. It just works out of the box.
If you are starting a new project, use this architecture 100%.
9. Deep Dive: Global Bucket Management (Advanced)
By default, MaterialApp provides a root PageStorageBucket.
But checking custom scopes? Or sharing buckets across specific trees?
You can inject your own Bucket.
final bucketGlobal = PageStorageBucket();
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PageStorage(
bucket: bucketGlobal, // Injecting my own bucket
child: MaterialApp(home: HomePage()),
);
}
}
Since bucketGlobal is a variable outside the tree, even if the widget tree is torn down and rebuilt, the state persists as long as the variable lives.
You can theoretically persist scroll positions even after Popping and Pushing pages context (though not recommended for everything).
10. Refactoring Challenge: The Tab Switching Problem
Problem:
You have a standard BottomNavigationBar with 3 tabs: Home, Search, Profile.
When a user switches from Search (scrolled down) to Home and back, Search resets to top.
Goal:
Fix this WITHOUT using IndexedStack (assume memory is tight).
Use PageStorage explicitly.
Hint:
- Assign a
PageStorageBucketto theScaffoldof the main screen. - Assign a unique
PageStorageKeyto theListViewin each tab. - Ensure your
PageStorageKeystring is constant (e.g., 'search_list').
// The Bucket
final bucket = PageStorageBucket();
// In Scaffold
PageStorage(
bucket: bucket,
child: body,
)
// In SearchTab
ListView(
key: PageStorageKey('search_tab_scroll'),
children: ...
)
This ensures that even if the Widget is rebuilt, it looks into the specific Bucket to find its offset.
11. Case Study: The 3-Step Wizard Form
The best use case is a Multi-Step Form (Step 1 -> 2 -> 3).
The Situation
Registration flow:
- Email/Password
- Profile Photo
- Terms Agreement
User is at Step 2, then thinks "Did I typo my email?" and hits [Back]. Step 1 is reset. All inputs gone. User rage-quits.
The Fix: PageStorageKey
Just tag each page with a key.
PageView(
children: [
Step1EmailInput(key: PageStorageKey('step1')),
Step2ProfileUpload(key: PageStorageKey('step2')),
Step3Terms(key: PageStorageKey('step3')),
],
)
Now, Flutter sees the key and restores the text input state from the bucket.
While global state (Provider/Redux) is the "Official" way for forms, for simple flows, PageStorage is a zero-boilerplate miracle.
9. FAQ
Q: Does it persist after app restart?
A: NO! PageStorage lives in RAM. Use Hive, SharedPreferences, or HydratedBloc for disk persistence.
Q: TextFields inside ListView lose text on scroll.
A: ListView destroys off-screen items. Give each TextField a ValueKey or PageStorageKey. But if you have 100 items, managing keys is hell. In that case, lift the state up to a List<String> model.
Q: Where do I attach the key? A: Attach it to the Scrollable widget (ListView) or the StatefulWidget that holds the state you want to save.
Summary
Users love apps with good memory.
- Add
PageStorageKeyif scroll resets. - Use
IndexedStackif tabs wipe state. - Prefer
PageStorageoverKeepAlivefor memory efficiency.
This tiny detail removes friction and defines the polish of your app.