Provider Scoping, Overrides, and Family Modifiers
Provider Scoping, Overrides, and Family Modifiers
Riverpod gives you three powerful mechanisms for controlling where a provider lives, what it returns in a given context, and how it accepts parameters. Understanding scoping, overrides, and the .family modifier lets you build feature-isolated, testable, and highly composable Flutter applications.
1. Provider Scoping with ProviderScope
Every Riverpod app wraps its widget tree in a top-level ProviderScope. All providers are, by default, available to the entire tree. Scoping means inserting a nested ProviderScope lower in the tree and overriding specific providers within that sub-tree only. Widgets inside the nested scope see the overridden value; widgets outside still see the original.
- Useful when a sub-feature needs its own isolated instance of a provider.
- The nested scope is disposed when the subtree is removed — no manual cleanup needed.
- Commonly used in lists where each item needs its own state (e.g., a todo item's editing state).
Nested ProviderScope — per-item isolation
// A provider that holds the "selected" state of a single item
final selectedItemProvider = StateProvider<bool>((ref) => false);
class ItemList extends StatelessWidget {
final List<String> items;
const ItemList({super.key, required this.items});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
// Each item gets its OWN ProviderScope, so its selectedItemProvider
// is completely independent from every other item.
return ProviderScope(
overrides: [
selectedItemProvider.overrideWith((ref) => false),
],
child: ItemTile(label: items[index]),
);
},
);
}
}
class ItemTile extends ConsumerWidget {
final String label;
const ItemTile({super.key, required this.label});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSelected = ref.watch(selectedItemProvider);
return ListTile(
title: Text(label),
trailing: Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? Colors.green : Colors.grey,
),
onTap: () => ref.read(selectedItemProvider.notifier).state = !isSelected,
);
}
}
ProviderScope, the override is only visible inside that scope's subtree. Any widget above or beside the nested scope continues to read the original provider unaffected.2. Provider Overrides
Overrides let you replace the implementation of a provider at runtime — most often in two scenarios:
- Testing: swap a real repository with a fake one so unit/widget tests never hit the network or database.
- Feature flags / environments: inject a different implementation depending on a flavour or environment variable.
You supply overrides via the overrides parameter of ProviderScope. Use overrideWithValue() to replace with a concrete instance, or overrideWith() to replace with a new provider factory.
Overriding a repository for widget tests
// Production provider
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => HttpProductRepository(),
);
final productListProvider = FutureProvider<List<Product>>((ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.fetchAll();
});
// In a widget test — inject a fake without touching production code
testWidgets('shows product list', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productRepositoryProvider.overrideWithValue(FakeProductRepository()),
],
child: const MaterialApp(home: ProductListPage()),
),
);
await tester.pumpAndSettle();
expect(find.text('Widget A'), findsOneWidget);
});
3. The .family Modifier
Providers are normally singletons — one instance per scope. The .family modifier turns a provider into a factory: each unique argument produces its own cached instance. This is the idiomatic Riverpod way to load data by ID.
- Works on
Provider,FutureProvider,StateNotifierProvider,NotifierProvider, and more. - The argument becomes part of the provider's identity —
productProvider(1)andproductProvider(2)are different providers. - Arguments must be comparable (override
==andhashCode), so primitive types, enums, or@freezedvalue objects work best. - Instances are cached for the lifetime of the scope; they are disposed when no widget is listening.
FutureProvider.family — fetch a product by ID
// Define a family provider parameterised by product ID (int)
final productByIdProvider = FutureProvider.family<Product, int>(
(ref, productId) async {
final repo = ref.watch(productRepositoryProvider);
return repo.fetchById(productId);
},
);
// Consume it in a widget — pass the ID as an argument
class ProductDetailPage extends ConsumerWidget {
final int productId;
const ProductDetailPage({super.key, required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Each productId gets its own cached AsyncValue
final productAsync = ref.watch(productByIdProvider(productId));
return productAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, _) => Text('Error: $err'),
data: (product) => Column(
children: [
Text(product.name, style: const TextStyle(fontSize: 24)),
Text(product.description),
Text('\$${product.price.toStringAsFixed(2)}'),
],
),
);
}
}
4. Combining Scoping, Overrides, and Family
These three features compose naturally. You can, for example, create a nested ProviderScope that overrides a .family provider to inject test data for a specific ID without affecting the rest of the tree.
==) as .family arguments. Riverpod uses the argument as a hash-map key; if two arguments that should be equal are not considered equal, you will get duplicate provider instances and subtle bugs.5. autoDispose with family
Pairing .family with .autoDispose (or using Riverpod 2's keepAlive) ensures that provider instances for IDs no longer on screen are automatically cleaned up, preventing memory leaks in long-running apps with many unique IDs.
autoDispose + family — memory-safe per-ID providers
// autoDispose disposes the provider instance when no widget is listening
final userProfileProvider = FutureProvider.autoDispose.family<UserProfile, String>(
(ref, userId) async {
// Optional: keep alive for 30 s after last listener detaches
final link = ref.keepAlive();
Timer(const Duration(seconds: 30), link.close);
final api = ref.watch(apiClientProvider);
return api.getUserProfile(userId);
},
);
Summary
Provider scoping isolates state to a widget sub-tree without any global side-effects. Overrides decouple production implementations from test or environment-specific ones. The .family modifier parameterises providers so each unique argument gets its own cached, lifecycle-managed instance — making patterns like "fetch by ID" both concise and correct. Together, these tools give you precise, declarative control over your application's state topology.