Introduction
By day 28 of serious Flutter development, you've likely hit the wall with setState or inherited widgets—managing state across your app becomes messy, testing breaks down, and rebuilds ripple everywhere. Riverpod for scalable state: what developers should know is essential reading because it gives you a way to declare state globally while keeping it scoped, testable, and predictable. If you're building production systems—think real-time lead lists, multi-screen flows, or complex integrations—Riverpod shifts state management from a pain point to a structured asset.
Providers are global declarations—but state is scoped and testable
The mental model shift with Riverpod starts here: you declare providers at the top level, outside any widget. They're global in scope, but the state instances they create are scoped to a ProviderScope and can be overridden per test. This is radically different from ad-hoc stateful widgets or service locators.
Consider a real scenario: a CRM app managing a list of leads. Without Riverpod, you might have a LeadService singleton, a LeadScreen that fetches data on init, and a sibling screen that needs to refresh the same list. You're now coupling screens to the service, duplicating fetch logic, and making tests brittle.
With Riverpod, you declare a single source of truth:
final leadServiceProvider = Provider<LeadService>((ref) {
return LeadService();
});
final leadsProvider = FutureProvider<List<Lead>>((ref) async {
final service = ref.watch(leadServiceProvider);
return service.fetchAll();
});Any widget that needs leads watches leadsProvider. When the data changes, only widgets depending on that provider rebuild. When you test, you override leadsProvider with a mock list or error state—no BuildContext required, no service locator gymnastics.
The key insight: a provider declaration is a recipe, not an instance. The actual state lives in ProviderScope. Multiple scopes can run independently (useful for testing, modal dialogs, or isolated feature screens).
Async built in—FutureProvider and AsyncValue handle loading and errors
Real apps fetch data. Real requests fail, take time, and need progress UI. Riverpod's FutureProvider and StreamProvider make async workflows first-class citizens, eliminating boilerplate around Future.then(), manual loading flags, and error state management.
When you watch a FutureProvider, you get an AsyncValue wrapper:
@riverpod
Future<List<Lead>> leads(Ref ref) async {
final repo = ref.watch(leadRepoProvider);
return repo.fetchAll();
}
// In a widget:
class LeadsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final leadsAsync = ref.watch(leadsProvider);
return leadsAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
data: (leads) => ListView(
children: leads
.map((lead) => LeadTile(lead))
.toList(),
),
);
}
}AsyncValue.when() exhaustively handles all states: loading, error, and data. No nullable fields, no manual loading bools, no "did we already fetch?" checks. The provider re-runs when dependencies change, and AsyncValue caches results, so you don't spam your API.
For streaming data (WebSocket updates, sensor readings, real-time feeds), use StreamProvider instead—same ergonomic when() pattern, but the state refreshes as the stream emits.
A practical detail: ref.refresh(leadsProvider) invalidates the cache and re-runs the provider. Useful after creating a new lead or when the user pulls to refresh.
Auto dispose—unused providers clean up automatically
One reason singletons are brittle is that they live forever. If a screen with a large cache pops off the navigation stack, that cache is still there, bleeding memory. Riverpod's auto-dispose feature prevents this: if no widget is watching a provider, it's cleaned up after a configurable duration (default: 60 seconds).
Annotate providers with @riverpod (the recommended annotation-based API, available via build_runner) and auto-dispose happens by default:
@riverpod
Future<List<Lead>> leads(Ref ref) async {
final repo = ref.watch(leadRepoProvider);
// Auto-disposed when no widget watches this for 60s
return repo.fetchAll();
}If you need to keep a provider alive longer (e.g., a global cache that should persist across tab switches), use @Riverpod(keepAlive: true).
You can also add cleanup logic:
@riverpod
Future<LeadService> leadService(Ref ref) async {
final service = LeadService();
ref.onDispose(() {
service.close(); // e.g., close WebSocket
});
return service;
}In production, this prevents memory leaks from forgotten subscriptions, database connections, or file handles. A load-testing scenario: 100 users navigate through screens. Without auto-dispose, all cached data accumulates. With it, inactive caches clean themselves, keeping memory stable.
Testing—override providers in ProviderScope for tests
Testing state is where Riverpod shines. No need to mock BuildContext, inject dependencies through constructors, or fight service locators. You override providers in the test's ProviderScope.
Example: testing the LeadsScreen without hitting the real API:
void main() {
testWidgets('LeadsScreen shows leads', (WidgetTester tester) async {
const mockLeads = [
Lead(id: '1', name: 'Alice', email: 'alice@example.com'),
Lead(id: '2', name: 'Bob', email: 'bob@example.com'),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
leadsProvider.overrideWith(
(ref) async => mockLeads,
),
],
child: const MaterialApp(home: LeadsScreen()),
),
);
// Provider returns mocked data immediately
await tester.pumpAndSettle();
expect(find.byType(LeadTile), findsNWidgets(2));
expect(find.text('Alice'), findsOneWidget);
});
}You can override with async data, synchronous values, or other providers. The test is isolated—changes in one test don't leak to the next because each ProviderScope is independent.
For error scenarios:
leadsProvider.overrideWith(
(ref) => Future.error(
ApiException('Network error'),
),
),Testing error UI becomes as simple as providing an error from the override.
Common mistakes
Watching providers outside ConsumerWidget or ConsumerStatefulWidget: Regular Widgets can't call ref.watch(). Use Consumer() or ConsumerBuilder() to access WidgetRef in the widget tree.
Holding refs outside the widget tree: Don't store WidgetRef in fields or static variables. It's tied to a specific ProviderScope` and becomes invalid when that scope rebuilds. Always access ref in the current build context.
Overcomplicating provider dependencies: If you find yourself chaining 10 ref.watch() calls, you probably need to refactor into a single computed provider:
@riverpod
Future<DashboardData> dashboard(Ref ref) async {
final leads = await ref.watch(leadsProvider.future);
final deals = await ref.watch(dealsProvider.future);
return DashboardData(leads: leads, deals: deals);
}Not handling cancellation in async providers: If a provider's dependencies change while a fetch is in-flight, the old future is dropped. Usually fine, but if the fetch has side effects (analytics, logging), ensure they're safe to interrupt.
Key takeaways
- Riverpod providers are global declarations that enable predictable, scoped state—no BuildContext coupling, no service locator mess.
FutureProviderandStreamProviderwithAsyncValue.when()eliminate boilerplate for loading, error, and data states.- Auto-dispose cleans up unused providers automatically, preventing memory leaks from caches, subscriptions, and connections.
- Override providers in test
ProviderScopeto mock data, errors, or side effects without modifying production code. - Use
ConsumerWidgetandConsumerStatefulWidgetto accessWidgetRef; never hold refs outside the widget tree. - Riverpod's compile-time safety (via code generation) catches missing providers and invalid dependencies early.






Comments
Loading comments…