Aller au contenu principal

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 Riverpod
  • ref.watch() : Récupère les données et se reabonne si elles changent
  • todosAsync.when() : Gère les 3 états loading/error/data en même temps
  • ref.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 :

  1. Utilisateur clique bouton "Ajouter"
  2. todoActions.addTodo(titre) est appelé
  3. Le titre est inséré en BD
  4. ref.refresh(todosProvider) vide le cache
  5. Le widget reconstruit et appelle todosProvider
  6. todosProvider refait getTodos() depuis la BD
  7. La liste s'affiche avec la nouvelle tâche

Séparation des responsabilités

CoucheResponsabilitéFichierImpact si changement
DonnéesAccès SQLite, CRUDdatabase_service.dartJuste modifier le service, UI inchangée
LogiqueGestion état, fluxproviders.dartModifier les providers, UI peut rester pareille
ModèleStructure donnéesmodels.dartImpacte partout, mais centralisé
UIAffichage, interactionmain.dartChange juste l'affichage

Exemple : Si vous remplacez SQLite par Firebase

  • Modifier uniquement DatabaseService (interface reste pareille)
  • providers.dart n'a besoin d'aucun changement
  • main.dart n'a besoin d'aucun changement
  • L'app continue de fonctionner

Avantages de cette architecture avec Riverpod

  1. Changement facile : Remplacer SQLite par Firebase ? Modifiez juste DatabaseService
  2. Testable : Mock DatabaseService ou les providers dans les tests
  3. Réutilisable : Plusieurs pages peuvent utiliser todosProvider
  4. Maintenance : Chaque fichier a une responsabilité unique
  5. Pas de prop drilling : Pas besoin de passer todosProvider à travers 10 niveaux de widgets
  6. Mise en cache automatique : Riverpod met en cache les résultats, évite les appels BD inutiles