Riverpod Deep Dive: AsyncNotifier, Code Generation e Testing
Riverpod 3.0 con code generation trasforma il modo di scrivere state management in
Flutter. Invece di dichiarare provider manualmente con tipi generici — approccio
soggetto a errori runtime difficili da debuggare — riverpod_generator
genera automaticamente provider tipizzati e sicuri a compile-time.
In questo articolo costruiamo una feature completa end-to-end: un sistema di gestione dei prodotti con lista, dettaglio, creazione e modifica. Partiamo dal repository astratto, arriviamo al widget, coprendo ogni passaggio con unit test e widget test che usano il meccanismo di override dei provider per isolare le dipendenze.
Cosa Imparerai
- Setup di riverpod_generator con build_runner nel progetto Flutter
- AsyncNotifier vs Notifier: quando usare quale
- AsyncValue: loading, data, error e il metodo .when()
- Pattern per aggiornamenti ottimistici con stato asincrono
- Override dei provider nei test: mock senza framework esterni
- Widget test con ConsumerWidget e ProviderContainer
Setup del Progetto
# pubspec.yaml - dipendenze necessarie
dependencies:
flutter:
sdk: flutter
riverpod: ^3.0.0
riverpod_annotation: ^3.0.0
flutter_riverpod: ^3.0.0
freezed_annotation: ^2.4.0 # opzionale, per data classes
dev_dependencies:
build_runner: ^2.4.0
riverpod_generator: ^3.0.0
freezed: ^2.4.0
riverpod_lint: ^3.0.0
custom_lint: ^0.7.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
# Genera i file .g.dart
flutter pub run build_runner watch --delete-conflicting-outputs
# Oppure una singola generazione
flutter pub run build_runner build --delete-conflicting-outputs
Struttura della Feature: Products
Struttura delle directory per la feature "products":
lib/
features/
products/
data/
product_repository.dart # Implementazione repository
product_repository.g.dart # File generato da riverpod_generator
domain/
product.dart # Model (con Freezed opzionale)
product_repository_interface.dart # Interfaccia astratta
presentation/
products_list_provider.dart # AsyncNotifier
products_list_provider.g.dart # File generato
products_list_page.dart # Widget
product_detail_page.dart # Widget
Domain Layer: Model e Repository Interface
// domain/product.dart - Con Freezed per immutabilita
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
part 'product.g.dart';
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
required String description,
required double price,
required bool inStock,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
}
// domain/product_repository_interface.dart
abstract class ProductRepositoryInterface {
Future<List<Product>> fetchProducts();
Future<Product> fetchProduct(String id);
Future<Product> createProduct(Product product);
Future<Product> updateProduct(Product product);
Future<void> deleteProduct(String id);
}
Data Layer: Implementazione con Dio
// data/product_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:dio/dio.dart';
part 'product_repository.g.dart';
// Provider per Dio (solitamente definito a livello globale)
@riverpod
Dio dio(Ref ref) {
return Dio(BaseOptions(
baseUrl: 'https://api.esempio.com/v1',
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
));
}
// Provider per il repository
@riverpod
ProductRepository productRepository(Ref ref) {
return ProductRepository(dio: ref.watch(dioProvider));
}
class ProductRepository implements ProductRepositoryInterface {
const ProductRepository({required Dio dio}) : _dio = dio;
final Dio _dio;
@override
Future<List<Product>> fetchProducts() async {
final response = await _dio.get<List<dynamic>>('/products');
return response.data!
.cast<Map<String, dynamic>>()
.map(Product.fromJson)
.toList();
}
@override
Future<Product> createProduct(Product product) async {
final response = await _dio.post<Map<String, dynamic>>(
'/products',
data: product.toJson(),
);
return Product.fromJson(response.data!);
}
@override
Future<Product> updateProduct(Product product) async {
final response = await _dio.put<Map<String, dynamic>>(
'/products/${product.id}',
data: product.toJson(),
);
return Product.fromJson(response.data!);
}
@override
Future<void> deleteProduct(String id) async {
await _dio.delete('/products/$id');
}
@override
Future<Product> fetchProduct(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/products/$id');
return Product.fromJson(response.data!);
}
}
Presentation Layer: AsyncNotifier
// presentation/products_list_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'products_list_provider.g.dart';
@riverpod
class ProductsList extends _$ProductsList {
@override
Future<List<Product>> build() async {
// Primo caricamento: fetchProducts() automatico
return _fetchProducts();
}
Future<List<Product>> _fetchProducts() {
return ref.read(productRepositoryProvider).fetchProducts();
}
// Ricarica manuale (pull-to-refresh)
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_fetchProducts);
}
// Aggiunta con ottimistic update
Future<void> addProduct(Product product) async {
// 1. Ottimistic update: aggiungi subito alla lista locale
final currentList = state.valueOrNull ?? [];
state = AsyncData([...currentList, product.copyWith(id: 'temp-${DateTime.now().millisecondsSinceEpoch}')]);
// 2. Chiama l'API
try {
final createdProduct = await ref.read(productRepositoryProvider).createProduct(product);
// 3. Sostituisci il prodotto temporaneo con quello reale
state = AsyncData([
...currentList,
createdProduct,
]);
} catch (error, stackTrace) {
// 4. Rollback in caso di errore
state = AsyncData(currentList);
state = AsyncError(error, stackTrace);
}
}
// Eliminazione con ottimistic update
Future<void> deleteProduct(String id) async {
final previousList = state.valueOrNull ?? [];
// Rimuovi immediatamente dalla lista
state = AsyncData(previousList.where((p) => p.id != id).toList());
try {
await ref.read(productRepositoryProvider).deleteProduct(id);
} catch (error, stackTrace) {
// Ripristina in caso di errore
state = AsyncData(previousList);
state = AsyncError(error, stackTrace);
}
}
}
Il Widget: ConsumerWidget e AsyncValue.when()
// presentation/products_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProductsListPage extends ConsumerWidget {
const ProductsListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Prodotti'),
actions: [
// Refresh manuale
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(productsListProvider.notifier).refresh(),
),
],
),
// Pull-to-refresh
body: RefreshIndicator(
onRefresh: () => ref.read(productsListProvider.notifier).refresh(),
child: productsAsync.when(
// Stato loading: skeleton screen
loading: () => const ProductsListSkeleton(),
// Stato errore con retry
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Errore: $error'),
ElevatedButton(
onPressed: () =>
ref.read(productsListProvider.notifier).refresh(),
child: const Text('Riprova'),
),
],
),
),
// Stato dati: lista prodotti
data: (products) => products.isEmpty
? const EmptyProductsView()
: ListView.separated(
itemCount: products.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final product = products[index];
return ProductTile(
product: product,
onDelete: () => ref
.read(productsListProvider.notifier)
.deleteProduct(product.id),
);
},
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddProductDialog(context, ref),
child: const Icon(Icons.add),
),
);
}
void _showAddProductDialog(BuildContext context, WidgetRef ref) {
// Dialog per aggiungere un prodotto
}
}
Testing: Override dei Provider
Il meccanismo di override dei provider di Riverpod e il superpotere del testing: nessuna dipendenza da un DI container esterno, nessun Mockito con code generation.
// test/products_list_provider_test.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// Mock del repository
class MockProductRepository extends Mock implements ProductRepositoryInterface {}
void main() {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
test('fetchProducts: carica la lista prodotti', () async {
// Arrange
final products = [
const Product(id: '1', name: 'Prodotto A', description: 'Desc', price: 10.0, inStock: true),
const Product(id: '2', name: 'Prodotto B', description: 'Desc', price: 20.0, inStock: false),
];
when(() => mockRepository.fetchProducts()).thenAnswer((_) async => products);
// ProviderContainer con override del repository
final container = ProviderContainer(
overrides: [
// Override: sostituisci il provider reale con il mock
productRepositoryProvider.overrideWithValue(mockRepository),
],
);
// Act: accedi al provider e aspetta il completamento
final result = await container.read(productsListProvider.future);
// Assert
expect(result, equals(products));
verify(() => mockRepository.fetchProducts()).called(1);
// Cleanup
container.dispose();
});
test('addProduct: aggiunge un prodotto con ottimistic update', () async {
// Arrange
final initialProducts = [
const Product(id: '1', name: 'Prodotto A', description: 'Desc', price: 10.0, inStock: true),
];
final newProduct = const Product(
id: '2', name: 'Nuovo Prodotto', description: 'Desc', price: 15.0, inStock: true,
);
when(() => mockRepository.fetchProducts()).thenAnswer((_) async => initialProducts);
when(() => mockRepository.createProduct(any())).thenAnswer((_) async => newProduct);
final container = ProviderContainer(
overrides: [
productRepositoryProvider.overrideWithValue(mockRepository),
],
);
// Carica la lista iniziale
await container.read(productsListProvider.future);
// Act: aggiungi il prodotto
await container.read(productsListProvider.notifier).addProduct(newProduct);
// Assert: la lista ora contiene entrambi i prodotti
final updatedProducts = container.read(productsListProvider).valueOrNull;
expect(updatedProducts, hasLength(2));
expect(updatedProducts!.any((p) => p.id == '2'), isTrue);
container.dispose();
});
}
Widget Test con ConsumerWidget
// test/products_list_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('ProductsListPage mostra la lista prodotti', (tester) async {
final products = [
const Product(id: '1', name: 'Prodotto Test', description: 'Test', price: 9.99, inStock: true),
];
// ProviderScope con override nella pumpa del widget
await tester.pumpWidget(
ProviderScope(
overrides: [
productsListProvider.overrideWith(
() => AsyncData(products),
),
],
child: const MaterialApp(home: ProductsListPage()),
),
);
// Aspetta il completamento delle animazioni
await tester.pumpAndSettle();
// Assert
expect(find.text('Prodotto Test'), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
});
testWidgets('ProductsListPage mostra loading skeleton', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productsListProvider.overrideWith(() => const AsyncLoading()),
],
child: const MaterialApp(home: ProductsListPage()),
),
);
await tester.pump();
expect(find.byType(ProductsListSkeleton), findsOneWidget);
});
}
Attenzione: build_runner e Performance
La code generation con build_runner aggiunge overhead alla pipeline
di sviluppo. Usa sempre build_runner watch durante lo sviluppo (non
build) per generazioni incrementali. In CI/CD, aggiungi il comando
flutter pub run build_runner build --delete-conflicting-outputs prima
dei test per assicurarti che i file generati siano aggiornati.
Next Steps
Con Riverpod padroneggiato, il prossimo articolo esplora BLoC in profondita: come strutturare events e states con sealed classes, la differenza tra Cubit e BLoC, la composizione di BLoC multipli e le strategie di hydration per la persistenza dello stato tra sessioni.
Conclusioni
Riverpod 3.0 con code generation e una delle soluzioni di state management piu ergonomiche disponibili in Flutter oggi. La compile-time safety, il meccanismo di override per i test e il pattern AsyncNotifier per la gestione dello stato asincrono formano un insieme coerente e potente.
La chiave e adottare la code generation sin dall'inizio del progetto: aggiungere
riverpod_generator a un progetto esistente che usa Riverpod manuale
e possibile ma richiede migrazione graduale. Partire con la generazione automatica
garantisce la massima type safety e il codice piu manutenibile nel lungo termine.







