← Articles

Managing async state in Flutter with Riverpod

By Charlin Joe · 27 December 2024

Riverpod is a state management library that fixes the problems with Provider: compile-time safety, no context dependency, testable by design. Managing async state — data fetching, loading, error states — is where Riverpod really shines.

The async state primitives

Riverpod represents async state as AsyncValue<T>:

// AsyncValue has three variants:
AsyncData<T>    // Success: holds the value
AsyncLoading<T> // Loading: no value yet
AsyncError<T>   // Error: holds the error and stack trace

FutureProvider: simple async data

@riverpod
Future<List<Order>> orders(OrdersRef ref) async {
  return ref.watch(orderRepositoryProvider).getOrders();
}

// In widget:
class OrdersScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncOrders = ref.watch(ordersProvider);

    return asyncOrders.when(
      data: (orders) => OrdersList(orders: orders),
      loading: () => const CircularProgressIndicator(),
      error: (error, _) => ErrorView(
        message: error.toString(),
        onRetry: () => ref.invalidate(ordersProvider),
      ),
    );
  }
}

AsyncNotifier: async state with operations

Use AsyncNotifier when you need both initial data fetch AND mutations:

@riverpod
class OrdersNotifier extends _$OrdersNotifier {
  @override
  Future<List<Order>> build() async {
    return ref.watch(orderRepositoryProvider).getOrders();
  }

  Future<void> cancelOrder(String orderId) async {
    // Optimistic update: show cancelled immediately
    final currentOrders = state.value ?? [];
    state = AsyncData(
      currentOrders.map((o) =>
        o.id == orderId ? o.copyWith(status: OrderStatus.cancelled) : o
      ).toList(),
    );

    try {
      await ref.read(orderRepositoryProvider).cancelOrder(orderId);
    } catch (e, stack) {
      // Revert on failure
      state = AsyncError(e, stack);
      // Re-fetch from server
      ref.invalidateSelf();
    }
  }

  Future<void> refresh() async {
    ref.invalidateSelf();
    await future; // Wait for the rebuild
  }
}

Family: parameterized providers

For loading a single item by ID:

@riverpod
Future<Order> order(OrderRef ref, String orderId) async {
  return ref.watch(orderRepositoryProvider).getOrder(orderId);
}

// Usage:
final orderAsync = ref.watch(orderProvider('order-123'));

Family providers are cached per parameter — orderProvider('order-1') and orderProvider('order-2') are separate cached values.

Combining providers (select)

// Only rebuild when the order count changes, not when other order fields change
final orderCount = ref.watch(
  ordersProvider.select((asyncOrders) =>
    asyncOrders.value?.length ?? 0
  ),
);

// Derive from multiple providers
@riverpod
AsyncValue<CartSummary> cartSummary(CartSummaryRef ref) {
  final cart = ref.watch(cartProvider);
  final user = ref.watch(userProvider);

  return cart.when(
    data: (cart) => user.when(
      data: (user) => AsyncData(CartSummary(cart: cart, user: user)),
      loading: () => const AsyncLoading(),
      error: (e, s) => AsyncError(e, s),
    ),
    loading: () => const AsyncLoading(),
    error: (e, s) => AsyncError(e, s),
  );
}

Refreshing and auto-dispose

// Invalidate to trigger a fresh fetch
ref.invalidate(ordersProvider);

// Wait for the next value
await ref.read(ordersProvider.future);

// Auto-dispose (default with code generation) — provider is
// disposed when all listeners unsubscribe
@riverpod // = @Riverpod(keepAlive: false)
Future<List<Order>> orders(OrdersRef ref) async { ... }

// Keep alive across navigation
@Riverpod(keepAlive: true)
Future<User> currentUser(CurrentUserRef ref) async { ... }

Loading state without flickering

When refreshing, asyncValue.isLoading is true but .value still holds the previous data:

asyncOrders.when(
  // skipLoadingOnRefresh: true means show stale data during refresh, not spinner
  skipLoadingOnRefresh: true,
  data: (orders) => Stack(
    children: [
      OrdersList(orders: orders),
      if (asyncOrders.isLoading)
        const LinearProgressIndicator(), // Subtle indicator for refreshes
    ],
  ),
  loading: () => const CircularProgressIndicator(), // Only on initial load
  error: (e, _) => ErrorView(message: e.toString()),
)

Testing with ProviderScope overrides

Widget buildWithProviders(Widget child, {List<Override> overrides = const []}) {
  return ProviderScope(
    overrides: overrides,
    child: MaterialApp(home: child),
  );
}

testWidgets('shows orders list', (tester) async {
  await tester.pumpWidget(
    buildWithProviders(
      const OrdersScreen(),
      overrides: [
        ordersProvider.overrideWith((ref) async => [order1, order2]),
      ],
    ),
  );

  await tester.pumpAndSettle();
  expect(find.byType(OrderCard), findsNWidgets(2));
});

testWidgets('shows error state', (tester) async {
  await tester.pumpWidget(
    buildWithProviders(
      const OrdersScreen(),
      overrides: [
        ordersProvider.overrideWith((ref) => throw Exception('Network error')),
      ],
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Network error'), findsOneWidget);
});

Common pitfalls

Calling ref.watch inside async code. ref.watch must be called synchronously in build or in the provider's build method. Calling it inside a Future or Stream.listen causes errors.

Not using select for derived values. Watching ordersProvider and extracting .length in the widget means rebuilding on any order change, even irrelevant field updates. Use .select to rebuild only when the relevant data changes.

Forgetting keepAlive for global state. By default, providers auto-dispose when unused. User session, auth state, and app-wide settings should use @Riverpod(keepAlive: true).

Sign in to like, dislike, or report.

Managing async state in Flutter with Riverpod — ANN Tech