Riverpod: The Future of Flutter State Management
Provider was the go-to state management for Flutter. Then Riverpod came along and fixed every problem Provider had — no BuildContext dependency, compile-time safety, auto-disposal, and proper testing support. Here's how it works in practice.
Setup
Add the dependency and wrap your app:
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.3
build_runner: ^2.4.8
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
ProviderScope stores all your provider state. Every provider you create lives inside this scope. That's it for setup.
The Five Provider Types You Actually Use
1. Provider — Static Values
For values that never change during the app's lifetime:
@riverpod
Dio dio(Ref ref) {
return Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
));
}
2. FutureProvider — Async Data
For one-shot async operations like fetching data:
@riverpod
Future<User> currentUser(Ref ref) async {
final dio = ref.watch(dioProvider);
final response = await dio.get('/me');
return User.fromJson(response.data);
}
In your widget:
class ProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(currentUserProvider);
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
data: (user) => Text('Hello, ${user.name}'),
);
}
}
No manual loading states. No setState. The .when() method handles loading, error, and data — every time.
3. StreamProvider — Reactive Data
For real-time data streams:
@riverpod
Stream<List<ChatMessage>> chatMessages(Ref ref, String chatId) {
final db = ref.watch(databaseProvider);
return db.watchMessages(chatId);
}
4. NotifierProvider — Mutable State
For state that changes over time with defined mutations:
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void addItem(Product product) {
state = [...state, CartItem(product: product, quantity: 1)];
}
void removeItem(String productId) {
state = state.where((item) => item.product.id != productId).toList();
}
double get total => state.fold(0, (sum, item) => sum + item.product.price * item.quantity);
}
5. AsyncNotifierProvider — Async Mutable State
The workhorse for most features. Combines async data fetching with mutations:
@riverpod
class TaskListNotifier extends _$TaskListNotifier {
@override
Future<List<Task>> build() async {
final repo = ref.watch(taskRepositoryProvider);
return repo.getAllTasks();
}
Future<void> toggleComplete(String taskId) async {
final repo = ref.read(taskRepositoryProvider);
await repo.toggleComplete(taskId);
ref.invalidateSelf(); // Refetch the list
await future; // Wait for rebuild to complete
}
}
Provider Dependencies
Providers can depend on other providers. Riverpod tracks the dependency graph and rebuilds only what's needed:
@riverpod
Future<List<Task>> filteredTasks(Ref ref) async {
final allTasks = await ref.watch(taskListNotifierProvider.future);
final filter = ref.watch(taskFilterProvider);
return switch (filter) {
TaskFilter.all => allTasks,
TaskFilter.active => allTasks.where((t) => !t.isCompleted).toList(),
TaskFilter.completed => allTasks.where((t) => t.isCompleted).toList(),
};
}
When taskFilterProvider changes, filteredTasks recalculates. When taskListNotifier refetches, filteredTasks also updates. No manual wiring.
Auto-Dispose: Memory Management That Works
By default, @riverpod providers auto-dispose when no widget is watching them. When a user navigates away from a screen, the provider's state is cleaned up automatically.
Want to keep state alive? Use keepAlive:
@Riverpod(keepAlive: true)
class AuthNotifier extends _$AuthNotifier {
@override
Future<AuthState> build() async {
// This stays alive for the entire app lifecycle
return _checkAuthStatus();
}
}
Testing Without Pain
Override any provider in tests — no mocking frameworks needed:
void main() {
test('filtered tasks returns only active tasks', () async {
final container = ProviderContainer(
overrides: [
taskRepositoryProvider.overrideWithValue(FakeTaskRepository()),
],
);
final tasks = await container.read(filteredTasksProvider.future);
expect(tasks.every((t) => !t.isCompleted), isTrue);
});
}
You swap the real repository with a fake one. The rest of the provider graph works exactly the same. No BuildContext needed, no widget tree required.
ref.watch vs ref.read vs ref.listen
This trips up every beginner. Here's the rule:
| Method | When to Use |
|---|---|
ref.watch | In build() methods. Rebuilds when value changes. |
ref.read | In callbacks, event handlers. One-time read, no rebuild. |
ref.listen | For side effects (show snackbar, navigate). |
// GOOD
final tasks = ref.watch(taskListProvider); // Rebuilds widget on change
// GOOD
onPressed: () => ref.read(cartProvider.notifier).addItem(product); // One-time action
// GOOD
ref.listen(authProvider, (prev, next) {
if (next is AsyncData && next.value == null) {
context.go('/login'); // Side effect: navigate on logout
}
});
Why Riverpod Over Provider
| Feature | Provider | Riverpod |
|---|---|---|
| Needs BuildContext | Yes | No |
| Compile-time safety | No | Yes |
| Auto-dispose | Manual | Built-in |
| Testing | Complex mocking | Simple overrides |
| Multiple providers of same type | Hacky | Natural |
| Code generation | No | Yes (optional) |
Provider requires you to climb the widget tree with context.read. Riverpod lets you access any provider from anywhere — your service layer, your tests, your other providers. No widget tree dependency.
What to Do Next
Start with one feature. Create an AsyncNotifierProvider for it. Use .when() in the UI. Override the repository in a test. Once you see how clean the code is compared to setState or Provider, you won't go back.



