Advanced Riverpod Patterns: Families, Notifiers, and Testing
# Advanced Riverpod Patterns for Production Flutter Apps
Riverpod has become my default state management solution for Flutter, but the gap between "getting started with Riverpod" and "using Riverpod well in production" is significant. This article covers the patterns, pitfalls, and architectural decisions I have refined across multiple shipping apps.
Provider Families and Parameterized Providers
Provider families let you create providers that accept parameters. This is essential when the same data-fetching logic applies to different inputs, like loading a user profile by ID or fetching products by category.
Basic Family Usage
final userProvider = FutureProvider.autoDispose.family<User, String>(
(ref, userId) async {
final api = ref.watch(apiClientProvider);
return api.getUser(userId);
},
);
class=class="code-string">"code-comment">// In the widget
class UserProfileScreen extends ConsumerWidget {
final String userId;
const UserProfileScreen({super.key, required this.userId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider(userId));
return userAsync.when(
loading: () => const LoadingIndicator(),
error: (err, stack) => ErrorDisplay(message: err.toString()),
data: (user) => UserProfileView(user: user),
);
}
}Complex Parameters with Records
When a provider needs multiple parameters, Dart records provide an elegant solution without creating a dedicated parameter class.
typedef ProductFilter = ({String category, int page, SortOrder sort});
final productsProvider = FutureProvider.autoDispose
.family<List<Product>, ProductFilter>((ref, filter) async {
final api = ref.watch(apiClientProvider);
return api.getProducts(
category: filter.category,
page: filter.page,
sort: filter.sort,
);
});
class=class="code-string">"code-comment">// Usage
final products = ref.watch(productsProvider((
category: class="code-string">'electronics',
page: class="code-number">1,
sort: SortOrder.priceAsc,
)));In my production apps, I have found that records strike the perfect balance: they give you named fields without the overhead of a full class, and Riverpod handles their equality comparison correctly out of the box.
StateNotifier and AsyncNotifier Patterns
When StateNotifier Still Makes Sense
Although `Notifier` and `AsyncNotifier` are the recommended approach since Riverpod 2.0, you will encounter `StateNotifier` in existing codebases. Understanding both is important.
class=class="code-string">"code-comment">// Legacy StateNotifier approach
class CartNotifier extends StateNotifier<List<CartItem>> {
final ApiClient _api;
CartNotifier(this._api) : super([]);
Future<void> addItem(Product product) async {
state = [...state, CartItem.fromProduct(product)];
await _api.syncCart(state);
}
void removeItem(String itemId) {
state = state.where((item) => item.id != itemId).toList();
}
}
final cartProvider =
StateNotifierProvider<CartNotifier, List<CartItem>>((ref) {
return CartNotifier(ref.watch(apiClientProvider));
});Modern AsyncNotifier Pattern
`AsyncNotifier` is where Riverpod truly shines for async workflows. The `build` method defines the initial async loading, and subsequent mutations can update state while preserving error handling.
class ProductListNotifier
extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
final api = ref.watch(apiClientProvider);
return api.fetchProducts();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => build());
}
Future<void> deleteProduct(String id) async {
final previousState = state;
class=class="code-string">"code-comment">// Optimistic update
state = AsyncData(
state.value!.where((p) => p.id != id).toList(),
);
try {
await ref.read(apiClientProvider).deleteProduct(id);
} catch (e, stack) {
class=class="code-string">"code-comment">// Rollback on failure
state = previousState;
state = AsyncError(e, stack);
}
}
}
final productListProvider =
AsyncNotifierProvider<ProductListNotifier, List<Product>>(
ProductListNotifier.new,
);In my production apps, I have found the optimistic update pattern above to be invaluable for creating responsive UIs. Users see immediate feedback while the network request happens in the background, and the state rolls back cleanly if anything goes wrong.
Family + AsyncNotifier Combination
class UserOrdersNotifier
extends FamilyAsyncNotifier<List<Order>, String> {
@override
Future<List<Order>> build(String userId) async {
final api = ref.watch(apiClientProvider);
return api.getUserOrders(userId);
}
Future<void> cancelOrder(String orderId) async {
await ref.read(apiClientProvider).cancelOrder(orderId);
ref.invalidateSelf();
await future;
}
}
final userOrdersProvider = AsyncNotifierProvider.family<
UserOrdersNotifier, List<Order>, String>(
UserOrdersNotifier.new,
);When to Use Which Provider Type
Choosing the right provider type is one of the first decisions that trips up developers. Here is the decision table I use.
| Scenario | Provider Type | Why |
|---|---|---|
| Simple computed value | `Provider` | Derived state, no side effects |
| Async data fetch (read-only) | `FutureProvider` | Automatic loading/error/data states |
| Real-time stream | `StreamProvider` | WebSocket, Firestore snapshots |
| Mutable sync state | `NotifierProvider` | Form state, toggles, counters |
| Mutable async state | `AsyncNotifierProvider` | CRUD operations with loading states |
| Parameterized read-only | `FutureProvider.family` | Same query, different inputs |
| Parameterized mutable | `AsyncNotifierProvider.family` | Per-entity operations |
| One-time setup | `Provider` | Dependency injection for services |
When in doubt, start with `FutureProvider` for reads and `AsyncNotifierProvider` for writes. You can always refactor later.
Riverpod Code Generation with riverpod_generator
Code generation reduces boilerplate and catches configuration errors at build time. In my production apps, I have found that adopting codegen from the start saves significant refactoring later.
Setup
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.0
riverpod_annotation: ^2.3.0
dev_dependencies:
riverpod_generator: ^2.4.0
build_runner: ^2.4.0Generated Providers in Practice
import class="code-string">'package:riverpod_annotation/riverpod_annotation.dart';
part class="code-string">'auth_provider.g.dart';
class=class="code-string">"code-comment">// Simple provider — replaces Provider<ApiClient>
@riverpod
ApiClient apiClient(ApiClientRef ref) {
return ApiClient(baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//api.example.com');
}
class=class="code-string">"code-comment">// FutureProvider with family — replaces FutureProvider.family
@riverpod
Future<User> userProfile(UserProfileRef ref, String userId) async {
final api = ref.watch(apiClientProvider);
return api.getUser(userId);
}
class=class="code-string">"code-comment">// AsyncNotifier — replaces AsyncNotifierProvider
@riverpod
class TodoList extends _$TodoList {
@override
Future<List<Todo>> build() async {
final api = ref.watch(apiClientProvider);
return api.fetchTodos();
}
Future<void> addTodo(String title) async {
final api = ref.read(apiClientProvider);
final newTodo = await api.createTodo(title);
state = AsyncData([...state.value ?? [], newTodo]);
}
}KeepAlive and AutoDispose Control
class=class="code-string">"code-comment">// This provider stays alive for the entire app lifetime
@Riverpod(keepAlive: true)
class AuthState extends _$AuthState {
@override
Future<AuthUser?> build() async {
return ref.watch(authRepositoryProvider).getCurrentUser();
}
}
class=class="code-string">"code-comment">// This provider is auto-disposed when no longer watched (default)
@riverpod
Future<List<Product>> searchProducts(
SearchProductsRef ref,
String query,
) async {
class=class="code-string">"code-comment">// Debounce the search
await Future.delayed(const Duration(milliseconds: class="code-number">300));
if (ref.state is AsyncLoading) return [];
final api = ref.watch(apiClientProvider);
return api.searchProducts(query);
}Testing Providers
Testing is where Riverpod's architecture really pays off. You do not need widget trees, BuildContext, or complex mocking frameworks. A `ProviderContainer` is all you need.
Unit Testing a Notifier
void main() {
late ProviderContainer container;
late MockApiClient mockApi;
setUp(() {
mockApi = MockApiClient();
container = ProviderContainer(
overrides: [
apiClientProvider.overrideWithValue(mockApi),
],
);
addTearDown(container.dispose);
});
test(class="code-string">'fetchProducts returns product list', () async {
final expectedProducts = [
Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Widget A'),
Product(id: class="code-string">'class="code-number">2', name: class="code-string">'Widget B'),
];
when(() => mockApi.fetchProducts())
.thenAnswer((_) async => expectedProducts);
class=class="code-string">"code-comment">// Listen to the provider to trigger the build
final subscription = container.listen(
productListProvider,
(_, __) {},
);
class=class="code-string">"code-comment">// Wait for the async operation to complete
await container.read(productListProvider.future);
expect(
container.read(productListProvider).value,
equals(expectedProducts),
);
subscription.close();
});
test(class="code-string">'deleteProduct performs optimistic update', () async {
when(() => mockApi.fetchProducts())
.thenAnswer((_) async => [Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Test')]);
when(() => mockApi.deleteProduct(class="code-string">'class="code-number">1'))
.thenAnswer((_) async {});
container.listen(productListProvider, (_, __) {});
await container.read(productListProvider.future);
await container
.read(productListProvider.notifier)
.deleteProduct(class="code-string">'class="code-number">1');
expect(
container.read(productListProvider).value,
isEmpty,
);
});
}Testing with Multiple Overrides
ProviderContainer createTestContainer({
AuthUser? currentUser,
List<Product>? products,
}) {
return ProviderContainer(
overrides: [
authStateProvider.overrideWith(() => MockAuthNotifier(currentUser)),
productListProvider.overrideWith(() => MockProductNotifier(products)),
analyticsProvider.overrideWithValue(MockAnalytics()),
],
);
}
test(class="code-string">'admin user can delete products', () async {
final container = createTestContainer(
currentUser: AuthUser(role: UserRole.admin),
products: [Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Test')],
);
addTearDown(container.dispose);
final canDelete = container.read(canDeleteProductProvider);
expect(canDelete, isTrue);
});Widget Testing with ProviderScope Overrides
testWidgets(class="code-string">'ProductListScreen shows loading then data', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productListProvider.overrideWith(
() => FakeProductNotifier([
Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Test Product'),
]),
),
],
child: const MaterialApp(home: ProductListScreen()),
),
);
class=class="code-string">"code-comment">// Initially shows loading
expect(find.byType(CircularProgressIndicator), findsOneWidget);
class=class="code-string">"code-comment">// After async completes
await tester.pumpAndSettle();
expect(find.text(class="code-string">'Test Product'), findsOneWidget);
});Riverpod + Clean Architecture Integration
Clean Architecture and Riverpod complement each other well when you establish clear boundaries. Here is the structure I use in production.
Layer Separation
lib/
features/
products/
data/
datasources/
product_remote_datasource.dart
product_local_datasource.dart
models/
product_model.dart
repositories/
product_repository_impl.dart
domain/
entities/
product.dart
repositories/
product_repository.dart # abstract
usecases/
get_products.dart
delete_product.dart
presentation/
providers/
product_providers.dart
screens/
product_list_screen.dart
widgets/
product_card.dartWiring It Up with Providers
class=class="code-string">"code-comment">// Data layer — concrete implementations
@riverpod
ProductRemoteDataSource productRemoteDataSource(
ProductRemoteDataSourceRef ref,
) {
final dio = ref.watch(dioProvider);
return ProductRemoteDataSourceImpl(dio);
}
class=class="code-string">"code-comment">// Domain layer — abstract repository provided via data layer
@riverpod
ProductRepository productRepository(ProductRepositoryRef ref) {
return ProductRepositoryImpl(
remote: ref.watch(productRemoteDataSourceProvider),
local: ref.watch(productLocalDataSourceProvider),
network: ref.watch(networkInfoProvider),
);
}
class=class="code-string">"code-comment">// Use cases
@riverpod
GetProducts getProducts(GetProductsRef ref) {
return GetProducts(ref.watch(productRepositoryProvider));
}
class=class="code-string">"code-comment">// Presentation layer — connects use cases to UI state
@riverpod
class ProductListController extends _$ProductListController {
@override
Future<List<Product>> build() async {
final getProducts = ref.watch(getProductsProvider);
final result = await getProducts(NoParams());
return result.fold(
(failure) => throw failure,
(products) => products,
);
}
}The key insight is that providers act as your dependency injection container. Each layer only depends on abstractions from the layer below, and Riverpod wires the concrete implementations together.
Common Riverpod Anti-Patterns
1. Using ref.read in build methods
class=class="code-string">"code-comment">// WRONG: ref.read does not trigger rebuilds when the value changes
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.read(userProvider); class=class="code-string">"code-comment">// Bug: will not update
return Text(user.name);
}
class=class="code-string">"code-comment">// CORRECT: ref.watch rebuilds the widget when the value changes
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return Text(user.name);
}2. Watching providers inside callbacks
class=class="code-string">"code-comment">// WRONG: ref.watch inside a callback causes unpredictable behavior
onPressed: () {
final api = ref.watch(apiProvider); class=class="code-string">"code-comment">// Do not watch in callbacks
api.submit();
}
class=class="code-string">"code-comment">// CORRECT: ref.read for one-time actions in callbacks
onPressed: () {
final api = ref.read(apiProvider);
api.submit();
}3. Fat providers that do everything
class=class="code-string">"code-comment">// WRONG: one provider handling auth, profile, settings, and notifications
@riverpod
class AppState extends _$AppState {
@override
Future<AppData> build() async {
final user = await fetchUser();
final settings = await fetchSettings();
final notifications = await fetchNotifications();
return AppData(user, settings, notifications);
}
}
class=class="code-string">"code-comment">// CORRECT: separate concerns into focused providers
@riverpod
Future<User> currentUser(CurrentUserRef ref) async { ... }
@riverpod
Future<Settings> userSettings(UserSettingsRef ref) async { ... }
@riverpod
Future<List<Notification>> notifications(NotificationsRef ref) async { ... }4. Ignoring autoDispose for navigation-scoped data
class=class="code-string">"code-comment">// WRONG: this provider leaks memory when the user navigates away
final searchResultsProvider = FutureProvider<List<Product>>((ref) async {
return ref.watch(apiProvider).search(ref.watch(searchQueryProvider));
});
class=class="code-string">"code-comment">// CORRECT: autoDispose cleans up when the screen is no longer visible
final searchResultsProvider =
FutureProvider.autoDispose<List<Product>>((ref) async {
return ref.watch(apiProvider).search(ref.watch(searchQueryProvider));
});5. Circular provider dependencies
class=class="code-string">"code-comment">// WRONG: providerA watches providerB which watches providerA
class=class="code-string">"code-comment">// This causes a ProviderException at runtime
class=class="code-string">"code-comment">// CORRECT: restructure so dependencies flow in one direction
class=class="code-string">"code-comment">// Extract shared logic into a third provider that both can depend onIn my production apps, I have found that anti-pattern number 3 causes the most real-world pain. When one massive provider handles multiple concerns, a change in notifications forces an unnecessary refetch of the user profile. Keeping providers small and focused lets Riverpod's dependency graph do its job efficiently.
Performance Tips
Selective Rebuilds with select
class=class="code-string">"code-comment">// Instead of rebuilding on any user change
final user = ref.watch(userProvider);
class=class="code-string">"code-comment">// Only rebuild when the specific field changes
final userName = ref.watch(
userProvider.select((user) => user.name),
);Debouncing Provider Rebuilds
@riverpod
Future<List<Product>> searchProducts(
SearchProductsRef ref,
String query,
) async {
class=class="code-string">"code-comment">// Cancel if a new query arrives within 300ms
ref.keepAlive();
final link = ref.keepAlive();
final timer = Timer(const Duration(seconds: class="code-number">30), link.close);
ref.onDispose(timer.cancel);
class=class="code-string">"code-comment">// Debounce
await Future.delayed(const Duration(milliseconds: class="code-number">300));
final api = ref.watch(apiClientProvider);
return api.search(query);
}Conclusion
Riverpod rewards deliberate architecture. The patterns in this article -- parameterized families, AsyncNotifier with optimistic updates, code generation, focused providers with clean architecture boundaries -- form a toolkit that scales from a small feature to a complex production app. The testing story alone, where a `ProviderContainer` with overrides replaces hundreds of lines of mock setup, makes Riverpod worth the investment.
Start with the decision table to pick the right provider type, adopt code generation early, keep your providers small and focused, and your Flutter apps will be a pleasure to maintain.
Contact me if you need help designing a Riverpod architecture for your Flutter project or migrating an existing codebase to modern Riverpod patterns.
Related Articles
Flutter State Management: Riverpod, Provider, and Bloc Comparison
Compare state management approaches in Flutter. Understand Riverpod, Provider, and Bloc with clear decision criteria for each scenario.
Clean Architecture in Flutter: Building Scalable Applications
Learn how to apply Clean Architecture in Flutter pragmatically. A practical guide to layers, dependency management, and testable code.
Flutter Testing Guide: Unit, Widget, and Integration Tests
Build a practical Flutter testing strategy using unit, widget, and integration tests with clear responsibilities.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch