In Flutter, why does an ExpansionTile in a long ListView collapse after being scrolled away and back, and how does a PageStorageKey fix it?
answer
- off-screen items are disposed
- initState reads a stored value
- PageStorage bucket per route
- identifier from PageStorageKey chain
- no key means nothing saved
basics
~20 sOff-screen items in a lazy ListView are disposed, so the tile's new State starts collapsed. With a unique PageStorageKey, ExpansionTile writes its expanded flag to the route's PageStorage bucket and reads it back when rebuilt.
solid answer
~40 sA `ListView` builds items lazily and disposes those that scroll far enough out of view, so an `ExpansionTile` returning to the screen gets a fresh `State` that reads `initiallyExpanded`, false by default. `ExpansionTile` is built on `Expansible`, which writes its expanded flag to `PageStorage.maybeOf(context)` whenever it toggles and reads it back in `initState`. Every `ModalRoute` already provides a `PageStorage`. The storage identifier is the chain of `PageStorageKey`s between the widget and that `PageStorage`, and with no such key nothing is saved at all. So give each tile a unique, stable `PageStorageKey`, for example `PageStorageKey<String>(album.id)`. `PageStorageKey` is a `ValueKey` subclass, so it also acts as the tile's normal key, and the saved value lives in memory for the route only, not across app restarts.
go deeper
Remember that list items scrolled away are disposed and that ExpansionTile needs a unique PageStorageKey to remember its expanded state.
Explain how the bucket builds its identifier from the chain of PageStorageKeys and why no key means nothing is stored.
Choose between page storage, keep-alive and app state for each piece of per-item UI state, based on how long it must survive.
Set a convention for which UI state is ephemeral, route-scoped or persisted, so each widget does not reinvent where its flags live.
## Why the tile forgets A playlist library groups tracks into album sections, each an `ExpansionTile`. The user expands "Album 12", scrolls to the bottom and back up, and finds it collapsed again. Two facts combine: 1. **Lazy lists dispose off-screen items.** `ListView.builder` (and a long `ListView`) only keeps elements for items near the viewport. Items far enough out of view are removed, and their `State` objects are disposed. 2. **A new `State` starts from its constructor defaults.** When the section scrolls back in, a new element and `State` are created. `ExpansionTile.initiallyExpanded` defaults to `false`, so the tile appears collapsed. Keeping items alive with keep-alive mechanisms is a separate, list-level technique. `PageStorageKey` solves it by **storing the flag outside the element**, where it survives the element's disposal. ## How PageStorage works - **`PageStorage`** is a widget holding a `PageStorageBucket`, an in-memory map. You rarely add one yourself: the docs note it is "already included in routes", since every `ModalRoute` wraps its page in one. - A widget that wants to persist a value calls `PageStorage.maybeOf(context)?.writeState(context, value)` and later `readState(context)`. - The bucket derives the **storage identifier** from the context: it collects every `PageStorageKey` on the path from that widget up to the nearest `PageStorage`, including the widget's own key. - If **no `PageStorageKey`** is on that path, `writeState` saves nothing and `readState` returns `null`. `ExpansionTile` delegates to `Expansible`, which does exactly this: in `initState` it reads the stored `bool` and falls back to the controller's initial value; each time it toggles, it writes the new value. That is why the `ExpansionTile` docs say a unique `PageStorageKey` "must be specified as the key" when the tile is used inside a scrolling list. ## Applying the fix ```dart ListView.builder( itemCount: albums.length, itemBuilder: (BuildContext context, int index) { final Album album = albums[index]; return ExpansionTile( key: PageStorageKey<String>(album.id), title: Text(album.title), children: [for (final Track t in album.tracks) ListTile(title: Text(t.title))], ); }, ) ``` ## Rules for the key value | Rule | Why | |---|---| | Unique within the nearest `PageStorage`, together with its ancestor chain | Two tiles with the same identifier would overwrite each other's flag | | Stable across rebuilds | The docs warn the value must not be an object whose identity changes each time the widget is created | | Derived from the data, not the index | An index would restore the flag onto whichever album now sits at that index | Because `PageStorageKey` **extends `ValueKey`**, it doubles as the widget's ordinary key: equality delegates to the value's `==`, and `runtimeType` is compared first, so a `PageStorageKey('a')` never equals a plain `ValueKey('a')`. ## Limits worth stating in an interview - The bucket is **in memory** and scoped to the route's `PageStorage`: pop the route or kill the process and the flags are gone. Surviving process death is a different restoration mechanism. - It stores only what a widget chooses to write. `ExpansionTile`, `Expansible` and scrollables use it; your own `StatefulWidget` must call `writeState` and `readState` itself to benefit. - A user choice that matters beyond this screen visit, such as which sections are pinned open, belongs in app state rather than page storage. ## Debugging a tile that still forgets 1. **Check where the key sits.** The identifier is collected upward from the widget that writes, so the `PageStorageKey` must be on the `ExpansionTile` itself or on an ancestor between it and the route, not on a child inside the tile. 2. **Check that values are unique.** Two sections keyed by the same title share one entry and open and close each other. 3. **Check which `PageStorage` is nearest.** A nested `Navigator` gives each of its routes its own bucket; popping that inner route discards the flags stored there. 4. **Remember when it is read.** `Expansible` reads the stored flag only in `initState`, so the key matters only when the tile's `State` is actually recreated, which is exactly the scroll-away case.
- Why must the PageStorageKey value be unique, not just present?The bucket identifies an entry by the list of `PageStorageKey`s from the widget up to its `PageStorage`. Two tiles whose chains are equal share one entry, so toggling one writes the flag the other later reads back, and sections appear to open and close together.
- Can your own StatefulWidget use PageStorageKey the same way?Yes, but the key alone does nothing. The widget must call `PageStorage.maybeOf(context)?.writeState(context, value)` when its value changes and `readState(context)` when it initialises; a `PageStorageKey` on it or on an ancestor then gives that stored value a unique identifier.
saying these in an interview costs you the question
- PageStorageKey saves the expanded state to disk so it survives app restarts.
- Any key works here, because every key stores its widget's state.
- You must wrap the screen in a PageStorage widget before ExpansionTile can use it.
- ValueKey(index) is a safe PageStorageKey value for sections in a list.