Complex Async State with Riverpod
5 min read
State Management
| Pattern | When |
|---|---|
ref.watch(p) inside a provider | Other provider's current sync value (reactive) |
ref.watch(p.future) | Other provider's eventual value (reactive + awaitable) |
Future.wait([...futures]) | Independent calls in parallel |
Sequential await calls | When B genuinely depends on A's result |
ref.invalidate(p) | "Drop the cached value, re-fetch on next listen" |
ref.refresh(p) | Invalidate AND immediately re-fetch |
AsyncValue.when(loading, error, data) | Render the three states |
AsyncValue.guard(() => future) | Wrap a future into an AsyncValue |
Code in action — a dashboard composed from 3 sources
// 1️⃣ Auth — the root of the chain
class AuthNotifier extends StateNotifier<AuthState> { ... }
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
// 2️⃣ User profile — depends on auth, throws if not authenticated
final userProfileProvider = FutureProvider<UserProfile>((ref) async {
final auth = ref.watch(authProvider);
if (auth is! Authenticated) throw UnauthorizedException();
return ref.watch(apiClientProvider).fetchProfile(auth.userId);
});
// 3️⃣ Orders — depends on profile.id
final ordersProvider = FutureProvider<List<Order>>((ref) async {
final profile = await ref.watch(userProfileProvider.future);
return ref.watch(apiClientProvider).fetchOrders(profile.id);
});
// 4️⃣ Notifications — independent of orders
final notificationsProvider = FutureProvider<List<Notif>>(...);
// 5️⃣ Combined dashboard — parallel where possible
final dashboardProvider = FutureProvider<DashboardData>((ref) async {
// userProfile is awaited first (orders depends on it)
// Then orders + notifications run in PARALLEL
final profile = await ref.watch(userProfileProvider.future);
final results = await Future.wait([
ref.watch(ordersProvider.future),
ref.watch(notificationsProvider.future),
]);
return DashboardData(
profile: profile,
orders: results[0] as List<Order>,
notifications: results[1] as List<Notif>,
);
});
Rendering with AsyncValue.when
class DashboardScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final dashboard = ref.watch(dashboardProvider);
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(userProfileProvider);
ref.invalidate(ordersProvider);
ref.invalidate(notificationsProvider);
await ref.read(dashboardProvider.future); // wait for new data
},
child: dashboard.when(
loading: () => const DashboardSkeleton(),
error: (e, _) => ErrorView(error: e,
onRetry: () => ref.invalidate(dashboardProvider)),
data: (data) => DashboardContent(data: data),
),
);
}
}
For "show old data while refreshing in background":
final data = dashboard.unwrapPrevious().valueOrNull; // last good value
final isLoading = dashboard.isLoading;
// render data immediately + show a top loading bar when refreshing
Strategy — partial failures
Two patterns when one source fails:
// 1. ALL-OR-NOTHING: if any source fails, the whole dashboard errors
final dashboardProvider = FutureProvider<Dashboard>((ref) async {
final results = await Future.wait([...]); // first error wins
});
// 2. PER-SOURCE: each piece is its own AsyncValue, render what succeeded
class DashboardScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return Column(children: [
ref.watch(profileProvider).when(...),
ref.watch(ordersProvider).when(...),
ref.watch(notificationsProvider).when(...),
]);
}
}
Pick based on UX — usually #2 (graceful degradation) for production dashboards.
Common mistakes to avoid
❌ Sequential awaits when calls are independent
final profile = await api.profile();
final orders = await api.orders(); // could have started 200ms earlier
✅ Future.wait([api.profile(), api.orders()])
❌ ref.read(p.future) inside another provider's body
read doesn't subscribe → when p changes, your provider won't recompute.
✅ ref.watch(p.future)
❌ Forgetting autoDispose on screen-scoped data
Detail page provider keeps holding fetched data after navigation away.
✅ FutureProvider.autoDispose, or @riverpod (autoDispose by default)
❌ Catching errors inside provider and returning success
Hides errors from the UI; users can't retry.
✅ Let the provider throw, render error in .when
❌ Triggering side effects (navigation, snackbar) from build()
final s = ref.watch(p);
if (s.hasValue) Navigator.push(...); // fires every rebuild
✅ ref.listen(p, (prev, next) { if (next is Success) Navigator.push(...); });
❌ Using try/catch + setState equivalents to replicate AsyncValue
You end up reinventing AsyncValue badly. Embrace .when(loading, error, data).
Interview follow-ups
-
How do you show stale data while refreshing?
AsyncValue.unwrapPrevious()(orprevious?.valueOrNull) gives you the last successful value during a refresh. Render that data immediately and show a subtle loading indicator (top spinner, refresh icon). Users see content, not skeletons. -
What happens if one of the parallel requests fails?
Future.waitrejects with the first error — other in-flight futures still complete in the background but you don't get their results in the combined call. For partial-failure UX, watch each provider separately at the UI layer and render per-section error states. -
ref.invalidatevsref.refresh— what's the difference?invalidatedrops the cached value but doesn't actively re-fetch — the next listener triggers a fetch.refreshinvalidates and immediately returns the new future. Userefreshwhen you want to await the new result (e.g., pull-to-refresh handler). -
How do you test these compositions? Create a
ProviderContainer(overrides: [...])in a unit test, override the leaf providers (auth, apiClient) with fakes, thenawait container.read(dashboardProvider.future)and assert on the result. No widget tree needed — that's a key Riverpod advantage.
How helpful was this content?
Please sign in to rate this article.