Riverpod Fundamentals: Providers and the ProviderScope
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.
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(),
);
}
}
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 andinitState.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),
),
],
),
);
}
}
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]),
),
),
),
);
}
}
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.
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.