Advanced State Management (Bloc & Riverpod)

Riverpod Fundamentals: Providers and the ProviderScope

16 min Lesson 7 of 14

Riverpod Fundamentals: Providers and the ProviderScope

Riverpod is a compile-safe, testable, and reactive state management framework for Flutter and Dart. Unlike its predecessor Provider, Riverpod eliminates the BuildContext requirement for reading state, catches mistakes at compile time rather than runtime, and makes it trivial to combine and override providers in tests. Every piece of state in a Riverpod app is encapsulated in a provider — a declarative description of how to create a value.

Note: Riverpod comes in two flavours: flutter_riverpod (for Flutter apps) and riverpod (pure Dart). This lesson uses flutter_riverpod. The code-generation package riverpod_generator is optional and covered in a later lesson — here we use the classic annotation-free API to learn the fundamentals.

Setting Up ProviderScope

Before any provider can be used, the entire widget tree must be wrapped in a ProviderScope. ProviderScope is the container that stores the state of all providers. Place it at the very top of the tree in main():

Wrapping the app with ProviderScope

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    const ProviderScope(   // Must wrap the entire app
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Demo',
      home: const HomeScreen(),
    );
  }
}
Tip: You can nest multiple ProviderScope instances to override providers for a sub-tree — a powerful pattern for testing and feature isolation. However, for a normal app you only need one at the root.

ConsumerWidget: The Riverpod-Aware Widget

To read providers inside the widget tree you replace StatelessWidget with ConsumerWidget and StatefulWidget with ConsumerStatefulWidget. Both give you a WidgetRef ref object — the gateway to every provider:

  • ref.watch(provider) — subscribes to the provider; rebuilds the widget whenever the value changes.
  • ref.read(provider) — reads the current value once without subscribing; use inside callbacks and initState.
  • ref.listen(provider, callback) — reacts to changes imperatively (e.g. showing a SnackBar) without rebuilding the widget.

Provider: Read-Only Computed Values

The simplest provider type. Use it for constants, computed values, and services that never change on their own. The value is created lazily on first access and cached for the lifetime of the ProviderScope.

Provider for a computed greeting string

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Declare providers at the top level (outside any class)
final greetingProvider = Provider<String>((ref) {
  return 'Hello, Riverpod!';
});

// A simple service exposed as a provider
final appVersionProvider = Provider<String>((ref) => '2.4.1');

class GreetingWidget extends ConsumerWidget {
  const GreetingWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watch keeps the widget in sync; fine for static values too
    final greeting = ref.watch(greetingProvider);
    final version = ref.watch(appVersionProvider);

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text(greeting, style: const TextStyle(fontSize: 24)),
        Text('v$version', style: const TextStyle(color: Colors.grey)),
      ],
    );
  }
}

StateProvider: Simple Mutable State

StateProvider holds a single mutable value and exposes a StateController via ref.read(provider.notifier). It is ideal for simple counters, toggles, selected tab indices, and filter values — anything that fits in a single primitive.

Counter with StateProvider

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// The provider holds an int, starting at 0
final counterProvider = StateProvider<int>((ref) => 0);

class CounterScreen extends ConsumerWidget {
  const CounterScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watch rebuilds this widget when the counter changes
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Riverpod Counter')),
      body: Center(
        child: Text(
          'Count: $count',
          style: Theme.of(context).textTheme.headlineLarge,
        ),
      ),
      floatingActionButton: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          FloatingActionButton(
            heroTag: 'inc',
            // ref.read inside callbacks — we don't need to watch here
            onPressed: () => ref.read(counterProvider.notifier).state++,
            child: const Icon(Icons.add),
          ),
          const SizedBox(height: 8),
          FloatingActionButton(
            heroTag: 'dec',
            onPressed: () => ref.read(counterProvider.notifier).state--,
            child: const Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}
Warning: Never call ref.watch inside a button's onPressed or any other callback. Watching inside callbacks causes providers to be read at unpredictable times and leaks subscriptions. Use ref.read in callbacks and ref.watch in the build method only.

FutureProvider: Async Data Loading

FutureProvider is designed for one-shot async operations like HTTP requests or database reads. It returns an AsyncValue<T> which is a sealed union of three states: AsyncData, AsyncLoading, and AsyncError. Pattern-match on it with .when() to handle all three cases safely.

FutureProvider for remote data

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Simulates a network call returning a list of usernames
final usersProvider = FutureProvider<List<String>>((ref) async {
  // In a real app: final response = await http.get(Uri.parse('...'));
  await Future.delayed(const Duration(seconds: 1)); // simulate latency
  return ['Alice', 'Bob', 'Carol'];
});

class UsersScreen extends ConsumerWidget {
  const UsersScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncUsers = ref.watch(usersProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: asyncUsers.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
        data: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) => ListTile(
            leading: const Icon(Icons.person),
            title: Text(users[index]),
          ),
        ),
      ),
    );
  }
}
Tip: To manually refresh a FutureProvider (e.g. on a pull-to-refresh gesture), call ref.invalidate(usersProvider). Riverpod will re-run the async function and the widget will transition back through the loading state.

Provider Composition

Providers can depend on other providers via the ref object passed to their create function. This is how you build a clean dependency graph without a service locator:

Composing providers

// Base providers
final baseUrlProvider = Provider<String>((ref) => 'https://api.example.com');

final httpClientProvider = Provider<String>((ref) {
  final base = ref.watch(baseUrlProvider); // reads another provider
  return 'HttpClient(baseUrl: $base)';     // returns configured client
});

// A FutureProvider that uses the HTTP client
final profileProvider = FutureProvider<String>((ref) async {
  final client = ref.watch(httpClientProvider);
  // Conceptually: return await client.get('/profile');
  return 'Profile loaded via $client';
});

Summary

Riverpod's compile-safe provider model solves the major pain points of InheritedWidget-based solutions. The three provider types introduced here cover the majority of real-world use cases:

  • Provider — constants, services, computed read-only values.
  • StateProvider — simple mutable state (primitives, enums, filter values).
  • FutureProvider — async operations with built-in loading/error handling via AsyncValue.

In the next lesson you will learn StateNotifierProvider and NotifierProvider for managing complex objects, and how to architect a real feature end-to-end with Riverpod.

Key Takeaway: Always wrap your app with ProviderScope, declare providers at the top level, use ref.watch in build for reactive UI, and ref.read in event handlers. Never watch inside callbacks.