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.