# Scroll restoration

> A list comes back where it was when its data is still cached, because the first build already has the rows — and what Flutter needs from you, a PageStorageKey or a restorationId, to put the position back.

The user scrolls halfway down a list of sixty devices, opens one, comes back
— and lands at the top of an empty list with a spinner, then at the top of
the full list. Two things went wrong there, and only one of them is about
data.

**The data half is solved by the cache.** Coming back to a list whose query
is still cached is instant: the first build after navigating back already
has the rows — a `QuerySuccess` on the first frame, no spinner and no empty
frame. That is what makes restoring a scroll position possible at all: a
saved offset into a list that is still loading has nothing to point at, and
Flutter clamps it to zero.

If the data is stale, it is refetched **behind** the rows, and
[structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md) keeps the instances of the rows
that did not change, so the list does not jump or rebuild what is the same.

**The position half is Flutter's.** What you need to do depends on how the
list went away.

## Pushed routes: nothing to do

`Navigator.push` keeps the route below alive, its widgets and its scroll
position included. Popping back to the list needs nothing from you or from
the cache. (The list's query still has a reader the whole time, so it is not
even collected.)

## Tabs, page views, switched bodies: `PageStorageKey`

A `TabBarView`, a `PageView`, or a screen that swaps its body builds the list
again from scratch when it comes back. Give the scrollable a
`PageStorageKey`, and Flutter's `PageStorage` keeps its offset while it is
gone and puts it back when it is built again — with its rows, since they
come from the cache:

```dart
class DeviceList extends StatelessWidget {
  const DeviceList({super.key, required this.kind});

  final String kind;

  @override
  Widget build(BuildContext context) {
    final devices = context.query(devicesOfKind(kind));
    final rows = devices.dataOrNull;
    if (rows == null) {
      return const Center(child: CircularProgressIndicator());
    }
    return ListView.builder(
      // Where PageStorage files this list's offset.
      key: PageStorageKey<String>('devices-$kind'),
      itemCount: rows.length,
      itemBuilder: (context, index) => DeviceTile(rows[index]),
    );
  }
}

class DeviceTabs extends StatelessWidget {
  const DeviceTabs({super.key});

  @override
  Widget build(BuildContext context) => DefaultTabController(
        length: 2,
        child: Scaffold(
          appBar: AppBar(
            bottom: const TabBar(
              tabs: <Widget>[Tab(text: 'Lights'), Tab(text: 'Shutters')],
            ),
          ),
          body: const TabBarView(
            children: <Widget>[
              DeviceList(kind: 'light'),
              DeviceList(kind: 'shutter'),
            ],
          ),
        ),
      );
}
```

Two details matter:

- **The spinner has no key.** While the rows are loading there is no list to
  restore; the `ListView` with the key is built only once there is data. The
  first time, that is after the fetch; every time after, it is the first
  frame.
- **One key per list.** Each tab's list files its offset under its own key
  (`devices-light`, `devices-shutter`), so switching tabs does not hand one
  list the other's position.

`AutomaticKeepAliveClientMixin` is the other way to keep a tab's position: it
keeps the whole tab alive while it is off screen. That costs the widgets'
memory, and the tab's queries keep a reader — so they keep polling and
refetching on focus. With a `PageStorageKey`, the tab is gone, its queries
have no reader, and it is rebuilt from the cache when it returns.

## How long the rows are there

The cache keeps a query without readers for its `gcTime` — five minutes by
default — before collecting it; see
[garbage collection](https://dualmeta-gmbh.github.io/query_kit/docs/guides/caching.md#garbage-collection). Come back within that
and the rows are there on the first frame. Come back later and the list
loads from scratch: the spinner shows first, and the saved offset is applied
when the keyed list is built with the new rows — a jump the user sees. An
infinite query is back to its first page then, so an offset further down is
clamped to what that page holds.

For a list the user returns to after longer breaks, raise the `gcTime` on
that query. An [infinite query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/infinite-queries.md) keeps every page it had
loaded — up to `maxPages` — so a long scrolled list comes back to its full
length, not to its first page.

## Across app restarts: state restoration

When the operating system ends a backgrounded app and the user returns to
it, Flutter's state restoration can bring the scroll position back: a
`restorationScopeId` on the app, a `restorationId` on the scrollable. The
data does not come back with it — the cache lives in memory — so the list
loads, and the scrollable takes its restored offset when it is built — so,
as with a `PageStorageKey`, build it only once the rows are there. An
infinite query starts again from its first page. There is no persistence of
the cache in 1.0.

Try it: in the screen below, press *Load more* a couple of times, then press
*Go to about*. The list is unmounted — its query has no reader — and *Back
to list* rebuilds it with every page on the first frame and no new request.
This demo's list has no `PageStorageKey`, so it comes back at the top: the
rows are the cache's half, the position is the part this page adds.

Live demo: [Load more and infinite scroll](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/load-more), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/load_more)). An infinite query that appends pages as you scroll.

> **Note: In React Query**
>
> The web has the browser's own scroll restoration, which works when the data
> is cached and the first render has it; TanStack's guide says as much. In
> Flutter the position is `PageStorage`'s or state restoration's, and the
> cache supplies the same thing — the rows on the first build. See
> [differences from TanStack Query](https://dualmeta-gmbh.github.io/query_kit/docs/reference/differences-from-tanstack.md).
