Complex Async State with Riverpod

Medium PriorityAsked in ~65% of senior interviews

5 min read

State Management

PatternWhen
ref.watch(p) inside a providerOther 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 callsWhen 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

  1. How do you show stale data while refreshing? AsyncValue.unwrapPrevious() (or previous?.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.

  2. What happens if one of the parallel requests fails? Future.wait rejects 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.

  3. ref.invalidate vs ref.refresh — what's the difference? invalidate drops the cached value but doesn't actively re-fetch — the next listener triggers a fetch. refresh invalidates and immediately returns the new future. Use refresh when you want to await the new result (e.g., pull-to-refresh handler).

  4. How do you test these compositions? Create a ProviderContainer(overrides: [...]) in a unit test, override the leaf providers (auth, apiClient) with fakes, then await 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.