Riverpod - Gestion d'État
À quoi sert Riverpod ?
Riverpod est un système de gestion d'état pour Flutter qui permet de :
- Séparer la logique métier de l'UI : La logique reste indépendante des widgets
- Partager des données entre différentes parties de l'application
- Mettre en cache automatiquement les données pour éviter les recalculs inutiles
- Écrire du code testable : Les providers peuvent être mockés facilement
- Garantir la sécurité de type : Erreurs détectées à la compilation, pas à l'exécution
Comment fonctionne Riverpod ?
Concept central : les Providers
Un provider est une déclaration qui définit une source de données. Il y a plusieurs types :
1. StateProvider - Pour l'état simple
final compteurProvider = StateProvider<int>((ref) => 0);
2. FutureProvider - Pour les données asynchrones
final utilisateurProvider = FutureProvider<User>((ref) async {
final reponse = await http.get('/api/user');
return User.fromJson(reponse);
});
3. StateNotifierProvider - Pour la logique complexe
final compteurProvider = StateNotifierProvider<CompteurNotifier, int>((ref) {
return CompteurNotifier();
});
Flux de données Riverpod
Provider (Source de données)
↓
Widget utilise ConsumerWidget ou watch()
↓
Widget se met à jour quand les données changent
↓
Automatiquement rebuiplé (et mise en cache)
Exemple complet : Gestion de Tâches avec Base de Données Locale
Scénario : Application TODO avec SQLite
Cet exemple montre comment Riverpod sépare la logique (accès BD) de l'interface.
Partie 1 : Le modèle de données
Le modèle représente la structure d'une tâche. C'est une classe simple qui :
- Stocke les données (id, titre, status)
- Convertit depuis/vers la base de données (Map ↔ Objet)
- Est immuable : pas de modification directe, création d'une nouvelle instance si changement
// Fichier: models.dart
class Todo {
final int id;
final String titre;
final bool complete;
Todo({
required this.id,
required this.titre,
required this.complete,
});
factory Todo.fromMap(Map<String, dynamic> map) {
return Todo(
id: map['id'] as int,
titre: map['titre'] as String,
complete: (map['complete'] as int) == 1,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'titre': titre,
'complete': complete ? 1 : 0,
};
}
}
Partie 2 : Service d'accès à la Base de Données
Ce service encapsule toutes les opérations de base de données. Avantages :
- Centralisation : un seul endroit pour la logique BD
- Réutilisabilité : utilisable partout dans l'app
- Testabilité : facile de mocker en tests
- Singleton : une seule connexion BD
// Fichier: database_service.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'models.dart';
class DatabaseService {
static final DatabaseService _instance = DatabaseService._internal();
factory DatabaseService() {
return _instance;
}
DatabaseService._internal();
late Database _db;
Future<void> init() async {
final dbPath = await getDatabasesPath();
_db = await openDatabase(
join(dbPath, 'todos.db'),
onCreate: (db, version) {
return db.execute(
'''CREATE TABLE todos(
id INTEGER PRIMARY KEY AUTOINCREMENT,
titre TEXT NOT NULL,
complete INTEGER NOT NULL DEFAULT 0
)''',
);
},
version: 1,
);
}
Future<List<Todo>> getTodos() async {
final List<Map<String, dynamic>> maps = await _db.query('todos');
return List.generate(maps.length, (i) => Todo.fromMap(maps[i]));
}
Future<int> addTodo(String titre) async {
return await _db.insert(
'todos',
{'titre': titre, 'complete': 0},
);
}
Future<void> updateTodo(Todo todo) async {
await _db.update(
'todos',
todo.toMap(),
where: 'id = ?',
whereArgs: [todo.id],
);
}
Future<void> deleteTodo(int id) async {
await _db.delete(
'todos',
where: 'id = ?',
whereArgs: [id],
);
}
}
Partie 3 : Les Providers (Logique métier)
Les providers sont le cœur de Riverpod. Ils :
- Exposent les données : la liste des tâches, le statut de sync, etc.
- Gèrent la logique : créer, modifier, supprimer des tâches
- Mettent en cache : évitent les appels BD inutiles
- Notifient les widgets : quand les données changent
// Fichier: providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'models.dart';
import 'database_service.dart';
final dbProvider = Provider<DatabaseService>((ref) {
return DatabaseService();
});
final todosProvider = FutureProvider<List<Todo>>((ref) async {
final db = ref.watch(dbProvider);
return db.getTodos();
});
final todoActionsProvider =
StateNotifierProvider<TodoActions, AsyncValue<void>>((ref) {
return TodoActions(ref);
});
class TodoActions extends StateNotifier<AsyncValue<void>> {
TodoActions(this.ref) : super(const AsyncValue.data(null));
final Ref ref;
Future<void> addTodo(String titre) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final db = ref.read(dbProvider);
await db.addTodo(titre);
ref.refresh(todosProvider);
});
}
Future<void> toggleTodo(Todo todo) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final db = ref.read(dbProvider);
final updatedTodo = Todo(
id: todo.id,
titre: todo.titre,
complete: !todo.complete,
);
await db.updateTodo(updatedTodo);
ref.refresh(todosProvider);
});
}
Future<void> deleteTodo(int id) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final db = ref.read(dbProvider);
await db.deleteTodo(id);
ref.refresh(todosProvider);
});
}
}
Points clés à comprendre :
ref.watch(): Écouter un provider. Le widget se reconstruit quand ce provider change.ref.read(): Accéder sans écouter. Utilisé quand on ne veut pas se reconstruire.ref.refresh(): Invalider le cache et forcer un nouvel appel.AsyncValue.guard(): Wrapper qui gère automatiquement les erreurs et les états loading/error/data.
Partie 4 : L'interface utilisateur (UI)
L'UI est séparée de la logique. Elle :
- Affiche les données via
ref.watch() - Appelle des actions via
ref.read() - Se met à jour automatiquement quand les données changent
// Fichier: main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
import 'database_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await DatabaseService().init();
runApp(
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo App',
home: TodoListPage(),
);
}
}
class TodoListPage extends ConsumerWidget {
final _titleController = TextEditingController();
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todosProvider);
final todoActions = ref.read(todoActionsProvider.notifier);
return Scaffold(
appBar: AppBar(title: Text('Mes Tâches')),
body: Column(
children: [
Padding(
padding: EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _titleController,
decoration: InputDecoration(
hintText: 'Nouvelle tâche...',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
if (_titleController.text.isNotEmpty) {
todoActions.addTodo(_titleController.text);
_titleController.clear();
}
},
child: Text('Ajouter'),
),
],
),
),
Expanded(
child: todosAsync.when(
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Erreur: $err')),
data: (todos) {
if (todos.isEmpty) {
return Center(child: Text('Aucune tâche'));
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(
todo.titre,
style: TextStyle(
decoration: todo.complete
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
leading: Checkbox(
value: todo.complete,
onChanged: (_) => todoActions.toggleTodo(todo),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => todoActions.deleteTodo(todo.id),
),
);
},
);
},
),
),
],
),
);
}
}
Points clés :
ConsumerWidget: classe de base pour widgets utilisant Riverpodref.watch(): Récupère les données et se reabonne si elles changenttodosAsync.when(): Gère les 3 états loading/error/data en même tempsref.read(todoActionsProvider.notifier): Accéder au notifier pour appeler ses méthodes
Flux de données complet
Comment l'information circule :
1. BD (SQLite)
↓ (getTodos)
2. DatabaseService (encapsule l'accès BD)
↓ (dbProvider expose le service)
3. Providers (logique métier)
- todosProvider : récupère et cache la liste
- todoActionsProvider : gère les modifications
↓ (ref.watch/ref.read)
4. UI (TodoListPage)
↓ (update display)
5. Écran visible à l'utilisateur
Quand un utilisateur ajoute une tâche :
- Utilisateur clique bouton "Ajouter"
todoActions.addTodo(titre)est appelé- Le titre est inséré en BD
ref.refresh(todosProvider)vide le cache- Le widget reconstruit et appelle
todosProvider todosProviderrefaitgetTodos()depuis la BD- La liste s'affiche avec la nouvelle tâche
Séparation des responsabilités
| Couche | Responsabilité | Fichier | Impact si changement |
|---|---|---|---|
| Données | Accès SQLite, CRUD | database_service.dart | Juste modifier le service, UI inchangée |
| Logique | Gestion état, flux | providers.dart | Modifier les providers, UI peut rester pareille |
| Modèle | Structure données | models.dart | Impacte partout, mais centralisé |
| UI | Affichage, interaction | main.dart | Change juste l'affichage |
Exemple : Si vous remplacez SQLite par Firebase
- Modifier uniquement
DatabaseService(interface reste pareille) providers.dartn'a besoin d'aucun changementmain.dartn'a besoin d'aucun changement- L'app continue de fonctionner
Avantages de cette architecture avec Riverpod
- Changement facile : Remplacer SQLite par Firebase ? Modifiez juste
DatabaseService - Testable : Mock
DatabaseServiceou les providers dans les tests - Réutilisable : Plusieurs pages peuvent utiliser
todosProvider - Maintenance : Chaque fichier a une responsabilité unique
- Pas de prop drilling : Pas besoin de passer
todosProviderà travers 10 niveaux de widgets - Mise en cache automatique : Riverpod met en cache les résultats, évite les appels BD inutiles