Stéphane De Todaro — tech lead

@super-dev.app · Lead-technique
Actif depuis 2017

Lead technique et architecte full-stack, freelance depuis 2019. Je conçois, industrialise et exploite des plateformes métier sur Azure, et je publie des moteurs logiciels open source.

Retour aux articles
FLUTTER
FLUTTER

Une architecture Flutter testable avec Riverpod

Article 2 sur 3 — Flutter en production
Offline-first, Riverpod, monorepo Melos : du mobile sérieux, pas un proto.

Une app Flutter qui grossit finit par poser la même question : où vit l'état, et comment le tester sans démarrer un widget ? setState soude la logique à l'UI dans la même classe. InheritedWidget propage bien la donnée mais ne dit rien de sa création ni de son remplacement en test. Riverpod traite les deux problèmes d'un coup : c'est un conteneur d'injection de dépendances qui produit des valeurs réactives, déclarées hors de l'arbre de widgets et reliées entre elles par un graphe typé.

Trois familles de providers

Riverpod expose la donnée par des provider , et trois formes couvrent la quasi-totalité des besoins. Un Provider renvoie une valeur immuable (un client HTTP, un dépôt, une configuration). Un NotifierProvider associe un état mutable à la logique qui le fait évoluer. Un AsyncNotifierProvider fait la même chose quand l'état initial est asynchrone : un appel réseau, une lecture de base.

Avec la génération de code ( `riverpod_generator` ), on n'instancie aucun de ces types à la main. On annote une fonction ou une classe avec @riverpod , le générateur produit le provider, et le build() renvoie l'état de départ.

Dart
1// A plain value: the object other providers depend on.
2@riverpod
3TodoRepository todoRepository(Ref ref) {
4 final client = ref.watch(httpClientProvider);
5 return TodoRepository(client);
6}
7
8// Synchronous mutable state plus its logic.
9@riverpod
10class Counter extends _$Counter {
11 @override
12 int build() => 0;
13
14 void increment() => state++;
15}
16
17// Asynchronous state: build() returns a Future.
18@riverpod
19class TodoList extends _$TodoList {
20 @override
21 Future<List<Todo>> build() {
22 return ref.watch(todoRepositoryProvider).fetchAll();
23 }
24}

Chaque annotation génère un identifiant typé : todoRepositoryProvider , counterProvider , todoListProvider . C'est cet identifiant que le reste de l'app consomme, jamais la classe directement.

Le graphe de dépendances

Le ref.watch à l'intérieur d'un provider crée une dépendance . todoListProvider observe todoRepositoryProvider , qui observe httpClientProvider . Si l'un change, tout ce qui en dépend se recalcule, sans câblage manuel en cascade. C'est un graphe orienté, et comme chaque provider a un type de retour connu à la compilation, une erreur de branchement devient une erreur de compilation plutôt qu'un crash à l'exécution.

Côté lecture, ref se décline en trois usages qu'il faut distinguer. ref.watch s'abonne : sa place est dans un build() , de widget ou de provider, pour réagir au changement. ref.read lit une fois, sans abonnement : on l'utilise dans un gestionnaire d'événement, pour déclencher une méthode. ref.listen enregistre un effet de bord (afficher une erreur, naviguer) sans reconstruire.

Dart
1class CounterView extends ConsumerWidget {
2 const CounterView({super.key});
3
4 @override
5 Widget build(BuildContext context, WidgetRef ref) {
6 // watch: rebuilds this widget when the value changes.
7 final count = ref.watch(counterProvider);
8
9 return ElevatedButton(
10 // read: fires a method once, no subscription created here.
11 onPressed: () => ref.read(counterProvider.notifier).increment(),
12 child: Text('$count'),
13 );
14 }
15}

Watcher le provider donne la valeur ; watcher son .notifier donne l'objet qui porte les méthodes. Confondre les deux, par exemple watcher un notifier dans un callback, reste l'erreur la plus fréquente chez les nouveaux venus.

