Développement Mobile

Première app Flutter avec Riverpod 3 pas à pas

17 دقائق للقراءة

Riverpod 3 a transformé la gestion d’état en Flutter. Plus besoin de jongler avec BuildContext, plus de duplication entre StateNotifier et ChangeNotifier, et désormais des mutations natives pour gérer les actions utilisateur (loading/error/success) et une persistance offline intégrée. Ce tutoriel construit une application complète from scratch — une liste de tâches persistée — en exploitant ces nouveautés. À la fin vous aurez un code de référence réutilisable pour vos vrais projets.

📖 Guide principal : Flutter 3 et Dart 3 pour le développement mobile cross-platform — pour comprendre où Riverpod s’inscrit dans l’architecture Flutter, lisez d’abord ce guide.

Prérequis

  • Flutter SDK 3.41+ et Dart 3.11+ installés (voir Installer Flutter SDK si nécessaire).
  • Connaissance de base de Dart et des widgets Flutter (StatelessWidget, MaterialApp, Scaffold).
  • VS Code ou Android Studio configuré avec l’extension Flutter.
  • Un émulateur Android ou un téléphone physique branché.
  • Niveau : intermédiaire. Temps estimé : 60 minutes.

Étape 1 — Créer le projet et ajouter Riverpod

On part d’un projet Flutter vierge. La structure scaffoldée par flutter create donne déjà tout ce qu’il faut pour démarrer : un main.dart, une configuration Material, un pubspec.yaml pour déclarer les dépendances. Notre travail consiste à remplacer le code par défaut et à ajouter Riverpod.

flutter create todo_riverpod
cd todo_riverpod
flutter pub add flutter_riverpod
flutter pub add sqflite path riverpod_sqflite

La commande flutter pub add insère automatiquement la dernière version stable dans pubspec.yaml. Au moment de cette rédaction, flutter_riverpod est en série 3.3 stable. Les paquets sqflite et path serviront à la persistance offline plus tard. Vérifiez en ouvrant pubspec.yaml :

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^3.3.0
  sqflite: ^2.4.2
  path: ^1.9.1
  riverpod_sqflite: ^0.4.2

Si une version est plus récente sur pub.dev, le caret ^ permettra à flutter pub upgrade de prendre la dernière compatible automatiquement. Lancez l’application telle quelle pour valider que la base compile :

flutter run

Vous devez voir l’application compteur par défaut. Coupez avec q dans le terminal.

Étape 2 — Activer ProviderScope

Tout l’écosystème Riverpod tourne autour d’un objet appelé ProviderContainer. Côté framework Flutter, ce container est injecté dans l’arbre des widgets via ProviderScope. C’est la seule modification structurelle obligatoire : sans elle, aucun provider n’est lisible depuis l’UI.

Ouvrez lib/main.dart et remplacez son contenu par :

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: TodoApp()));
}

class TodoApp extends StatelessWidget {
  const TodoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo Riverpod',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const TodoHomePage(),
    );
  }
}

class TodoHomePage extends StatelessWidget {
  const TodoHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Mes tâches')),
      body: const Center(child: Text('À venir')),
    );
  }
}

Le widget ProviderScope englobe toute l’application. Tout ce qui descend dans l’arbre — y compris les écrans poussés par le navigateur — partage le même container et donc les mêmes providers. Relancez l’application : vous voyez désormais un écran « Mes tâches » vide. C’est notre socle.

Étape 3 — Déclarer un premier provider et le consommer

Un provider en Riverpod est une fabrique de valeur paresseuse. La valeur n’est calculée qu’au premier read ou watch et reste en cache tant que le provider a au moins un écouteur. C’est le mécanisme qui remplace à la fois Singleton, Service Locator et State Management.

Créez un fichier lib/todo_model.dart pour le modèle métier :

class Todo {
  const Todo({required this.id, required this.title, this.done = false});

  final String id;
  final String title;
  final bool done;

  Todo copyWith({String? id, String? title, bool? done}) => Todo(
        id: id ?? this.id,
        title: title ?? this.title,
        done: done ?? this.done,
      );

  Map<String, Object?> toJson() =>
      {'id': id, 'title': title, 'done': done};

  factory Todo.fromJson(Map<String, Object?> json) => Todo(
        id: json['id'] as String,
        title: json['title'] as String,
        done: (json['done'] as bool?) ?? false,
      );
}

