Flutter: Modern State Management with Riverpod
Riverpod is a modern state management solution developed by Remi Rousselet, the creator of Provider, that overcomes all the limitations of Provider. It offers compile-time safety, testability, and flexibility.
Why is State Management Important?
Modern applications have complex data flows:
- User login and session management
- Instant messaging and notifications
- Offline data synchronization
- Multiple API integrations
- Real-time updates
A powerful state management solution is essential to manage this complexity.
Limitations of Provider
Provider is a great tool, but it has some limitations:
1. BuildContext Dependency
// You always need context to read Provider
final user = Provider.of<UserProvider>(context);
// You can't access it in places without context (utility functions, services)2. Runtime Errors
// If Provider is not found, the app throws error AT RUNTIME
// Cannot be caught at compile time
final data = context.read<SomeProvider>(); // ProviderNotFoundException!3. Provider Combination Difficulty
It becomes complex when one provider depends on another provider.
4. Hot Reload Issues
Providers sometimes don't update properly on hot reload.
5. Global State Access
Accessing state from outside the widget tree is difficult.
How Riverpod Solves These Problems
✅ No BuildContext Needed
// Riverpod: Access from anywhere without context
final user = ref.watch(userProvider);
// Works in service classes, tests, everywhere✅ Compile-Time Safety
// If provider is not found, code WON'T COMPILE
// You catch errors before running
final data = ref.watch(someProvider); // Compile-time check!✅ Easy Provider Combination
// A provider can easily watch other providers
final userOrdersProvider = FutureProvider((ref) async {
final user = await ref.watch(userProvider.future);
return fetchOrders(user.id);
});✅ Excellent Testability
// You can easily override providers
ProviderScope(
overrides: [
userProvider.overrideWithValue(mockUser),
],
child: MyApp(),
)✅ Memory Management with AutoDispose
Unused providers are automatically cleaned up.
When Should You Use Riverpod?
| Situation | Provider | Riverpod |
|---|---|---|
| Simple apps | ✅ | ✅ |
| Large/Enterprise projects | ⚠️ | ✅ |
| Many dependent providers | ❌ | ✅ |
| Heavy testing requirements | ⚠️ | ✅ |
| Access outside widget tree | ❌ | ✅ |
| New projects | ⚠️ | ✅ |
General Rule: If you're starting a new project or have complex state requirements, prefer Riverpod.
Provider vs Riverpod
| Feature | Provider | Riverpod |
|---|---|---|
| BuildContext | Required | Not needed |
| Compile-time safety | Limited | Full |
| Provider combination | Hard | Easy |
| Testability | Medium | Excellent |
| Hot reload | Problematic | Seamless |
| Global access | With BuildContext | From anywhere |
⚠️ Important Note: Riverpod is an external package and does not work in DartPad. To run the examples on your computer, follow the installation steps below.
Installation
Add to your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.4.9Then run in terminal:
flutter pub getBasic Setup
Wrap your application with ProviderScope:
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}Provider Types
1. Provider (Immutable Values)
// Simple value
final greetingProvider = Provider<String>((ref) {
return 'Hello Flutter!';
});
// Usage
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final greeting = ref.watch(greetingProvider);
return Text(greeting);
}
}2. StateProvider (Simple State)
// Counter state
final counterProvider = StateProvider<int>((ref) => 0);
// Usage
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(child: Text('$count')),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(counterProvider.notifier).state++;
},
child: Icon(Icons.add),
),
);
}
}3. StateNotifierProvider (Complex State)
// State class
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
void decrement() => state--;
void reset() => state = 0;
}
// Provider definition
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
// Usage
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('$count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: Text('Increment'),
),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).decrement(),
child: Text('Decrement'),
),
],
);
}
}4. FutureProvider (Async Data)
final userProvider = FutureProvider<User>((ref) async {
final response = await http.get(Uri.parse('https://api.example.com/user'));
return User.fromJson(jsonDecode(response.body));
});
// Usage
class UserPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider);
return userAsync.when(
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
data: (user) => Text('Hello ${user.name}'),
);
}
}5. StreamProvider (Stream Data)
final messagesProvider = StreamProvider<List<Message>>((ref) {
return FirebaseFirestore.instance
.collection('messages')
.snapshots()
.map((snapshot) => snapshot.docs.map((doc) => Message.fromDoc(doc)).toList());
});
// Usage
class MessagesPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesAsync = ref.watch(messagesProvider);
return messagesAsync.when(
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
data: (messages) => ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) => ListTile(
title: Text(messages[index].text),
),
),
);
}
}ConsumerWidget vs Consumer
ConsumerWidget (Entire widget)
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}Consumer (Only a section)
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Page')), // Won't rebuild
body: Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('$count'); // Only this rebuilds
},
),
);
}
}ref.watch vs ref.read
// ✅ Use watch in build method (listens to changes)
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
// ✅ Use read in event handlers (one-time read)
onPressed: () {
ref.read(counterProvider.notifier).increment();
}
// ❌ Don't use read in build method
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.read(counterProvider); // Won't update
return Text('$count');
}Real World Example: Todo App
// Todo model
class Todo {
final String id;
final String title;
final bool isCompleted;
Todo({required this.id, required this.title, this.isCompleted = false});
Todo copyWith({String? title, bool? isCompleted}) {
return Todo(
id: id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
// Todo Notifier
class TodoNotifier extends StateNotifier<List<Todo>> {
TodoNotifier() : super([]);
void addTodo(String title) {
state = [
...state,
Todo(id: DateTime.now().toString(), title: title),
];
}
void toggleTodo(String id) {
state = state.map((todo) {
if (todo.id == id) {
return todo.copyWith(isCompleted: !todo.isCompleted);
}
return todo;
}).toList();
}
void removeTodo(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
// Provider
final todoProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
return TodoNotifier();
});
// Filter provider
enum TodoFilter { all, completed, uncompleted }
final todoFilterProvider = StateProvider<TodoFilter>((ref) => TodoFilter.all);
// Filtered todo list
final filteredTodosProvider = Provider<List<Todo>>((ref) {
final todos = ref.watch(todoProvider);
final filter = ref.watch(todoFilterProvider);
switch (filter) {
case TodoFilter.completed:
return todos.where((todo) => todo.isCompleted).toList();
case TodoFilter.uncompleted:
return todos.where((todo) => !todo.isCompleted).toList();
case TodoFilter.all:
return todos;
}
});
// UI
class TodoPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(filteredTodosProvider);
return Scaffold(
appBar: AppBar(
title: Text('Tasks'),
actions: [
PopupMenuButton<TodoFilter>(
onSelected: (filter) {
ref.read(todoFilterProvider.notifier).state = filter;
},
itemBuilder: (context) => [
PopupMenuItem(value: TodoFilter.all, child: Text('All')),
PopupMenuItem(value: TodoFilter.completed, child: Text('Completed')),
PopupMenuItem(value: TodoFilter.uncompleted, child: Text('Uncompleted')),
],
),
],
),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) {
ref.read(todoProvider.notifier).toggleTodo(todo.id);
},
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
ref.read(todoProvider.notifier).removeTodo(todo.id);
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context, ref),
child: Icon(Icons.add),
),
);
}
void _showAddDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('New Task'),
content: TextField(
controller: controller,
decoration: InputDecoration(hintText: 'Task name'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (controller.text.isNotEmpty) {
ref.read(todoProvider.notifier).addTodo(controller.text);
Navigator.pop(context);
}
},
child: Text('Add'),
),
],
),
);
}
}Provider Combination
// User provider
final userProvider = FutureProvider<User>((ref) async {
return await fetchUser();
});
// User's orders (depends on userProvider)
final userOrdersProvider = FutureProvider<List<Order>>((ref) async {
final user = await ref.watch(userProvider.future);
return await fetchOrders(user.id);
});
// Total order amount
final totalOrderAmountProvider = Provider<double>((ref) {
final ordersAsync = ref.watch(userOrdersProvider);
return ordersAsync.when(
loading: () => 0,
error: (_, __) => 0,
data: (orders) => orders.fold(0, (sum, order) => sum + order.amount),
);
});Family Modifier
Creating parameterized providers:
// Product detail provider
final productProvider = FutureProvider.family<Product, String>((ref, productId) async {
final response = await http.get(
Uri.parse('https://api.example.com/products/$productId'),
);
return Product.fromJson(jsonDecode(response.body));
});
// Usage
class ProductPage extends ConsumerWidget {
final String productId;
ProductPage({required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productAsync = ref.watch(productProvider(productId));
return productAsync.when(
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
data: (product) => Text(product.name),
);
}
}AutoDispose Modifier
Automatic provider cleanup:
// Provider is cleaned up when widget is disposed
final searchProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
// For debounce
await Future.delayed(Duration(milliseconds: 500));
// Cancellation check
if (ref.state.isRefreshing) return [];
return await searchProducts();
});Side Effects with ref.listen
class MyPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Run side effect when state changes
ref.listen<int>(counterProvider, (previous, next) {
if (next == 10) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('You reached 10!')),
);
}
});
return Text('${ref.watch(counterProvider)}');
}
}Refresh with ref.invalidate
// Re-run the provider
ElevatedButton(
onPressed: () {
ref.invalidate(userProvider);
},
child: Text('Refresh'),
)Best Practices
1. Define providers globally
// ✅ At the top of the file
final counterProvider = StateProvider<int>((ref) => 0);
// ❌ Don't define inside widgets
class MyWidget extends ConsumerWidget {
final counterProvider = StateProvider<int>((ref) => 0); // Wrong!
}2. Create small, focused providers
// ✅ Correct - Each provider does one thing
final userProvider = FutureProvider<User>(...);
final userOrdersProvider = FutureProvider<List<Order>>(...);
final userBalanceProvider = Provider<double>(...);
// ❌ Wrong - Provider doing too much
final everythingProvider = FutureProvider<Everything>(...);3. Use select to prevent unnecessary rebuilds
// ✅ Only rebuilds when name changes
final userName = ref.watch(userProvider.select((user) => user.name));
// ❌ Rebuilds when any field of User changes
final user = ref.watch(userProvider);Summary
- Riverpod: Advanced version of Provider
- ProviderScope: Scope wrapping the application
- StateProvider: For simple state
- StateNotifierProvider: For complex state
- FutureProvider: For async data
- StreamProvider: For stream data
- ref.watch: Use in build (listens)
- ref.read: Use in events (doesn't listen)
- family: Parameterized provider
- autoDispose: Automatic cleanup
Riverpod is an excellent solution for state management in large and complex Flutter projects.