Flutter Riverpod 2.0 State Management Guide
State management is the most debated topic in Flutter development, and for good reason. The wrong choice costs weeks of refactoring later. After evaluating Provider, BLoC, GetX, and Riverpod across production apps, Riverpod 2.0 has emerged as the clear winner for most projects in 2023.
Riverpod 2.0, released late last year, brings code generation, improved type safety, and a dramatically simplified API. If you tried Riverpod 1.x and found it verbose, version 2.0 deserves a fresh look. This guide covers everything you need to build production-ready Flutter apps with Riverpod 2.0.
Why Riverpod Over the Alternatives
Before diving into implementation, let me address why Riverpod 2.0 specifically:
Provider (the package, not the concept) works well for simple apps but struggles with complex dependency graphs. It relies on the widget tree for scoping, which creates tight coupling between your state management and UI hierarchy.
BLoC provides excellent structure but introduces significant boilerplate. Every feature requires Events, States, and BLoC classes. For teams that value explicit state machines, BLoC is solid. For teams that value productivity, it can feel heavy.
GetX offers simplicity but at the cost of testability and explicit dependency management. Its global state approach makes it difficult to reason about data flow in large applications.
Riverpod 2.0 combines the best aspects: compile-time safety, framework independence (providers exist outside the widget tree), excellent testability, and with code generation, minimal boilerplate.
Setting Up
Riverpod 2.0
Add the required dependencies to your pubspec.yaml:
dependencies:
flutter_riverpod: ^2.2.0
riverpod_annotation: ^2.0.0
dev_dependencies:
riverpod_generator: ^2.1.0
build_runner: ^2.3.0
Wrap your app with ProviderScope:
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
Code Generation: The
New Standard
The most significant change in Riverpod 2.0 is code generation. Instead of manually choosing between Provider, StateProvider, FutureProvider, StreamProvider, StateNotifierProvider, and NotifierProvider, you write annotated functions or classes and the generator creates the correct provider type.
Simple Providers
For computed values or dependencies without state:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@riverpod
String greeting(GreetingRef ref) {
return 'Hello from Riverpod 2.0';
}
@riverpod
double taxRate(TaxRateRef ref) {
return 0.10; // Australian GST
}
@riverpod
double priceWithTax(PriceWithTaxRef ref, double price) {
final tax = ref.watch(taxRateProvider);
return price * (1 + tax);
}
Run dart run build_runner build and Riverpod generates the appropriate provider types automatically. No more choosing between provider variants.
Async Providers
For data fetched from APIs or databases:
@riverpod
Future<List<Product>> products(ProductsRef ref) async {
final repository = ref.watch(productRepositoryProvider);
return repository.fetchProducts();
}
@riverpod
Stream<List<Message>> chatMessages(ChatMessagesRef ref, String roomId) {
final chatService = ref.watch(chatServiceProvider);
return chatService.messagesStream(roomId);
}
The generator recognises Future and Stream return types and creates FutureProvider and StreamProvider instances respectively. Parameters are handled automatically, creating family providers behind the scenes.
Stateful Providers with Notifier
For mutable state with business logic, use the class-based syntax:
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() {
return [];
}
void addItem(Product product) {
state = [
...state,
CartItem(
product: product,
quantity: 1,
addedAt: DateTime.now(),
),
];
}
void removeItem(String productId) {
state = state.where((item) => item.product.id != productId).toList();
}
void updateQuantity(String productId, int quantity) {
state = state.map((item) {
if (item.product.id == productId) {
return item.copyWith(quantity: quantity);
}
return item;
}).toList();
}
double get total => state.fold(
0,
(sum, item) => sum + (item.product.price * item.quantity),
);
}
The build method replaces the constructor-based initialisation from Riverpod 1.x. It defines the initial state and is called whenever the provider is first read or needs to rebuild.
Async Notifiers
For stateful providers that need to fetch initial data:
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async {
final authService = ref.watch(authServiceProvider);
final userId = authService.currentUserId;
final repository = ref.watch(userRepositoryProvider);
return repository.getUser(userId);
}
Future<void> updateName(String name) async {
final repository = ref.watch(userRepositoryProvider);
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final updated = await repository.updateUser(
state.requireValue.copyWith(name: name),
);
return updated;
});
}
}
Working with AsyncValue
AsyncValue is Riverpod’s way of representing asynchronous state. It has three states: AsyncData, AsyncLoading, and AsyncError. The when method provides exhaustive pattern matching:
class ProductListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductCard(
product: products[index],
),
),
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Something went wrong: $error'),
ElevatedButton(
onPressed: () => ref.invalidate(productsProvider),
child: const Text('Retry'),
),
],
),
),
);
}
}
AsyncValue Best Practices
Preserve previous data during refresh. When refreshing, you often want to show the old data with a loading indicator rather than replacing the entire screen with a spinner:
productsAsync.when(
data: (products) => RefreshIndicator(
onRefresh: () => ref.refresh(productsProvider.future),
child: ProductList(products: products),
),
loading: () => const LoadingScreen(),
error: (e, s) => ErrorScreen(error: e, onRetry: () => ref.invalidate(productsProvider)),
// Keep showing old data while refreshing
skipLoadingOnRefresh: true,
);
Use AsyncValue.guard for state mutations in async notifiers. It automatically catches exceptions and wraps them in AsyncError:
Future<void> deleteProduct(String id) async {
state = await AsyncValue.guard(() async {
await repository.delete(id);
return state.requireValue.where((p) => p.id != id).toList();
});
}
Provider Lifecycle and Disposal
Understanding when providers are created and disposed is critical for resource management:
@riverpod
class WebSocketConnection extends _$WebSocketConnection {
@override
Stream<ServerEvent> build() {
final channel = WebSocketChannel.connect(
Uri.parse('wss://api.example.com/events'),
);
// Cleanup when provider is disposed
ref.onDispose(() {
channel.sink.close();
});
return channel.stream.map(
(data) => ServerEvent.fromJson(jsonDecode(data)),
);
}
}
By default, generated providers are auto-disposed when no longer listened to. If you need a provider to persist, use the keepAlive annotation:
@Riverpod(keepAlive: true)
class AuthState extends _$AuthState {
@override
Future<AuthUser?> build() async {
return authService.getCurrentUser();
}
}
Testing with Riverpod
Testing is where Riverpod truly shines. You can override any provider in tests without modifying your production code:
void main() {
test('CartNotifier adds items correctly', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final cart = container.read(cartNotifierProvider.notifier);
cart.addItem(testProduct);
expect(container.read(cartNotifierProvider).length, 1);
expect(container.read(cartNotifierProvider).first.product, testProduct);
});
test('Products provider returns data from repository', () async {
final container = ProviderContainer(
overrides: [
productRepositoryProvider.overrideWithValue(
MockProductRepository(),
),
],
);
addTearDown(container.dispose);
final products = await container.read(productsProvider.future);
expect(products.length, 3);
});
}
Widget tests work similarly with ProviderScope overrides:
testWidgets('ProductListScreen shows products', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productsProvider.overrideWith(
(ref) => Future.value(testProducts),
),
],
child: const MaterialApp(
home: ProductListScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Test Product'), findsOneWidget);
});
Architecture Patterns with Riverpod
Repository Pattern
Structure your data layer with repository providers:
@riverpod
ProductRepository productRepository(ProductRepositoryRef ref) {
final dio = ref.watch(dioProvider);
final db = ref.watch(databaseProvider);
return ProductRepositoryImpl(dio: dio, database: db);
}
Feature-Based Organisation
Organise providers by feature rather than type:
lib/
features/
products/
data/
product_repository.dart
domain/
product.dart
presentation/
product_list_screen.dart
product_providers.dart
cart/
data/
cart_repository.dart
domain/
cart_item.dart
presentation/
cart_screen.dart
cart_providers.dart
shared/
providers/
dio_provider.dart
database_provider.dart
Combining Providers
One of Riverpod’s strengths is composing providers. A provider can watch other providers to create derived state:
@riverpod
Future<HomeScreenData> homeScreenData(HomeScreenDataRef ref) async {
final products = await ref.watch(featuredProductsProvider.future);
final user = await ref.watch(userProfileProvider.future);
final cart = ref.watch(cartNotifierProvider);
return HomeScreenData(
featuredProducts: products,
userName: user.name,
cartItemCount: cart.length,
);
}
Migration from Provider or Riverpod 1.x
If you are migrating from the Provider package, the conceptual shift is moving state management outside the widget tree. Providers become global declarations, and you use ref.watch and ref.read instead of context.watch and context.read.
If you are migrating from Riverpod 1.x, the key change is adopting code generation. The StateNotifier is replaced by Notifier (and AsyncNotifier), and you no longer need to manually choose provider types. Migration can be incremental — old-style and new-style providers coexist within the same app.
Practical Recommendations
- Always use code generation for new projects. The reduction in boilerplate and type safety improvements are worth the build step.
- Start with functional providers and move to class-based Notifiers only when you need mutable state with methods.
- Use
ref.watchin build methods andref.readin callbacks. This is the single most important rule for avoiding stale state bugs. - Invalidate rather than reset. When you need to refresh data,
ref.invalidate(provider)triggers a clean rebuild. - Keep providers focused. One provider per concern. Combine them with derived providers when needed.
Riverpod 2.0 represents the maturation of Flutter state management. It is no longer a question of whether Riverpod is production-ready — it is the standard that other solutions are measured against.
Building a Flutter app? Our team at eawesome delivers cross-platform mobile applications with robust, scalable architecture.