Puis créez lib/todos_provider.dart avec un NotifierProvider qui expose la liste et permet de la modifier :

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_model.dart';

class TodosNotifier extends Notifier<List<Todo>> {
  @override
  List<Todo> build() => const [];

  void add(String title) {
    final newTodo = Todo(
      id: DateTime.now().microsecondsSinceEpoch.toString(),
      title: title,
    );
    state = [...state, newTodo];
  }

  void toggle(String id) {
    state = [
      for (final t in state)
        if (t.id == id) t.copyWith(done: !t.done) else t,
    ];
  }

  void remove(String id) {
    state = state.where((t) => t.id != id).toList();
  }
}

final todosProvider =
    NotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);

Le pattern Notifier est l’évolution de StateNotifier des versions précédentes. Il est plus simple à écrire (pas de classe générique séparée pour l’état), plus testable (instanciable sans ProviderContainer) et complètement immuable. Chaque mutation produit une nouvelle liste, jamais une modification en place — Flutter peut alors comparer les références et ne reconstruire que ce qui a changé.

Étape 4 — Câbler l’UI avec ConsumerWidget

Pour qu’un widget puisse lire les providers, il doit étendre ConsumerWidget (ou utiliser Consumer en interne). La différence avec StatelessWidget est minime : un paramètre WidgetRef ref de plus dans build. C’est ce ref qui donne accès aux providers.

Mettez à jour lib/main.dart pour afficher la liste et y ajouter un bouton flottant :

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todos_provider.dart';

