skip to content

In a Flutter weather screen built on FutureBuilder, how do you implement pull-to-refresh that keeps showing the old forecast instead of flashing a full-screen spinner?

level: seniorimportance: should knowfreq 32%

answer

  1. new future via setState
  2. old data survives the switch
  3. hasData while waiting
  4. return the future from onRefresh
  5. stale results are ignored

basics

~20 s

In RefreshIndicator.onRefresh, assign a new future in setState and return it. FutureBuilder keeps the previous result in snapshot.data while waiting, so the builder shows old data whenever hasData is true and spins only when there is none.

solid answer

~40 s

Keep the future in a `State` field. In `RefreshIndicator(onRefresh: ...)`, call `setState(() => _forecast = fetch(city))` and return `_forecast`, so the indicator spins until it completes. When `FutureBuilder` gets the new future, it keeps the previous snapshot's data and moves through `none` to `waiting`, so a builder that checks `hasData` before `connectionState` keeps rendering the old forecast, optionally with a thin progress bar when `connectionState == ConnectionState.waiting`. Show the full-screen spinner only when there is no data at all. Two edges: if the refresh fails, the snapshot becomes `done` with an error and no data, so the builder must decide whether to keep a copy of the last good forecast; and a refresh started before an earlier one completes is safe, because `FutureBuilder` ignores results from futures it no longer holds.

code

dart · 39 lines
dart
class _ForecastScreenState extends State<ForecastScreen> {
  late Future<List<String>> _forecast = widget.fetch(widget.city);

  Future<void> _refresh() {
    final next = widget.fetch(widget.city);
    setState(() => _forecast = next);
    return next;
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: _refresh,
      child: FutureBuilder<List<String>>(
        future: _forecast,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            final refreshing =
                snapshot.connectionState == ConnectionState.waiting;
            return ListView(
              physics: const AlwaysScrollableScrollPhysics(),
              children: [
                if (refreshing) const LinearProgressIndicator(),
                for (final day in snapshot.requireData) ListTile(title: Text(day)),
              ],
            );
          }
          if (snapshot.hasError) {
            return ListView(
              physics: const AlwaysScrollableScrollPhysics(),
              children: const [ListTile(title: Text('Forecast unavailable. Pull to retry'))],
            );
          }
          return const Center(child: CircularProgressIndicator());
        },
      ),
    );
  }
}

go deeper

for a junior

Recall that refreshing means assigning a new future in setState and that RefreshIndicator waits for the future onRefresh returns.

for a middle

Explain that FutureBuilder carries the old data into the new future's waiting snapshot and why checking hasData first avoids the spinner flash.

for a senior

Handle the failed-refresh case deliberately, keep a last-good copy or move caching to a repository, and explain why stale results cannot race.

for a principal

Decide product-wide whether stale data with a banner beats an error screen, and where caching lives so each screen does not reinvent it.

## The naive version and its flash A common first attempt branches on `connectionState`: ```dart if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } ``` On first load that is fine. On refresh, the new future puts the snapshot back into `waiting`, so the whole forecast disappears behind a spinner and pops back a moment later. Users read it as the screen breaking. ## What FutureBuilder does when the future changes The source of `FutureBuilder` answers the design question: - In `didUpdateWidget`, if the new `future` differs from the old one, it forgets the old future's callbacks and sets the snapshot to `inState(ConnectionState.none)`. **`inState` keeps `data`, `error` and `stackTrace`.** - It then subscribes to the new future and moves to `waiting`, still carrying the previous data. - When the new future completes, the snapshot is replaced by `done` with the new data, or `done` with the error and **no data**. - Results of a future it no longer holds are ignored: each subscription has an identity token, and stale callbacks are dropped. So the previous forecast is available in `snapshot.data` for the whole refresh. The builder just has to use it. ## Building it 1. Store the future: `late Future<Forecast> _forecast = widget.fetch(widget.city);` 2. Wrap the scrollable content in **`RefreshIndicator`**. Its `onRefresh` is a `RefreshCallback`, a `Future<void> Function()`; the spinner stays until that future completes. 3. In `onRefresh`, assign and return the new future: ```dart Future<void> _refresh() { final next = widget.fetch(widget.city); setState(() => _forecast = next); return next; } ``` 4. In the builder, check in this order: - `hasData`: render the forecast; if `connectionState == ConnectionState.waiting`, overlay a slim `LinearProgressIndicator`. - `hasError`: show an error panel with retry. - otherwise: full-screen spinner, which now appears only on the very first load. ## The failed refresh If the refresh fails, the `done` snapshot carries the error and `data` is `null`, so the old forecast vanishes from the snapshot. Options: - Keep a `Forecast? _lastGood` field, update it when a future completes successfully, and show it with an error banner. - Accept the error screen, if stale weather is worse than none for the product. - Move caching into a repository so the widget never owns the "last good" copy. Checking `hasError` *after* `hasData` is safe here only because an error snapshot has no data; with a manual `_lastGood` copy, check the error to decide on the banner. ## Comparison of builder strategies | Builder checks | First load | During refresh | Failed refresh | |---|---|---|---| | `connectionState != done` first | spinner | full spinner, content hidden | error | | `hasData` first | spinner | old forecast shown | error, old data gone | | `hasData` first + `_lastGood` | spinner | old forecast shown | old forecast + banner | ## Details worth mentioning - Return the same future you stored, not a second call, or you fetch twice. - A quick double pull is harmless for the UI: the builder follows only the latest future. Both requests still hit the server. - `RefreshIndicator` needs a scrollable child; wrap short content in a scroll view with `AlwaysScrollableScrollPhysics` so the pull gesture works. - `FutureBuilder.debugRethrowError` can be switched on in debug builds while diagnosing why refreshes fail silently.

  • In Flutter, if the user pulls to refresh twice quickly, can the first, slower response overwrite the second?
    Not in `FutureBuilder`. Each subscription gets an identity token, and callbacks from a future the widget no longer holds are ignored. Only the latest future's result reaches the snapshot, although both requests still run on the server.
  • In Flutter, why must onRefresh return the future rather than just calling setState?
    `RefreshIndicator` keeps its spinner visible until the `Future` returned by `onRefresh` completes. Returning an already-completed future, or a different call, dismisses the spinner early or triggers a duplicate fetch.

saying these in an interview costs you the question

  • FutureBuilder clears snapshot.data as soon as it receives a new future
  • A slower earlier refresh can overwrite a newer result in FutureBuilder
  • Checking connectionState first is the right way to keep old data visible
  • A failed refresh keeps the previous data in the snapshot
  • onRefresh can return immediately; RefreshIndicator tracks the FutureBuilder