Riverpod Deep Dive: AsyncNotifier, Code Generation and Testing
Riverpod 3.0 with code generation transforms the way you write state management
Flutters. Instead of declaring providers manually with generic types — approach
prone to hard-to-debug runtime errors — riverpod_generator
automatically generates typed, compile-time safe providers.
In this article we build a complete end-to-end feature: a management system of products with list, detail, creation and modification. Let's start with the repository abstract, we get to the widget, covering each step with unit tests and widget tests which use the provider overriding mechanism to isolate dependencies.
What You Will Learn
- Setup of riverpod_generator with build_runner in Flutter project
- AsyncNotifier vs Notifier: when to use which
- AsyncValue: loading, data, error and the .when() method
- Pattern for optimistic updates with asynchronous state
- Overriding providers in tests: mock without external frameworks
- Widget testing with ConsumerWidget and ProviderContainer
Project Setup
# 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
Feature Structure: Products
Directory structure for the "products" feature:
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 and 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: Implementation with God
// 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);
}
}
}
The Widget: ConsumerWidget and 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: Provider Override
Riverpod's provider override mechanism and testing superpower: no dependency on an external DI container, no Mockito with 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 with 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);
});
}
Attention: build_runner and Performance
Code generation with build_runner adds overhead to the pipeline
of development. Always use build_runner watch during development (not
build) for incremental generations. In CI/CD, add the command
flutter pub run build_runner build --delete-conflicting-outputs before
tests to ensure that the generated files are up to date.
Next Steps
With Riverpod mastered, the next article explores BLoC in depth: how to structure events and states with sealed classes, the difference between Cubit and BLoC, the composition of multiple BLoCs and hydration strategies for persistence of the state between sessions.
Conclusions
Riverpod 3.0 with code generation is one of the most popular state management solutions ergonomics available in Flutter today. Compile-time safety, the mechanism overrides for testing and the AsyncNotifier pattern for state management asynchronous form a coherent and powerful whole.
The key is to adopt code generation from the beginning of the project: add
riverpod_generator to an existing project that uses manual Riverpod
It is possible but requires gradual migration. Start with automatic generation
guarantees maximum type safety and the most maintainable code in the long term.







