Back to Blog
Mastering State Management in Flutter with Riverpod
Learn how to implement clean and scalable state management in Flutter applications using Riverpod. This guide covers providers, state notifiers, and best practices.
5 min read
Flutter
# Mastering State Management in Flutter with Riverpod
State management is one of the most crucial aspects of Flutter development. While Flutter provides several options for managing state, Riverpod stands out as a powerful, type-safe, and testable solution that addresses many limitations of its predecessors.
## What is Riverpod?
Riverpod is a reactive caching framework for Flutter and Dart. It's a complete rewrite of the Provider package, designed to fix its limitations while providing a more robust development experience.
### Key Benefits
- **Compile-time safety**: Catches errors at compile time rather than runtime
- **No BuildContext dependency**: Providers can be read anywhere
- **Better testing**: Easy to mock and test
- **DevTools support**: Excellent debugging capabilities
## Basic Provider Types
Let's explore the fundamental provider types in Riverpod:
### 1. Provider
Used for read-only values that never change:
```dart
final configProvider = Provider<AppConfig>((ref) {
return AppConfig(
apiUrl: 'https://api.example.com',
timeout: Duration(seconds: 30),
);
});
```
### 2. StateProvider
For simple state that can be modified:
```dart
final counterProvider = StateProvider<int>((ref) => 0);
// Usage in widget
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Text('Increment'),
),
],
);
}
}
```
### 3. StateNotifierProvider
For more complex state management:
```dart
class TodoState {
final List<Todo> todos;
final bool isLoading;
TodoState({
required this.todos,
this.isLoading = false,
});
TodoState copyWith({
List<Todo>? todos,
bool? isLoading,
}) {
return TodoState(
todos: todos ?? this.todos,
isLoading: isLoading ?? this.isLoading,
);
}
}
class TodoNotifier extends StateNotifier<TodoState> {
TodoNotifier() : super(TodoState(todos: []));
Future<void> addTodo(String title) async {
state = state.copyWith(isLoading: true);
try {
final newTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
completed: false,
);
state = state.copyWith(
todos: [...state.todos, newTodo],
isLoading: false,
);
} catch (e) {
state = state.copyWith(isLoading: false);
// Handle error
}
}
void toggleTodo(String id) {
state = state.copyWith(
todos: state.todos.map((todo) {
return todo.id == id
? todo.copyWith(completed: !todo.completed)
: todo;
}).toList(),
);
}
}
final todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) {
return TodoNotifier();
});
```
## Advanced Patterns
### Family Providers
When you need to create providers with parameters:
```dart
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final repository = ref.read(userRepositoryProvider);
return repository.getUser(userId);
});
// Usage
final user = ref.watch(userProvider('user123'));
```
### Auto-dispose
For providers that should be disposed when no longer used:
```dart
final temporaryDataProvider = StateProvider.autoDispose<String?>((ref) {
// This provider will be disposed when no widget watches it
return null;
});
```
### Combining Providers
```dart
final filteredTodosProvider = Provider<List<Todo>>((ref) {
final todos = ref.watch(todoProvider).todos;
final filter = ref.watch(filterProvider);
switch (filter) {
case TodoFilter.completed:
return todos.where((todo) => todo.completed).toList();
case TodoFilter.pending:
return todos.where((todo) => !todo.completed).toList();
case TodoFilter.all:
default:
return todos;
}
});
```
## Best Practices
### 1. Use Proper Provider Types
Choose the right provider type for your use case:
- `Provider` for immutable data
- `StateProvider` for simple mutable state
- `StateNotifierProvider` for complex state with business logic
- `FutureProvider` for asynchronous operations
### 2. Keep Business Logic in Notifiers
```dart
// ✅ Good: Business logic in notifier
class CartNotifier extends StateNotifier<CartState> {
CartNotifier() : super(CartState.empty());
void addItem(Product product) {
if (state.items.length >= 10) {
throw CartFullException();
}
// Business logic here
final updatedItems = [...state.items, CartItem(product: product)];
state = state.copyWith(items: updatedItems);
}
}
// ❌ Bad: Business logic in widget
class CartWidget extends ConsumerWidget {
void addToCart(WidgetRef ref, Product product) {
final currentItems = ref.read(cartProvider).items;
if (currentItems.length >= 10) {
// Business logic should not be here
return;
}
ref.read(cartProvider.notifier).addRawItem(product);
}
}
```
### 3. Use Immutable State
Always use immutable state objects with copyWith methods:
```dart
@freezed
class AppState with _$AppState {
const factory AppState({
required User? user,
required bool isLoading,
required String? error,
}) = _AppState;
}
```
### 4. Error Handling
Implement proper error handling in your providers:
```dart
final userDataProvider = FutureProvider<UserData>((ref) async {
try {
final api = ref.read(apiServiceProvider);
return await api.fetchUserData();
} catch (e) {
// Log error
logger.error('Failed to fetch user data: $e');
rethrow;
}
});
```
## Testing with Riverpod
Riverpod makes testing straightforward:
```dart
void main() {
group('TodoNotifier', () {
test('should add todo', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(todoProvider.notifier);
await notifier.addTodo('Test todo');
final state = container.read(todoProvider);
expect(state.todos.length, 1);
expect(state.todos.first.title, 'Test todo');
});
test('should toggle todo completion', () {
final container = ProviderContainer();
addTearDown(container.dispose);
// Override provider for testing
container.read(todoProvider.notifier).state = TodoState(
todos: [Todo(id: '1', title: 'Test', completed: false)],
);
container.read(todoProvider.notifier).toggleTodo('1');
final state = container.read(todoProvider);
expect(state.todos.first.completed, true);
});
});
}
```
## Conclusion
Riverpod provides a robust foundation for state management in Flutter applications. Its type safety, testability, and developer experience make it an excellent choice for projects of any size.
The key to success with Riverpod is:
1. Understanding which provider type to use
2. Keeping business logic in notifiers
3. Using immutable state
4. Writing comprehensive tests
Start with simple providers and gradually adopt more advanced patterns as your application grows in complexity.
## Resources
- [Riverpod Documentation](https://riverpod.dev/)
- [Flutter State Management Guide](https://flutter.dev/docs/development/data-and-backend/state-mgmt)
- [Riverpod Examples Repository](https://github.com/rrousselGit/riverpod/tree/master/examples)