Lire l'asynchrone sans booléen baladeur

Un AsyncNotifierProvider ne renvoie pas un Future brut mais un AsyncValue , un type somme qui encode les trois états possibles : donnée, chargement, erreur. Le .when(data:, loading:, error:) force à traiter les trois, ce qui supprime le booléen isLoading qu'on oublie de remettre à false .

Pour muter un état asynchrone, la méthode du notifier passe par AsyncValue.guard , qui capture l'exception et la range dans un AsyncError au lieu de la laisser remonter.

Dart
1// A method on the TodoList notifier above.
2Future<void> add(Todo todo) async {
3 final repo = ref.read(todoRepositoryProvider);
4 state = const AsyncValue.loading();
5
6 // guard() turns a thrown exception into an AsyncError state.
7 state = await AsyncValue.guard(() async {
8 await repo.create(todo);
9 return repo.fetchAll();
10 });
11}

L'UI n'a rien à changer : elle observe toujours le même AsyncValue , et son .when affiche le spinner pendant le rechargement, puis la liste, puis un message si l'écriture a échoué.

Des variables globales qui n'en sont pas

Un provider se déclare en final au niveau du fichier, ce qui ressemble à une variable globale. La ressemblance s'arrête au premier coup d'œil. L'identifiant sert de clé ; l'état réel vit dans un ProviderContainer . Deux containers, deux tests ou deux fenêtres, ne partagent aucun état même s'ils lisent le même provider.

Avec la génération de code, @riverpod rend le provider autoDispose par défaut : dès que plus rien ne l'écoute, son état est détruit, puis recréé paresseusement à la prochaine lecture. Pour garder un état en vie, un cache ou une session, on l'annote @Riverpod(keepAlive: true) . Et ref.onDispose libère une ressource, par exemple fermer un socket, au moment du nettoyage.

L'override comme voie normale du test

C'est ici que Riverpod dépasse InheritedWidget : chaque provider est remplaçable au montage du container. En test, on injecte un faux dépôt sans toucher au code de production.

Dart
1test('loads the current todo list', () async {
2 final container = ProviderContainer(
3 overrides: [
4 todoRepositoryProvider.overrideWithValue(FakeTodoRepository()),
5 ],
6 );
7 addTearDown(container.dispose);
8
9 final todos = await container.read(todoListProvider.future);
10 expect(todos, hasLength(3));
11});

Pas de mock global, pas de singleton à réinitialiser entre deux cas : chaque ProviderContainer est isolé, et addTearDown garantit sa libération. Pour un test de widget, le même mécanisme passe par ProviderScope(overrides: [...]) au sommet de l'arbre. La documentation des tests Riverpod détaille les patterns de pump et d'écoute.

Découper une feature

Le graphe de providers donne un plan de découpe. Une feature s'organise en trois couches, et les providers sont les coutures qui les relient.

La couche donnée expose les dépôts par des Provider . La couche logique tient les notifiers, qui watch ent ces dépôts. La couche présentation ne contient que des ConsumerWidget qui watch ent les notifiers et appellent leurs méthodes. Un widget ne porte aucune logique métier : il lit et il délègue.

La direction reste à sens unique, de la présentation vers la logique vers la donnée, comme une architecture en couches classique, sauf que le câblage passe par ref.watch au lieu d'un constructeur. Le test suit la même frontière : on remplace la couche du dessous par un override, et on vérifie celle du dessus, isolément.

Une architecture Flutter se mesure à sa testabilité : la logique doit se vérifier sans monter un seul widget. Riverpod place l'état hors de l'arbre et fait de l'override un mécanisme de première classe, ce qui rend cette vérification possible par défaut.
Stéphane De Todaro — super-dev.app
// Continuer dans FLUTTER
Sync offline-first en Flutter + Firebase
20 févr. 2026 • 5 min
Monorepo Flutter avec Melos
30 oct. 2025 • 6 min