skip to content

In Riverpod 3, what does a news feed's AsyncValue contain while it refreshes, and how do isRefreshing and skipLoadingOnRefresh keep old articles visible?

level: middleimportance: should knowfreq 44%

answer

  1. previous data is carried along
  2. refresh stays AsyncData
  3. reload becomes AsyncLoading
  4. skipLoadingOnRefresh defaults true
  5. skipLoadingOnReload defaults false

basics

~20 s

After invalidate or refresh, the AsyncValue stays AsyncData holding the old articles with isLoading and isRefreshing true, and when() shows data because skipLoadingOnRefresh defaults to true. A watched dependency change instead yields AsyncLoading with the old value (isReloading).

solid answer

~40 s

Riverpod merges a new loading state with the previous one. When the feed is rebuilt by `ref.invalidate` or `ref.refresh` - pull-to-refresh - the result is still an `AsyncData` containing the old articles, with `isLoading` and `isRefreshing` true. A switch on `AsyncData(:final value)` keeps showing them, and you add a `LinearProgressIndicator` when `feed.isRefreshing`. `when` agrees by default: `skipLoadingOnRefresh` is `true`. When the feed rebuilds because a watched dependency changed, such as the selected category, it becomes an `AsyncLoading` that still carries the old value, and `isReloading` is true; `when` calls `loading` because `skipLoadingOnReload` defaults to `false`. An error after data is an `AsyncError` whose `value` is the old data; `skipError: true` renders data instead. `unwrapPrevious()` strips the carried-over data.

code

dart · 28 lines
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class NewsFeedBody extends ConsumerWidget {
  const NewsFeedBody({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final feed = ref.watch(newsFeedProvider);
    return RefreshIndicator(
      onRefresh: () => ref.refresh(newsFeedProvider.future),
      child: Column(
        children: [
          if (feed.isLoading && feed.hasValue) const LinearProgressIndicator(),
          Expanded(
            child: feed.when(
              skipLoadingOnReload: true,
              skipError: true,
              data: (articles) => ArticleList(articles: articles),
              error: (error, _) => FeedError(message: '$error'),
              loading: () => const Center(child: CircularProgressIndicator()),
            ),
          ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Know that Riverpod keeps the old data while a provider refreshes and that isRefreshing tells you a refresh is in progress.

for a middle

Explain refresh versus reload, the runtime type in each case, the when flag defaults, and why a type-pattern switch treats them differently.

for a senior

Choose per screen whether stale data, a spinner or an error wins, and use unwrapPrevious where stale content would mislead users.

for a principal

Set product-wide rules for stale-data presentation so refresh, reload and error states behave consistently across every async screen.

## Stale-while-refreshing is built in A news feed that flashes to a full-screen spinner on every pull-to-refresh feels broken. Riverpod avoids this by letting an `AsyncValue` hold **several states at once**: when a provider that already has data starts loading again, the new state is merged with the previous one, so the old articles travel along. ## Refresh versus reload Riverpod distinguishes why the provider is rebuilding: | Trigger | Name | Runtime type during fetch | Flags | `value` | |---|---|---|---|---| | `ref.invalidate` / `ref.refresh` | refresh | `AsyncData` | `isLoading`, `isRefreshing` | old articles | | watched dependency changed | reload | `AsyncLoading` | `isLoading`, `isReloading` | old articles | | first load | initial | `AsyncLoading` | `isLoading` | `null` | The source defines `isRefreshing` as 'has data or error, is loading, and is **not** an `AsyncLoading`', and `isReloading` as the same but **is** an `AsyncLoading`. The reasoning: a refresh asks for fresher copies of the same data, so showing the old list is right; a reload happens because an input changed - a different category - so the old list may be the wrong content. ## What each rendering style shows With a type-pattern `switch`: ```dart return Column(children: [ if (feed.isRefreshing) const LinearProgressIndicator(), Expanded( child: switch (feed) { AsyncData(:final value) => ArticleList(articles: value), AsyncError(:final error) => FeedError(message: '$error'), AsyncLoading() => const Center(child: CircularProgressIndicator()), }, ), ]); ``` - Refresh: `AsyncData` branch, old articles plus the thin bar. - Reload: `AsyncLoading` branch, full spinner. - Error after data: `AsyncError` branch, even though `value` still holds the old list. With `when`, three flags decide the branch: 1. `skipLoadingOnRefresh` - default **true**: on refresh, call `data` with the old articles. 2. `skipLoadingOnReload` - default **false**: on reload, call `loading`. Set it to `true` to keep the old category's list until the new one arrives. 3. `skipError` - default **false**: with an error and previous data, call `error`. Set it to `true` to keep showing data and report the error elsewhere, for example in a snackbar. With the property-pattern style from the tutorial - `AsyncValue(:final value?)` first - any state that carries old data renders the data branch, including reloads and errors after data. ## Forcing a hard reset - `feed.unwrapPrevious()` returns the state without carried-over data or error, for a screen that must never show stale content. - Invalidating with `asReload: true` makes the rebuild count as a reload rather than a refresh. ## Notifier-driven loading Inside an `AsyncNotifier`, assigning `state = const AsyncLoading()` is also merged with the previous state - as a reload, so the old articles stay in `value` while `isReloading` is true. Screens using the default `when` then show the spinner. ## Choosing the experience - News feed pull-to-refresh: rely on the refresh defaults and add a thin progress bar. - Category switch: decide deliberately - spinner (`skipLoadingOnReload: false`) or old list with a bar (`true`). - Error after data: usually keep the list and surface the error non-destructively.

  • Why does Riverpod treat a dependency-driven reload differently from a manual refresh?
    A refresh re-fetches the same query, so the old data is the right content, just older. A reload happens because an input changed - a new category or user - so the old data may belong to a different query and showing it could mislead. Hence `skipLoadingOnReload` defaults to false and the state is an `AsyncLoading`.
  • How do you make sure a screen never shows stale data?
    Call `unwrapPrevious()` on the watched value before rendering: it drops the carried-over value and error, so a loading state is a plain loading state. Alternatively, test `isRefreshing` or `isReloading` and render the spinner branch yourself.

saying these in an interview costs you the question

  • ref.invalidate always sends the screen back to a full-screen spinner
  • isRefreshing and isReloading mean the same thing
  • After an error, the old data is always discarded
  • skipLoadingOnReload defaults to true in when
  • A refreshing AsyncValue has runtime type AsyncLoading