Fortgeschrittene Riverpod-Patterns: Families, Notifiers und Testing
# Fortgeschrittene Riverpod-Patterns fuer produktionsreife Flutter-Apps
Riverpod ist meine Standard-Loesung fuer State Management in Flutter geworden. Doch zwischen dem Einstieg in Riverpod und dem professionellen Einsatz in Produktionsumgebungen liegt ein erheblicher Unterschied. In diesem Artikel teile ich die Patterns, Fallstricke und Architekturentscheidungen, die ich ueber mehrere veroeffentlichte Apps hinweg verfeinert habe.
Provider Families und parametrisierte Provider
Mit Provider Families lassen sich Provider erstellen, die Parameter entgegennehmen. Das ist unverzichtbar, wenn dieselbe Datenabruf-Logik fuer unterschiedliche Eingaben gilt -- etwa beim Laden eines Nutzerprofils anhand einer ID oder beim Abrufen von Produkten nach Kategorie.
Grundlegende Family-Nutzung
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">// Im 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),
);
}
}Komplexe Parameter mit Records
Wenn ein Provider mehrere Parameter benoetigt, bieten Dart Records eine elegante Loesung -- ohne eine eigene Parameterklasse anlegen zu muessen.
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">// Verwendung
final products = ref.watch(productsProvider((
category: class="code-string">'electronics',
page: class="code-number">1,
sort: SortOrder.priceAsc,
)));In meinen Produktions-Apps habe ich festgestellt, dass Records die perfekte Balance bieten: Sie liefern benannte Felder ohne den Overhead einer vollstaendigen Klasse, und Riverpod behandelt den Gleichheitsvergleich direkt korrekt.
StateNotifier und AsyncNotifier Patterns
Wann StateNotifier noch sinnvoll ist
Obwohl `Notifier` und `AsyncNotifier` seit Riverpod 2.0 der empfohlene Ansatz sind, werden Sie in bestehenden Codebasen auf `StateNotifier` stossen. Beide Varianten zu verstehen ist wichtig.
class=class="code-string">"code-comment">// Legacy StateNotifier-Ansatz
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));
});Das moderne AsyncNotifier-Pattern
Bei asynchronen Workflows zeigt Riverpod mit `AsyncNotifier` seine wahre Staerke. Die `build`-Methode definiert das initiale asynchrone Laden, und nachfolgende Mutationen koennen den State aktualisieren, waehrend die Fehlerbehandlung erhalten bleibt.
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">// Optimistisches 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 bei Fehler
state = previousState;
state = AsyncError(e, stack);
}
}
}
final productListProvider =
AsyncNotifierProvider<ProductListNotifier, List<Product>>(
ProductListNotifier.new,
);In meinen Produktions-Apps hat sich das optimistische Update-Pattern als unverzichtbar erwiesen, um reaktionsfaehige UIs zu bauen. Der Nutzer bekommt sofortiges Feedback, waehrend die Netzwerkanfrage im Hintergrund laeuft -- und bei Fehlern wird der State sauber zurueckgesetzt.
Family + AsyncNotifier Kombination
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,
);Welchen Provider-Typ wann verwenden?
Die Wahl des richtigen Provider-Typs ist eine der ersten Entscheidungen, an der Entwickler straucheln. Hier ist die Entscheidungstabelle, die ich verwende.
| Szenario | Provider-Typ | Grund |
|---|---|---|
| Einfacher abgeleiteter Wert | `Provider` | Berechneter State, keine Seiteneffekte |
| Asynchroner Datenabruf (nur lesen) | `FutureProvider` | Automatische Lade-/Fehler-/Datenzustaende |
| Echtzeit-Stream | `StreamProvider` | WebSocket, Firestore Snapshots |
| Veraenderbarer synchroner State | `NotifierProvider` | Formularzustand, Toggles, Zaehler |
| Veraenderbarer asynchroner State | `AsyncNotifierProvider` | CRUD-Operationen mit Ladezustaenden |
| Parametrisiert (nur lesen) | `FutureProvider.family` | Gleiche Abfrage, unterschiedliche Eingaben |
| Parametrisiert (veraenderbar) | `AsyncNotifierProvider.family` | Entitaetsbezogene Operationen |
| Einmalige Einrichtung | `Provider` | Dependency Injection fuer Services |
Im Zweifelsfall: Beginnen Sie mit `FutureProvider` fuer Leseoperationen und `AsyncNotifierProvider` fuer Schreiboperationen. Refactoring ist jederzeit moeglich.
Codegenerierung mit riverpod_generator
Codegenerierung reduziert Boilerplate und faengt Konfigurationsfehler zur Kompilierzeit ab. In meinen Produktions-Apps habe ich festgestellt, dass die fruehe Einfuehrung von Codegen spaeter erheblichen Refactoring-Aufwand spart.
Einrichtung
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.0
riverpod_annotation: ^2.3.0
dev_dependencies:
riverpod_generator: ^2.4.0
build_runner: ^2.4.0Generierte Provider in der Praxis
import class="code-string">'package:riverpod_annotation/riverpod_annotation.dart';
part class="code-string">'auth_provider.g.dart';
class=class="code-string">"code-comment">// Einfacher Provider — ersetzt 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 mit Family — ersetzt 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 — ersetzt 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 und AutoDispose-Steuerung
class=class="code-string">"code-comment">// Dieser Provider bleibt waehrend der gesamten App-Laufzeit aktiv
@Riverpod(keepAlive: true)
class AuthState extends _$AuthState {
@override
Future<AuthUser?> build() async {
return ref.watch(authRepositoryProvider).getCurrentUser();
}
}
class=class="code-string">"code-comment">// Dieser Provider wird automatisch bereinigt, wenn er nicht mehr beobachtet wird
@riverpod
Future<List<Product>> searchProducts(
SearchProductsRef ref,
String query,
) async {
class=class="code-string">"code-comment">// Suche entprellen
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);
}Provider testen
Beim Testen zahlt sich die Architektur von Riverpod richtig aus. Sie brauchen keine Widget-Trees, keinen BuildContext und keine komplexen Mocking-Frameworks. Ein `ProviderContainer` genuegt.
Unit-Tests fuer 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 gibt Produktliste zurueck', () 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);
final subscription = container.listen(
productListProvider,
(_, __) {},
);
await container.read(productListProvider.future);
expect(
container.read(productListProvider).value,
equals(expectedProducts),
);
subscription.close();
});
test(class="code-string">'deleteProduct fuehrt optimistisches Update durch', () 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,
);
});
}Tests mit mehreren 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-Nutzer kann Produkte loeschen', () 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-Tests mit ProviderScope-Overrides
testWidgets(class="code-string">'ProductListScreen zeigt Laden und dann Daten', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productListProvider.overrideWith(
() => FakeProductNotifier([
Product(id: class="code-string">'class="code-number">1', name: class="code-string">'Testprodukt'),
]),
),
],
child: const MaterialApp(home: ProductListScreen()),
),
);
class=class="code-string">"code-comment">// Anfangs wird der Ladeindikator angezeigt
expect(find.byType(CircularProgressIndicator), findsOneWidget);
class=class="code-string">"code-comment">// Nach Abschluss der asynchronen Operation
await tester.pumpAndSettle();
expect(find.text(class="code-string">'Testprodukt'), findsOneWidget);
});Riverpod + Clean Architecture Integration
Clean Architecture und Riverpod ergaenzen sich hervorragend, wenn klare Grenzen definiert werden. Hier ist die Struktur, die ich in Produktionsprojekten verwende.
Schichtentrennung
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 # abstrakt
usecases/
get_products.dart
delete_product.dart
presentation/
providers/
product_providers.dart
screens/
product_list_screen.dart
widgets/
product_card.dartVerdrahtung mit Providern
class=class="code-string">"code-comment">// Datenschicht — konkrete Implementierungen
@riverpod
ProductRemoteDataSource productRemoteDataSource(
ProductRemoteDataSourceRef ref,
) {
final dio = ref.watch(dioProvider);
return ProductRemoteDataSourceImpl(dio);
}
class=class="code-string">"code-comment">// Domaenschicht — abstraktes Repository ueber Datenschicht bereitgestellt
@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">// Praesentationsschicht — verbindet Use Cases mit dem 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,
);
}
}Die zentrale Erkenntnis: Provider fungieren als Ihr Dependency-Injection-Container. Jede Schicht haengt nur von den Abstraktionen der darunterliegenden Schicht ab, und Riverpod verbindet die konkreten Implementierungen miteinander.
Haeufige Riverpod Anti-Patterns
1. ref.read in build-Methoden verwenden
class=class="code-string">"code-comment">// FALSCH: ref.read loest bei Wertaenderungen keinen Rebuild aus
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.read(userProvider); class=class="code-string">"code-comment">// Bug: wird nicht aktualisiert
return Text(user.name);
}
class=class="code-string">"code-comment">// RICHTIG: ref.watch baut das Widget bei Wertaenderungen neu auf
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return Text(user.name);
}2. ref.watch innerhalb von Callbacks
class=class="code-string">"code-comment">// FALSCH: ref.watch im Callback verursacht unvorhersehbares Verhalten
onPressed: () {
final api = ref.watch(apiProvider); class=class="code-string">"code-comment">// Nicht in Callbacks watchen
api.submit();
}
class=class="code-string">"code-comment">// RICHTIG: ref.read fuer einmalige Aktionen in Callbacks
onPressed: () {
final api = ref.read(apiProvider);
api.submit();
}3. Aufgeblaehte Provider, die alles machen
class=class="code-string">"code-comment">// FALSCH: ein Provider fuer Auth, Profil, Einstellungen und Benachrichtigungen
@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">// RICHTIG: Verantwortlichkeiten in fokussierte Provider aufteilen
@riverpod
Future<User> currentUser(CurrentUserRef ref) async { ... }
@riverpod
Future<Settings> userSettings(UserSettingsRef ref) async { ... }
@riverpod
Future<List<Notification>> notifications(NotificationsRef ref) async { ... }4. autoDispose bei navigationsbezogenen Daten vergessen
class=class="code-string">"code-comment">// FALSCH: dieser Provider verursacht ein Speicherleck nach Navigation
final searchResultsProvider = FutureProvider<List<Product>>((ref) async {
return ref.watch(apiProvider).search(ref.watch(searchQueryProvider));
});
class=class="code-string">"code-comment">// RICHTIG: autoDispose raeumt auf, wenn der Screen nicht mehr sichtbar ist
final searchResultsProvider =
FutureProvider.autoDispose<List<Product>>((ref) async {
return ref.watch(apiProvider).search(ref.watch(searchQueryProvider));
});5. Zirkulaere Provider-Abhaengigkeiten
class=class="code-string">"code-comment">// FALSCH: providerA beobachtet providerB, der providerA beobachtet
class=class="code-string">"code-comment">// Das verursacht zur Laufzeit eine ProviderException
class=class="code-string">"code-comment">// RICHTIG: Abhaengigkeiten so umstrukturieren, dass sie in eine Richtung fliessen
class=class="code-string">"code-comment">// Gemeinsame Logik in einen dritten Provider auslagernIn meinen Produktions-Apps habe ich festgestellt, dass Anti-Pattern Nummer 3 in der Praxis den meisten Schmerz verursacht. Wenn ein einziger riesiger Provider mehrere Verantwortlichkeiten traegt, erzwingt eine Aenderung bei Benachrichtigungen ein unnoeties Neuladen des Nutzerprofils. Kleine, fokussierte Provider lassen Riverpods Abhaengigkeitsgraphen effizient arbeiten.
Tipps zur Leistungsoptimierung
Gezielte Rebuilds mit select
class=class="code-string">"code-comment">// Statt bei jeder Nutzeraenderung neu zu bauen
final user = ref.watch(userProvider);
class=class="code-string">"code-comment">// Nur bei Aenderung eines bestimmten Feldes neu bauen
final userName = ref.watch(
userProvider.select((user) => user.name),
);Debouncing von Provider-Rebuilds
@riverpod
Future<List<Product>> searchProducts(
SearchProductsRef ref,
String query,
) async {
class=class="code-string">"code-comment">// Abbrechen, wenn innerhalb von 300ms eine neue Anfrage kommt
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">// Entprellen
await Future.delayed(const Duration(milliseconds: class="code-number">300));
final api = ref.watch(apiClientProvider);
return api.search(query);
}Fazit
Riverpod belohnt durchdachte Architektur. Die Patterns in diesem Artikel -- parametrisierte Families, AsyncNotifier mit optimistischen Updates, Codegenerierung, fokussierte Provider mit Clean-Architecture-Grenzen -- bilden ein Werkzeugset, das von einem kleinen Feature bis zur komplexen Produktions-App skaliert. Allein die Testbarkeit, bei der ein `ProviderContainer` mit Overrides hunderte Zeilen Mock-Setup ersetzt, macht Riverpod zur lohnenden Investition.
Beginnen Sie mit der Entscheidungstabelle fuer den richtigen Provider-Typ, fuehren Sie Codegenerierung frueh ein, halten Sie Ihre Provider klein und fokussiert -- und Ihre Flutter-Apps werden eine Freude in der Wartung sein.
Kontaktieren Sie mich, wenn Sie Unterstuetzung beim Entwurf einer Riverpod-Architektur oder bei der Migration einer bestehenden Codebasis auf moderne Riverpod-Patterns benoetigen.
Verwandte Artikel
Flutter State Management: Riverpod, Provider und Bloc im Vergleich
Vergleichen Sie State-Management-Ansätze in Flutter. Verstehen Sie Riverpod, Provider und Bloc mit klaren Entscheidungskriterien.
Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
Lernen Sie, Clean Architecture in Flutter praxisnah umzusetzen. Ein Leitfaden für Schichten, Dependency Management und testbaren Code.
Flutter Testing: Unit-, Widget- und Integrationstests
Erstellen Sie eine praktikable Flutter-Teststrategie mit klaren Rollen für Unit-, Widget- und Integrationstests.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen