skip to content

In Flutter, why does an ExpansionTile in a long ListView collapse after being scrolled away and back, and how does a PageStorageKey fix it?

level: middleimportance: nice to knowfreq 26%

answer

  1. off-screen items are disposed
  2. initState reads a stored value
  3. PageStorage bucket per route
  4. identifier from PageStorageKey chain
  5. no key means nothing saved

basics

~20 s

Off-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 s

A `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

for a junior

Remember that list items scrolled away are disposed and that ExpansionTile needs a unique PageStorageKey to remember its expanded state.

for a middle

Explain how the bucket builds its identifier from the chain of PageStorageKeys and why no key means nothing is stored.

for a senior

Choose between page storage, keep-alive and app state for each piece of per-item UI state, based on how long it must survive.

for a principal

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.