Skip to content
ExponaBLOG
FLUTTER · DAY 028 / 100

Riverpod for scalable state: what developers should know

Compile-safe providers without BuildContext.

Expona Admin6 min read

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:

dart
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:

dart
@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:

dart
@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:

dart
@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:

dart
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:

dart
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:

dart
@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.
  • FutureProvider and StreamProvider with AsyncValue.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 ProviderScope to mock data, errors, or side effects without modifying production code.
  • Use ConsumerWidget and ConsumerStatefulWidget to access WidgetRef; never hold refs outside the widget tree.
  • Riverpod's compile-time safety (via code generation) catches missing providers and invalid dependencies early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles