AsyncValue
Welcome to Mastering Riverpod: Core Concepts — the third Riverpod series (after Foundation and Provider Types). This series goes deep on the cross-cutting features that make providers powerful. We start with the one you've already met a dozen times: AsyncValue — the sealed type that every async provider hands you. Master it and async UI in Riverpod becomes effortless.
You saw AsyncValue in FutureProvider and StreamProvider. Now we learn everything it can do.
The problem AsyncValue solves
Any async operation is always in one of three states: loading, succeeded (with data), or failed (with an error). The classic mistake is modeling these as separate fields (isLoading, data, error) that can contradict each other (recall the boolean-soup anti-pattern). AsyncValue makes the three states mutually exclusive and exhaustive — it's always exactly one.
sealed class AsyncValue<T> {}
class AsyncData<T> extends AsyncValue<T> { final T value; }
class AsyncLoading<T> extends AsyncValue<T> {}
class AsyncError<T> extends AsyncValue<T> { final Object error; final StackTrace stackTrace; }
Because it's a sealed class (Dart Part 8!), the compiler can force you to handle all three cases — no forgotten loading spinner, no unhandled error. That's the whole point.
.when — the everyday way
.when requires a callback for each of the three states and returns a value (usually a widget):
final userAsync = ref.watch(userProvider); // AsyncValue<User>
return userAsync.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);
Because all three are required, you physically cannot forget a state — the loading and error UI are mandatory, which is exactly the discipline async UI needs. This single expression replaces a FutureBuilder and its snapshot.connectionState checks.
.when options for smooth UX
.when takes flags that control behavior during re-fetches (refresh/reload), so the UI doesn't flicker:
userAsync.when(
skipLoadingOnRefresh: true, // don't show loading when refreshing (keep old data)
skipLoadingOnReload: true, // don't show loading when a dependency caused a reload
skipError: false,
data: (user) => UserView(user),
loading: () => const Spinner(),
error: (e, st) => ErrorView(e),
);
With skipLoadingOnRefresh: true (and a provider that preserves previous data, Part 5 of Provider Types), a pull-to-refresh keeps the current data on screen instead of flashing a spinner — the "stale-while-revalidate" feel.
switch — pattern matching
Since AsyncValue is sealed, a Dart 3 switch expression (sealed classes & patterns) is an equally valid, sometimes clearer, alternative — especially when you want to match on both type and value:
return switch (ref.watch(userProvider)) {
AsyncData(:final value) => Text(value.name),
AsyncError(:final error) => Text('Error: $error'),
AsyncLoading() => const CircularProgressIndicator(),
};
The switch is exhaustive (the compiler checks all three subtypes). You can also use guards and finer matching:
return switch (ref.watch(userProvider)) {
AsyncData(:final value) when value.isPremium => PremiumView(value),
AsyncData(:final value) => StandardView(value),
AsyncError(:final error) => ErrorView(error),
_ => const Spinner(),
};
.whenvsswitch:.whenis concise and forces all three states — great default.switchshines when you need guards or to distinguish sub-cases of the data. Both are correct; use whichever reads better.
Handy accessors
Sometimes you don't want full pattern matching — you just want the value if it's there. AsyncValue has convenient getters:
final async = ref.watch(userProvider);
async.value; // T? — the data if available (else null)
async.valueOrNull; // T? — same, safe even in error state
async.hasValue; // bool — is there data?
async.isLoading; // bool — is it loading (including refresh)?
async.hasError; // bool — did it error?
async.error; // Object? — the error, if any
async.requireValue; // T — the value, or THROWS if not available (use when sure)
Common uses:
// Disable a button while loading:
ElevatedButton(
onPressed: async.isLoading ? null : _submit,
child: const Text('Save'),
);
// Show data if present, otherwise a placeholder, without full .when:
Text(async.valueOrNull?.name ?? '—');
Transforming: whenData, maybeWhen, map
A few more tools you'll reach for:
// whenData: transform the data, passing loading/error through unchanged.
AsyncValue<String> nameAsync = userAsync.whenData((user) => user.name);
// maybeWhen: handle some cases, fall back with orElse.
final label = userAsync.maybeWhen(
data: (u) => u.name,
orElse: () => 'Loading…',
);
// map: like when, but each branch receives the AsyncX object (access to previous, etc.)
userAsync.map(
data: (d) => Text(d.value.name),
loading: (l) => const Spinner(),
error: (e) => Text('${e.error}'),
);
whenData is especially handy in providers that derive one async value from another without unwrapping the states.
Loading while keeping previous data
When a provider refreshes, its state briefly becomes AsyncLoading again — but AsyncValue can carry the previous data through that loading state so the UI need not blank out. This is copyWithPrevious (set by AsyncNotifier mutations, Provider Types Part 5) plus helpers to inspect it:
final async = ref.watch(todoListProvider);
async.isRefreshing; // true if loading but previous data exists
async.isReloading; // true if reloading due to a dependency change
async.unwrapPrevious(); // strip the "previous" wrapper to the underlying state
In practice you rarely call these directly — you set skipLoadingOnRefresh: true in .when and let AsyncValue keep the old data visible. But knowing they exist explains how "show stale while revalidating" works under the hood.
Why AsyncValue is everywhere
Step back: AsyncValue is the lingua franca of async Riverpod. Every FutureProvider, StreamProvider, AsyncNotifier, and StreamNotifier exposes one. Learn it once, and you handle all async state — fetches, streams, mutations — the same way: .when (or switch) for the three states, accessors for quick reads, and the skip-loading flags for smooth refreshes. It's the payoff that makes Riverpod's async story so much nicer than raw FutureBuilder/StreamBuilder.
Practice Challenges
Challenge 1 — Render with .when. Show a FutureProvider<List<Post>> with all three states.
Show solution
ref.watch(postsProvider).when(
data: (posts) => PostList(posts),
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
);
Challenge 2 — Render with switch. Same, using pattern matching.
Show solution
switch (ref.watch(postsProvider)) {
AsyncData(:final value) => PostList(value),
AsyncError(:final error) => Text('$error'),
AsyncLoading() => const CircularProgressIndicator(),
};
Challenge 3 — Disable while loading. Disable a submit button when submitProvider is loading.
Show solution
final async = ref.watch(submitProvider);
ElevatedButton(
onPressed: async.isLoading ? null : _submit,
child: const Text('Submit'),
);
isLoading is the quick accessor for this.
Challenge 4 — Transform data. Derive an AsyncValue<int> of a list's length from an AsyncValue<List<T>>.
Show solution
final countAsync = listAsync.whenData((list) => list.length);
whenData maps the data and passes loading/error through.
Challenge 5 — No flicker on refresh. Make a refreshing list keep old data on screen instead of a spinner.
Show solution
async.when(
skipLoadingOnRefresh: true,
data: (items) => ItemList(items),
loading: () => const Spinner(),
error: (e, _) => ErrorView(e),
);
Combined with a provider that preserves previous data (copyWithPrevious), the old list stays visible during refresh.
Questions to test yourself
Q1 (basic). What are the three states of AsyncValue?
Show answer
AsyncData(value), AsyncLoading(), and AsyncError(error, stackTrace). It's always in exactly one — mutually exclusive and exhaustive.
Q2 (basic). What does .when require, and why is that good?
Show answer
A callback for each of data, loading, and error. Because all three are mandatory, you can't forget to handle loading or errors — it enforces complete async UI.
Q3 (intermediate). Why can you use a Dart switch on an AsyncValue?
Show answer
Because AsyncValue is sealed (subtypes AsyncData/AsyncLoading/AsyncError), so a switch over it is exhaustive — the compiler ensures all states are handled (Dart Part 8).
Q4 (intermediate). What's the difference between .value and .requireValue?
Show answer
.value (and .valueOrNull) returns T? — the data if available, else null. .requireValue returns T but throws if no value is available. Use requireValue only when you're certain data exists.
Q5 (intermediate). What does skipLoadingOnRefresh: true do?
Show answer
It tells .when not to show the loading widget during a refresh — instead it keeps showing the existing data (when the provider preserves previous data via copyWithPrevious). This avoids a spinner flash on pull-to-refresh ("stale-while-revalidate").
Q6 (advanced). Why is AsyncValue described as the "lingua franca" of async Riverpod?
Show answer
Because every async provider — FutureProvider, StreamProvider, AsyncNotifier, StreamNotifier — exposes its state as an AsyncValue. So one consistent set of tools (.when/switch, accessors like valueOrNull/isLoading, the skip-loading flags) handles all async state — fetches, streams, and mutations — uniformly. Learn it once, use it everywhere.
Wrapping up
AsyncValue is the heart of async Riverpod:
- It's a sealed type with exactly three states:
AsyncData,AsyncLoading,AsyncError. - Render it with
.when(all three required) or a Dart 3switch(exhaustive, supports guards). - Quick reads via
valueOrNull,isLoading,hasError,error,requireValue. - Transform with
whenData/maybeWhen/map; smooth refreshes withskipLoadingOnRefresh+ preserved previous data. - It's exposed by every async provider — one model for all async state.
A single provider gives one value. But often you need parameterized state — a user provider for user 123, another for 456, each cached independently. That's the family modifier. Part 2 covers the family modifier — parameterized providers done right.