Flutter & Riverpod: Modular State Management
Building complex Flutter applications requires a robust and scalable state management solution. As applications grow, maintaining a clean architecture, ensuring testability, and facilitating team collaboration become paramount. This article explores how to achieve modular state management in Flutter using Riverpod, a powerful and highly performant state management library.
The Need for Modular State Management
Modular state management focuses on organizing application logic into self-contained, independent units. This approach offers several significant advantages:
- Scalability: Easily add new features or modules without affecting existing ones.
- Maintainability: Code is easier to understand, debug, and update when logic is compartmentalized.
- Testability: Individual modules (providers, notifiers) can be tested in isolation, simplifying the testing process.
- Collaboration: Multiple developers can work on different modules simultaneously with fewer merge conflicts.
- Separation of Concerns: Clearly separates UI components from business logic and data.
Introducing Riverpod
Riverpod is a reactive caching and data-binding framework for Flutter, built by the creator of the popular provider package. It addresses many of the limitations of provider by offering compile-time safety, complete testability, and better dependency management. Riverpod’s design encourages a modular approach, making it an excellent choice for modern Flutter development.
Key Advantages of Riverpod:
- Compile-time safety: Catches errors early, reducing runtime exceptions.
- No
BuildContextdependency: Providers can be accessed and modified from anywhere, not just within the widget tree. - Provider scopes: Easily override providers for testing or different application environments.
- Decoupling: Widgets are completely decoupled from business logic.
- Strong typing: Less prone to type-related errors.
Core Concepts of Riverpod
To understand modularity with Riverpod, it's essential to grasp its fundamental building blocks:
1. Providers
Providers are the core of Riverpod. They encapsulate a piece of state or logic that can be consumed by widgets or other providers. Riverpod offers various types of providers for different use cases:
-
Provider<T>: For read-only values or objects that don't change over time (e.g., API clients, repositories).final apiServiceProvider = Provider((ref) => ApiService()); -
StateProvider<T>: For simple mutable states like booleans, strings, or numbers.final counterProvider = StateProvider<int>((ref) => 0); -
StateNotifierProvider<NotifierType, StateType>: For complex, mutable states that require custom business logic and expose methods to modify the state.class TodoListNotifier extends StateNotifier<List<Todo>> { TodoListNotifier() : super([]); void addTodo(Todo todo) => state = [...state, todo]; } final todoListProvider = StateNotifierProvider<TodoListNotifier, List<Todo>>((ref) { return TodoListNotifier(); }); -
FutureProvider<T>: For asynchronously loading single values, handling loading and error states automatically.final userProvider = FutureProvider<User>((ref) async { return await ref.watch(apiServiceProvider).fetchUser(); }); -
StreamProvider<T>: For listening to asynchronous streams of data.final tickerProvider = StreamProvider<int>((ref) { return Stream.periodic(const Duration(seconds: 1), (count) => count); });
2. ref Object
The ref object is available inside provider definitions and ConsumerWidgets/ConsumerStatefulWidgets. It allows you to:
ref.watch(): Listen to a provider's state and rebuild the widget/provider when it changes.ref.read(): Get a provider's state once without listening for changes (e.g., inside anonPressedcallback).ref.listen(): React to changes in a provider without rebuilding the widget.ref.dispose(): Clean up resources when a provider is no longer in use.
3. Consuming Providers
Widgets consume providers using ConsumerWidget, ConsumerStatefulWidget, or the Consumer widget directly.
import 'package:flutter_riverpod/flutter_riverpod.dart';
class MyCounterApp extends ConsumerWidget { // Use ConsumerWidget
@override
Widget build(BuildContext context, WidgetRef ref) { // Access WidgetRef here
final counter = ref.watch(counterProvider); // Watch the counterProvider
return Scaffold(
appBar: AppBar(title: const Text('Counter App')),
body: Center(
child: Text('Count: $counter'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state++, // Modify state
child: const Icon(Icons.add),
),
);
}
}
Implementing Modular State Management with Riverpod
A modular structure typically involves organizing your application into features or domains. Within each feature, you'll define providers, models, and UI components.
Recommended Folder Structure:
lib/
├── main.dart
├── app_bootstrap.dart (for global providers, services setup)
├── features/
│ ├── auth/
│ │ ├── domain/ (models, entities)
│ │ ├── data/ (repositories, data sources)
│ │ ├── presentation/ (widgets, screens)
│ │ └── providers/ (auth-related providers)
│ ├── products/
│ │ ├── domain/
│ │ ├── data/
│ │ ├── presentation/
│ │ └── providers/
│ └── ... (other features)
├── shared/
│ ├── models/ (cross-feature models)
│ ├── services/ (common services like HTTP client)
│ └── widgets/ (reusable widgets)
└── utils/ (helpers, constants)
Example 1: Simple Counter (StateProvider)
Let's create a counter module.
1. Define the Provider (features/counter/providers/counter_provider.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';
final counterProvider = StateProvider<int>((ref) => 0);
2. Consume in a Widget (features/counter/presentation/counter_screen.dart)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/counter/providers/counter_provider.dart';
class CounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('Modular Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Icon(Icons.add),
),
);
}
}
Example 2: Asynchronous Data Fetching (FutureProvider)
Let's create a user list module that fetches data from an API.
1. Define the Model (features/users/domain/user_model.dart)
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
}
2. Define the API Service (features/users/data/user_api_service.dart)
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/users/domain/user_model.dart';
class UserApiService {
Future<List<User>> fetchUsers() async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users'));
if (response.statusCode == 200) {
Iterable l = json.decode(response.body);
return List<User>.from(l.map((model) => User.fromJson(model)));
} else {
throw Exception('Failed to load users');
}
}
}
final userApiServiceProvider = Provider((ref) => UserApiService());
3. Define the Provider (features/users/providers/user_providers.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/users/data/user_api_service.dart';
import 'package:your_app/features/users/domain/user_model.dart';
final userListProvider = FutureProvider<List<User>>((ref) async {
final apiService = ref.watch(userApiServiceProvider);
return apiService.fetchUsers();
});
4. Consume in a Widget (features/users/presentation/user_list_screen.dart)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/users/providers/user_providers.dart';
class UserListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userListAsyncValue = ref.watch(userListProvider);
return Scaffold(
appBar: AppBar(title: const Text('Modular User List')),
body: userListAsyncValue.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}
Example 3: Complex State with StateNotifierProvider (Todo List)
For more complex state logic, such as managing a list of items with CRUD operations, StateNotifierProvider is ideal.
1. Define the Model (features/todos/domain/todo_model.dart)
class Todo {
final String id;
final String description;
bool completed;
Todo({required this.id, required this.description, this.completed = false});
Todo copyWith({String? id, String? description, bool? completed}) {
return Todo(
id: id ?? this.id,
description: description ?? this.description,
completed: completed ?? this.completed,
);
}
}
2. Define the StateNotifier (features/todos/data/todo_list_notifier.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/todos/domain/todo_model.dart';
class TodoListNotifier extends StateNotifier<List<Todo>> {
TodoListNotifier() : super([]);
void addTodo(String description) {
state = [...state, Todo(id: DateTime.now().toIso8601String(), description: description)];
}
void toggleTodo(String id) {
state = [
for (final todo in state)
if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo,
];
}
void removeTodo(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
3. Define the Provider (features/todos/providers/todo_providers.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/todos/data/todo_list_notifier.dart';
import 'package:your_app/features/todos/domain/todo_model.dart';
final todoListProvider = StateNotifierProvider<TodoListNotifier, List<Todo>>((ref) {
return TodoListNotifier();
});
4. Consume in a Widget (features/todos/presentation/todo_list_screen.dart)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/features/todos/providers/todo_providers.dart';
class TodoListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todoListProvider);
final notifier = ref.read(todoListProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Modular Todo List')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: () => notifier.addTodo('New Task ${todos.length + 1}'),
child: const Text('Add Todo'),
),
),
Expanded(
child: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(
todo.description,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
),
),
leading: Checkbox(
value: todo.completed,
onChanged: (_) => notifier.toggleTodo(todo.id),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => notifier.removeTodo(todo.id),
),
);
},
),
),
],
),
);
}
}
Benefits of this Modular Approach
- Clear Separation: Each module (e.g.,
features/users) contains its own models, data logic, and UI components, making boundaries explicit. - High Cohesion, Low Coupling: Providers within a module are highly cohesive, serving a specific purpose, while coupling between modules is minimized.
- Improved Testability: Providers (e.g.,
userListProvider,TodoListNotifier) can be easily mocked or overridden for unit testing. The UI widgets just consume them. - Scalability: Adding a new feature means creating a new folder and defining its internal structure without impacting other parts of the application significantly.
- Readability: Developers can quickly navigate to the relevant code for a specific feature.
Best Practices
- Keep Providers Small and Focused: Each provider should ideally manage a single piece of state or expose a single service.
- Name Providers Descriptively: Use clear names that indicate what the provider holds (e.g.,
userListProvider,authServiceProvider). - Use
familyfor Parameterized Providers: If a provider needs a parameter to create its state (e.g., fetching a user by ID), use.family. - Consider
autoDispose: For providers whose state is no longer needed after a certain point, use.autoDisposeto free up resources. - Group Related Providers: Keep providers related to a specific feature in their own
providerssub-directory within that feature.
Conclusion
Riverpod, when combined with a thoughtful modular architecture, provides an incredibly powerful and efficient way to manage state in Flutter applications. It promotes clean code, enhances testability, and ensures that your application remains scalable and maintainable as it grows. By adopting this modular approach with Riverpod, developers can build robust Flutter applications that are a joy to work with, both individually and in team environments.