Flutter State Management with Riverpod: A Comprehensive Guide
•9 min read
Riverpod is a powerful state management solution for Flutter that provides compile-time safety, dependency injection, and testability. This guide covers everything you need to know about using Riverpod effectively in your Flutter applications.
1. Getting Started with Riverpod
Basic Setup
// pubspec.yaml dependencies: flutter_riverpod: ^2.4.0 riverpod_annotation: ^2.3.0 dev_dependencies: riverpod_generator: ^2.3.0 build_runner: ^2.4.0 // main.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp( ProviderScope( child: MyApp(), ), ); }
Simple Providers
// Basic provider final counterProvider = StateProvider<int>((ref) => 0); // Provider usage class CounterWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider); return Text('Count: $count'); } } // Modifying state class CounterButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ElevatedButton( onPressed: () => ref.read(counterProvider.notifier).state++, child: Text('Increment'), ); } }
2. Advanced Provider Types
StateNotifier Provider
// State class @immutable class TodoState { final List<Todo> todos; final bool isLoading; const TodoState({ required this.todos, this.isLoading = false, }); TodoState copyWith({ List<Todo>? todos, bool? isLoading, }) { return TodoState( todos: todos ?? this.todos, isLoading: isLoading ?? this.isLoading, ); } } // StateNotifier class class TodoNotifier extends StateNotifier<TodoState> { TodoNotifier() : super(TodoState(todos: [])); Future<void> fetchTodos() async { state = state.copyWith(isLoading: true); try { final todos = await TodoRepository().fetchTodos(); state = state.copyWith( todos: todos, isLoading: false, ); } catch (e) { state = state.copyWith(isLoading: false); throw e; } } void addTodo(Todo todo) { state = state.copyWith( todos: [...state.todos, todo], ); } void removeTodo(String id) { state = state.copyWith( todos: state.todos.where((todo) => todo.id != id).toList(), ); } } // Provider definition final todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) { return TodoNotifier(); }); // Usage in widget class TodoList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final todoState = ref.watch(todoProvider); if (todoState.isLoading) { return CircularProgressIndicator(); } return ListView.builder( itemCount: todoState.todos.length, itemBuilder: (context, index) { final todo = todoState.todos[index]; return ListTile( title: Text(todo.title), trailing: IconButton( icon: Icon(Icons.delete), onPressed: () => ref.read(todoProvider.notifier).removeTodo(todo.id), ), ); }, ); } }
Future Provider
@riverpod Future<List<User>> users(UsersRef ref) async { final repository = ref.watch(repositoryProvider); return repository.fetchUsers(); } // Usage with AsyncValue class UserList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final usersAsync = ref.watch(usersProvider); return usersAsync.when( data: (users) => ListView.builder( itemCount: users.length, itemBuilder: (context, index) => UserTile(user: users[index]), ), loading: () => CircularProgressIndicator(), error: (error, stack) => ErrorWidget(error.toString()), ); } }
Stream Provider
@riverpod Stream<List<Message>> messages(MessagesRef ref) { final repository = ref.watch(repositoryProvider); return repository.watchMessages(); } // Usage class MessageList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messagesAsync = ref.watch(messagesProvider); return messagesAsync.when( data: (messages) => ListView.builder( itemCount: messages.length, itemBuilder: (context, index) => MessageTile(message: messages[index]), ), loading: () => CircularProgressIndicator(), error: (error, stack) => ErrorWidget(error.toString()), ); } }
3. Dependency Injection
Service Providers
// API client provider @riverpod HttpClient apiClient(ApiClientRef ref) { return HttpClient( baseUrl: 'https://api.example.com', timeout: Duration(seconds: 30), ); } // Repository provider @riverpod UserRepository userRepository(UserRepositoryRef ref) { final client = ref.watch(apiClientProvider); return UserRepository(client); } // Service provider @riverpod AuthService authService(AuthServiceRef ref) { final repository = ref.watch(userRepositoryProvider); return AuthService(repository); }
Scoped Providers
@riverpod class ThemeNotifier extends _$ThemeNotifier { @override ThemeMode build() => ThemeMode.system; void toggleTheme() { state = state == ThemeMode.light ? ThemeMode.dark : ThemeMode.light; } } // Usage with override void main() { runApp( ProviderScope( overrides: [ themeProvider.overrideWith((ref) => ThemeMode.dark), ], child: MyApp(), ), ); }
4. State Management Patterns
Repository Pattern
// Repository interface abstract class Repository<T> { Future<List<T>> getAll(); Future<T> getById(String id); Future<void> create(T item); Future<void> update(T item); Future<void> delete(String id); } // Implementation class UserRepository implements Repository<User> { final HttpClient _client; UserRepository(this._client); @override Future<List<User>> getAll() async { final response = await _client.get('/users'); return response.map((json) => User.fromJson(json)).toList(); } // Other implementations... } // Provider @riverpod UserRepository userRepository(UserRepositoryRef ref) { final client = ref.watch(apiClientProvider); return UserRepository(client); }
Service Pattern
class AuthService { final UserRepository _repository; AuthService(this._repository); Future<User> login(String email, String password) async { try { final user = await _repository.login(email, password); return user; } catch (e) { throw AuthException('Login failed'); } } } // Provider @riverpod class AuthNotifier extends _$AuthNotifier { @override FutureOr<User?> build() => null; Future<void> login(String email, String password) async { state = const AsyncValue.loading(); try { final service = ref.read(authServiceProvider); final user = await service.login(email, password); state = AsyncValue.data(user); } catch (e, stack) { state = AsyncValue.error(e, stack); } } }
5. Testing with Riverpod
Provider Tests
void main() { test('Counter increments', () { final container = ProviderContainer(); addTearDown(container.dispose); expect(container.read(counterProvider), 0); container.read(counterProvider.notifier).state++; expect(container.read(counterProvider), 1); }); test('TodoNotifier adds todo', () { final container = ProviderContainer(); addTearDown(container.dispose); final notifier = container.read(todoProvider.notifier); final todo = Todo(id: '1', title: 'Test'); notifier.addTodo(todo); expect(container.read(todoProvider).todos, contains(todo)); }); }
Mock Providers
class MockUserRepository implements UserRepository { @override Future<List<User>> getAll() async => [ User(id: '1', name: 'Test User'), ]; } void main() { test('UserService with mock repository', () { final container = ProviderContainer( overrides: [ userRepositoryProvider.overrideWithValue(MockUserRepository()), ], ); addTearDown(container.dispose); expect( container.read(userServiceProvider).getUsers(), completion(isA<List<User>>()), ); }); }
Best Practices
- Provider Organization
// Organize providers by feature class UserProviders { static final userProvider = StateNotifierProvider<UserNotifier, UserState>((ref) { return UserNotifier(ref.watch(userRepositoryProvider)); }); static final userListProvider = FutureProvider<List<User>>((ref) { return ref.watch(userRepositoryProvider).getUsers(); }); }
- Error Handling
@riverpod class ApiNotifier extends _$ApiNotifier { @override FutureOr<T> build<T>(Future<T> Function() apiCall) async { try { return await apiCall(); } catch (e) { if (e is NetworkException) { // Handle network errors } rethrow; } } }
- State Immutability
@immutable class AppState { final User? user; final ThemeMode themeMode; final List<Todo> todos; const AppState({ this.user, required this.themeMode, required this.todos, }); AppState copyWith({ User? user, ThemeMode? themeMode, List<Todo>? todos, }) { return AppState( user: user ?? this.user, themeMode: themeMode ?? this.themeMode, todos: todos ?? this.todos, ); } }
Conclusion
Effective state management with Riverpod requires:
-
Provider Organization
- Group related providers
- Use appropriate provider types
- Maintain clear dependencies
-
State Design
- Immutable state objects
- Clear state updates
- Proper error handling
-
Testing
- Unit test providers
- Mock dependencies
- Test error cases
-
Performance
- Minimize rebuilds
- Use select for granular updates
- Cache expensive computations
Remember to:
- Keep providers focused and single-purpose
- Handle errors appropriately
- Test thoroughly
- Document provider dependencies
- Use code generation for boilerplate reduction
By following these practices, you can create maintainable and scalable Flutter applications with Riverpod state management.