class TodoHomePage extends ConsumerWidget {
  const TodoHomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todos = ref.watch(todosProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Mes tâches')),
      body: todos.isEmpty
          ? const Center(child: Text('Aucune tâche — ajoutez-en une'))
          : ListView.separated(
              itemCount: todos.length,
              separatorBuilder: (_, __) => const Divider(height: 1),
              itemBuilder: (context, index) {
                final todo = todos[index];
                return CheckboxListTile(
                  value: todo.done,
                  title: Text(todo.title),
                  onChanged: (_) =>
                      ref.read(todosProvider.notifier).toggle(todo.id),
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
    final controller = TextEditingController();
    final result = await showDialog<String>(
      context: context,
      builder: (c) => AlertDialog(
        title: const Text('Nouvelle tâche'),
        content: TextField(
          controller: controller,
          autofocus: true,
          decoration: const InputDecoration(hintText: 'Que faire ?'),
        ),
        actions: [
          TextButton(
              onPressed: () => Navigator.pop(c), child: const Text('Annuler')),
          FilledButton(
              onPressed: () => Navigator.pop(c, controller.text.trim()),
              child: const Text('Ajouter')),
        ],
      ),
    );
    if (result != null && result.isNotEmpty) {
      ref.read(todosProvider.notifier).add(result);
    }
  }
}

Deux idiomes à retenir : ref.watch(provider) abonne le widget aux changements et déclenche un rebuild à chaque mutation ; ref.read(provider.notifier) récupère le contrôleur sans s’abonner, parfait pour appeler une méthode depuis un callback d’action. Mélanger les deux dans le mauvais sens (watch dans un callback, read dans un build) est l’erreur la plus fréquente quand on démarre.

Sauvegardez, le hot reload affiche immédiatement la nouvelle interface. Cliquez sur le bouton « + », ajoutez deux tâches, cochez-en une. Le state est en mémoire vive — il disparaît au prochain redémarrage.

Étape 5 — Ajouter une mutation pour les actions asynchrones

Quand un utilisateur clique sur « Ajouter », on veut souvent afficher un spinner pendant un appel réseau, gérer l’erreur s’il y a lieu, et montrer un retour visuel en cas de succès. Avant Riverpod 3, ce câblage demandait un StateNotifier dédié avec trois booléens. Désormais, les mutations font ça nativement.

Simulons un délai pour mimer un appel réseau. Modifiez la méthode add du notifier :

Future<void> add(String title) async {
  await Future.delayed(const Duration(milliseconds: 800));
  if (title.toLowerCase().contains('erreur')) {
    throw Exception('Mot interdit');
  }
  final newTodo = Todo(
    id: DateTime.now().microsecondsSinceEpoch.toString(),
    title: title,
  );
  state = [...state, newTodo];
}

Puis définissez une mutation et utilisez-la dans le widget :

final addTodoMutation = Mutation<void>();

// Dans _showAddDialog, remplacez l'appel direct par :
addTodoMutation.run(ref, () async {
  await ref.read(todosProvider.notifier).add(result);
});

Et dans le build, lisez l’état de la mutation pour adapter l’UI :

final addState = ref.watch(addTodoMutation);
final isAdding = addState is MutationPending;

return Scaffold(
  appBar: AppBar(
    title: const Text('Mes tâches'),
    bottom: isAdding
        ? const PreferredSize(
            preferredSize: Size.fromHeight(2),
            child: LinearProgressIndicator())
        : null,
  ),
  // ...
);

La mutation est un provider à part entière. Vous pouvez l’observer (ref.watch) pour réagir à ses transitions MutationIdle → MutationPending → MutationSuccess ou MutationError. Plus besoin d’écrire à la main un loading flag, l’API encapsule la logique. Testez en saisissant « erreur » dans la boîte de dialogue — vous verrez la barre de progression apparaître, puis disparaître quand l’exception remonte.

Étape 6 — Persister la liste avec offline persistence

Une todo-list qui disparaît à chaque redémarrage n’est pas utilisable. Riverpod 3 intègre une API persist() qui sauvegarde l’état d’un AsyncNotifier dans un stockage local (SQLite par défaut) et le restaure automatiquement au démarrage de l’application.

Nous devons convertir TodosNotifier en AsyncNotifier pour bénéficier de la persistance. Réécrivez todos_provider.dart :

import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_sqflite/riverpod_sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import 'todo_model.dart';

final storageProvider = FutureProvider<JsonSqFliteStorage>((ref) async {
  final path = p.join(await getDatabasesPath(), 'todos.db');
  return JsonSqFliteStorage.open(path);
});

class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async {
    persist(
      ref.watch(storageProvider.future),
      key: 'todos',
      encode: (todos) => jsonEncode(todos.map((t) => t.toJson()).toList()),
      decode: (raw) {
        final list = jsonDecode(raw) as List;
        return list
            .map((e) => Todo.fromJson(e as Map<String, Object?>))
            .toList();
      },
    );
    return const [];
  }

  Future<void> add(String title) async {
    await Future.delayed(const Duration(milliseconds: 300));
    final current = await future;
    state = AsyncData([
      ...current,
      Todo(
          id: DateTime.now().microsecondsSinceEpoch.toString(),
          title: title),
    ]);
  }

  Future<void> toggle(String id) async {
    final current = await future;
    state = AsyncData([
      for (final t in current)
        if (t.id == id) t.copyWith(done: !t.done) else t,
    ]);
  }
}

final todosProvider =
    AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);

Le call persist() dans build() branche le notifier au storage. Au premier lancement, il essaie de lire la clé todos ; à chaque mise à jour de state, il sérialise et écrit. Tout est automatique. Côté UI, ref.watch(todosProvider) retourne maintenant un AsyncValue<List<Todo>> qu’il faut décomposer avec un when ou un switch pattern.

Adaptez le widget :

final todosAsync = ref.watch(todosProvider);

return todosAsync.when(
  loading: () => const Center(child: CircularProgressIndicator()),
  error: (err, _) => Center(child: Text('Erreur : $err')),
  data: (todos) => todos.isEmpty
      ? const Center(child: Text('Aucune tâche'))
      : ListView.separated(/* ... */),
);

Relancez l’application avec flutter run, ajoutez trois tâches, fermez complètement l’application, relancez. Les tâches sont toujours là. C’est exactement ce qu’on attend.

Étape 7 — Vérification et bonnes pratiques

Plusieurs réflexes valent la peine d’être ancrés tôt. D’abord, organisez votre code en features — chaque dossier features/<nom>/ contient son modèle, son provider, ses widgets — plutôt qu’en couches techniques (models/, providers/, widgets/) qui éclatent la cohérence métier.

Ensuite, ne mettez jamais d’effet de bord dans build(). La méthode build() d’un Notifier peut être appelée plusieurs fois (auto-dispose, refresh). Tout ce qui doit s’exécuter une seule fois doit être déclenché à la demande depuis l’UI ou via ref.listen.

Enfin, profitez de ref.invalidate(todosProvider) pour forcer un rebuild complet d’un provider — utile dans les écrans de pull-to-refresh ou après une déconnexion utilisateur. Et ref.read(provider.notifier) dans un callback de bouton, jamais ref.watch.

Vérifiez avec flutter analyze que votre code passe le lint sans warning. Lancez flutter test pour vérifier que les tests unitaires générés par flutter create passent toujours après vos modifications.

Comprendre les types de providers Riverpod

Avant d’aller plus loin, il vaut la peine de poser le vocabulaire. Riverpod offre plusieurs types de providers, chacun adapté à un usage précis. Confondre les types est la deuxième source d’erreurs après l’oubli du ProviderScope.

Le Provider de base expose une valeur immuable calculée une fois et mise en cache : c’est le bon choix pour un service singleton (client HTTP partagé, configuration applicative, parsers). Le FutureProvider retourne un Future et expose son résultat sous forme d’AsyncValue consommable dans l’UI — parfait pour un appel API au démarrage qui ne change pas, par exemple charger la liste des pays disponibles. Le StreamProvider fait la même chose pour un Stream et se réabonne automatiquement à chaque rebuild — typique pour écouter Firestore ou un WebSocket.

Pour de l’état modifiable depuis l’UI, deux options : le NotifierProvider pour un état synchrone (la majorité des cas) et l’AsyncNotifierProvider pour un état initial asynchrone (charger depuis API ou base locale). Dans notre todo-list, on a démarré avec NotifierProvider pour aller vite, puis on a basculé vers AsyncNotifierProvider dès qu’on a voulu charger depuis SQLite — c’est la migration classique.

Le suffixe .family attaché à n’importe lequel de ces providers permet de paramétrer la création : todoByIdProvider(String id) crée une instance distincte par valeur d’id. Et le suffixe .autoDispose libère automatiquement le provider quand plus aucun widget ne l’écoute — indispensable pour les détails d’un produit qu’on ne veut pas garder en cache éternellement.

Génération de code optionnelle

Riverpod propose une variante avec code generation qui réduit la verbosité. Au lieu de déclarer manuellement la combinaison NotifierProvider<Notifier, T>, vous annotez une simple fonction ou classe avec @riverpod et build_runner génère le provider correspondant. C’est plus concis et le compilateur garantit la cohérence des types.

L’inconvénient : un build runner permanent (dart run build_runner watch) qui consomme de la RAM, et une marche de plus à monter pour un développeur qui débute. Si votre projet a moins de vingt providers, l’API manuelle reste largement lisible. Au-delà, la génération devient rentable.

Stratégie de tests pour les notifiers

L’un des arguments forts de Riverpod par rapport à Provider classique est la testabilité. Un Notifier peut être testé sans aucun widget, sans aucun BuildContext, juste avec un ProviderContainer instancié manuellement. Le test ressemble à du test unitaire Java ou TypeScript standard.

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_riverpod/todos_provider.dart';

void main() {
  test('ajoute une tâche au début vide', () async {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    await container.read(todosProvider.future);
    await container.read(todosProvider.notifier).add('Acheter du pain');
    final state = await container.read(todosProvider.future);

    expect(state.length, 1);
    expect(state.first.title, 'Acheter du pain');
  });
}

Le motif addTearDown(container.dispose) garantit que les ressources sont libérées même si le test échoue. C’est une bonne hygiène à systématiser. Pour bypasser la persistance dans un test, redéfinissez storageProvider avec un override qui retourne un storage en mémoire (vous écrivez votre propre classe qui implémente l’interface Storage de Riverpod, ou plus simple : créez un override qui ne persiste rien) :

// Option 1 : injecter une instance déjà ouverte sur ':memory:' (sqflite)
final memStorage = await JsonSqFliteStorage.open(':memory:');
final container = ProviderContainer(overrides: [
  storageProvider.overrideWith((_) => Future.value(memStorage)),
]);

// Option 2 : court-circuiter complètement la persistance en
// neutralisant le provider source de storage (pas d'écriture disque)
final container2 = ProviderContainer(overrides: [
  storageProvider.overrideWith((_) => throw UnimplementedError('storage off in tests')),
]);

Aucune dépendance à mocker manuellement, aucun magic frameworké — Riverpod laisse le pattern d’injection s’exprimer naturellement. C’est ce qui rend le code de production testable à 100 %.

Organisation du projet à grande échelle

Le code que nous venons d’écrire tient en quatre fichiers, ce qui est confortable pour un tutoriel mais ne représente pas la réalité d’un projet de production. Quand l’application grandit, l’organisation par feature plutôt que par couche technique évite que tout finisse dans deux dossiers monstrueux.

Une structure éprouvée ressemble à ceci :

lib/
  main.dart
  core/
    router.dart
    theme.dart
    storage.dart
  features/
    todos/
      todo_model.dart
      todos_provider.dart
      todos_screen.dart
      widgets/
        todo_tile.dart
        add_todo_dialog.dart
    auth/
      auth_provider.dart
      auth_screen.dart
  shared/
    widgets/
      empty_state.dart

Chaque feature regroupe son modèle, son provider, son écran et ses widgets internes. Le dossier core/ contient ce qui est transverse — routage, thème, storage. Le dossier shared/ récupère les widgets vraiment réutilisables entre features. Cette discipline rend le projet navigable même à dix mille lignes de code.

Pour les conventions de nommage des providers, deux écoles. Soit suffixer systématiquement par Provider (todosProvider, authProvider) — c’est verbeux mais sans ambiguïté. Soit ne pas suffixer (todos, auth) et compter sur l’autocomplétion de l’IDE. Choisissez l’une ou l’autre et tenez-vous-y dans tout le projet.

Erreurs fréquentes

Erreur Cause Solution
The provider ... has no scope Widget en dehors du ProviderScope. Vérifier que ProviderScope englobe bien MaterialApp dans main().
État qui ne se met pas à jour Modification en place de state sans réassignation. Toujours créer une nouvelle liste/objet : state = [...state, item], jamais state.add(item).
setState() called after dispose Mutation déclenchée alors que le widget n’existe plus. Vérifier ref.mounted avant d’appeler state = dans une suite asynchrone.
Provider qui se redéclenche en boucle ref.watch dans un provider qui dépend d’une valeur qui change à chaque rebuild. Utiliser ref.read pour les valeurs constantes, ref.listen pour les side-effects.
Build runner échoue avec code generation Cache obsolète. dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs

Tutoriels associés

Foire aux questions

Riverpod ou Provider classique ?

Pour un nouveau projet en 2026, Riverpod sans hésitation. Provider reste maintenu mais Riverpod offre auto-dispose, mutations natives et persistence offline qui changent le quotidien. La courbe d’apprentissage initiale est légèrement plus rude — quelques heures suffisent.

Faut-il utiliser la génération de code Riverpod ?

C’est optionnel. La syntaxe @riverpod + build_runner est plus concise pour les gros projets avec beaucoup de providers, mais ajoute une étape de build. Pour démarrer ou pour des projets moyens, l’API manuelle est suffisante et plus transparente.

Comment tester un Notifier ?

Instanciez un ProviderContainer dans le test, appelez container.read(provider.notifier).method(), puis vérifiez container.read(provider). Aucun widget nécessaire. C’est l’avantage principal de Riverpod sur les solutions liées au BuildContext.

Que vaut la persistance offline en pratique ?

Pour des listes de petite à moyenne taille (quelques centaines d’éléments), c’est largement suffisant et plus simple qu’une base relationnelle. Pour des dizaines de milliers de lignes, des index, des requêtes complexes, basculez sur Drift et utilisez Riverpod pour exposer ses Stream dans l’UI.

Comment gérer la navigation avec Riverpod ?

Riverpod ne s’occupe pas de la navigation. Combinez-le avec go_router (route déclarative officielle Flutter) qui expose un refreshListenable et s’intègre proprement avec un provider d’authentification.

Riverpod marche-t-il pour des applications très complexes ?

Oui. Des éditeurs comme Reflectly, Wonderous ou plusieurs néobanques tournent avec Riverpod en production sur des architectures à plusieurs dizaines d’écrans et centaines de providers. Le passage à l’échelle se gère par la discipline d’organisation (features, autodispose, families) plus que par le framework lui-même.

Que faire si plusieurs providers ont besoin de la même donnée ?

Vous extrayez cette donnée dans un provider parent et chaque consommateur fait ref.watch(parentProvider). Riverpod cache automatiquement la valeur — elle n’est calculée qu’une fois et partagée. C’est le pattern à privilégier sur la duplication ou le passage manuel par paramètre.

Ressources officielles

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité