Chapitre 06 · Semaine 2

Riverpod : les fondamentaux

Providers, ref.watch / read / listen, AsyncValue, Notifier et AsyncNotifier, cycle de vie et nouveautés de Riverpod 3. C'est le socle de toute question « state management » en entretien senior, et le point où un passé GetX doit se transformer en argument plutôt qu'en handicap.

Durée 3 séances de 2 h Niveau fondamental à intermédiaire Prérequis chapitres 01 à 03

Objectifs

  • Expliquer en 2 minutes ce que résout un state management et pourquoi Riverpod plutôt qu'un service locator.
  • Choisir le bon type de provider et justifier watch, read ou listen à chaque endroit.
  • Écrire sans IA un AsyncNotifier avec chargement, erreur, refresh et mutation.
  • Traduire chaque réflexe GetX (Obx, GetxController, Get.find) en son équivalent Riverpod.
  • Citer les nouveautés de Riverpod 3 et leurs effets concrets (retry, Ref.mounted, pause, ProviderContainer.test).

Le problème que résout un state management

Flutter sait reconstruire un widget quand son état change : c'est setState. Le problème commence quand une donnée est utile à plusieurs écrans éloignés dans le widget tree (l'arbre de widgets). Le solde du wallet s'affiche sur l'accueil, conditionne le bouton de transfert et doit être rafraîchi après un paiement. Le faire remonter de parent en parent par les constructeurs (prop drilling) devient vite intenable.

Un state management répond à quatre questions :

  • Où vit l'état ? Hors des widgets, pour survivre aux reconstructions et être partagé.
  • Qui est notifié quand il change ? Uniquement les widgets qui en dépendent.
  • Comment le modifier ? Par des méthodes explicites, pas en écrivant n'importe où.
  • Comment le remplacer en test ? Sans toucher au code de production.

État éphémère et état applicatif

La documentation Flutter distingue l'ephemeral state (état local à un widget : onglet sélectionné, animation en cours, texte d'un champ) et l'app state (état partagé : session, solde, panier, préférences). Le premier reste dans un StatefulWidget. Le second va dans un state management. La frontière n'est pas dogmatique : un état local qui doit soudain être lu ailleurs migre.

Piège d'entretien

« Mettez-vous tout dans Riverpod ? » Répondre oui est une erreur. Un TextEditingController, un AnimationController ou l'index d'un TabBar local restent dans un State. Mettre de l'état éphémère dans un provider global crée du couplage et des fuites. La bonne réponse : « l'état applicatif dans des providers, l'état purement visuel et local dans le widget ».

Pourquoi Riverpod

Riverpod a été écrit par l'auteur de provider pour corriger ses limites. Ses arguments tiennent en quatre points, à savoir formuler en entretien.

  • Compile-safe. Un provider est une variable globale finale et immuable : une déclaration, pas un état. Si vous le lisez, il existe forcément. Pas de ProviderNotFoundException à l'exécution comme avec provider, pas de Get.find qui échoue parce qu'un Get.put n'a pas eu lieu.
  • Pas de dépendance au BuildContext. Un provider peut en lire un autre via ref, sans widget. La logique métier ne dépend pas de l'arbre de widgets.
  • Testabilité. Chaque provider peut être remplacé (override) dans un ProviderScope ou un ProviderContainer, sans singleton à réinitialiser.
  • Un graphe de dépendances. Quand un provider en watch un autre, Riverpod connaît l'arête. Si la source change, les dépendants sont recalculés. Si plus personne n'écoute, l'état peut être libéré (autoDispose).
dioProvider ──▶ walletRepositoryProvider ──▶ walletProvider ──▶ HomeScreen (ref.watch) │ └──▶ BalanceLabel (select) └──▶ recentTransactionsProvider ──▶ HomeScreen invalidate(walletProvider) ⇒ seuls walletProvider et ses lecteurs sont recalculés
À dire en entretien

« Je vois Riverpod comme un graphe de dépendances déclaratif. Chaque provider décrit comment obtenir une valeur et de quoi elle dépend. Le framework se charge du cache, de l'invalidation et de la libération. Le gain principal pour une équipe, c'est la testabilité : je remplace un repository par un fake avec un override, sans singleton ni setup global. »

ProviderScope et les widgets consommateurs

Les états des providers vivent dans un ProviderContainer. En Flutter, ProviderScope crée ce container et doit envelopper l'application. Sans lui, la première lecture lève une erreur.

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

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

Trois façons d'obtenir un WidgetRef dans un widget :

WidgetUsageÉquivalent mental
ConsumerWidgetCas par défaut : build(context, ref)StatelessWidget + ref
ConsumerStatefulWidget + ConsumerStateBesoin d'un State (controllers, initState), ref est un champStatefulWidget + ref
ConsumerLimiter la reconstruction à un sous-arbre, ou lire un provider dans un widget non RiverpodUn Builder avec ref
class ClockConsumer extends StatelessWidget {
  const ClockConsumer({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      const Text('Statique, jamais reconstruit'),
      Consumer(
        builder: (context, ref, child) {
          // Seul ce builder est reconstruit à chaque seconde.
          final now = ref.watch(clockProvider).value;
          return Text(now?.toIso8601String() ?? '');
        },
      ),
    ]);
  }
}
À retenir

Ref (dans les providers) et WidgetRef (dans les widgets) sont deux types distincts. Depuis Riverpod 3, il n'existe plus qu'un seul Ref : les anciens FutureProviderRef, AutoDisposeRef ou ExampleRef générés ont disparu.

Les types de providers

Riverpod 3 recommande cinq types, plus leurs variantes .family et .autoDispose. StateProvider, StateNotifierProvider et ChangeNotifierProvider sont legacy : ils ont été déplacés dans package:flutter_riverpod/legacy.dart et ne doivent plus apparaître dans du nouveau code.

ProviderExposeCas typique
ProviderUne valeur synchrone, recalculée si ses dépendances changentRepository, client Dio, valeur dérivée
FutureProviderAsyncValue d'un FutureLecture HTTP sans mutation
StreamProviderAsyncValue d'un StreamWebSocket, Firestore, requête drift observée
NotifierProviderUn état synchrone + des méthodesFiltre, préférences, formulaire
AsyncNotifierProviderUn état asynchrone + des méthodesListe chargée puis modifiée, pagination

Il existe aussi StreamNotifierProvider, moins courant. Règle simple : lecture seule, Provider / FutureProvider / StreamProvider ; état modifiable par l'UI, un Notifier.

Deux syntaxes : manuelle et codegen

Chaque provider peut s'écrire à la main ou être généré par riverpod_generator (annotation @riverpod, outil build_runner). Les deux sont officiellement supportées.

final walletRepositoryProvider = Provider<WalletRepository>((ref) {
  return HttpWalletRepository(ref.watch(dioProvider));
});

final walletProvider = FutureProvider.autoDispose<Wallet>((ref) {
  final repo = ref.watch(walletRepositoryProvider);
  return repo.fetchWallet();
});

final clockProvider = StreamProvider.autoDispose<DateTime>((ref) {
  return Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now());
});
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'wallet_providers.g.dart';

@Riverpod(keepAlive: true)
WalletRepository walletRepository(Ref ref) {
  return HttpWalletRepository(ref.watch(dioProvider));
}

// Génère walletProvider, autoDispose par défaut.
@riverpod
Future<Wallet> wallet(Ref ref) {
  return ref.watch(walletRepositoryProvider).fetchWallet();
}

Le générateur déduit le type de provider de la signature : une fonction qui renvoie un Future donne un équivalent de FutureProvider, une classe qui étend _$Nom donne un Notifier. Le nom du provider est le nom de la fonction ou de la classe suivi de Provider. Détail vérifié avec riverpod_generator 4 : pour une classe nommée TodoNotifier, le suffixe est retiré et le provider s'appelle todoProvider.

Quel style recommander

Pour une équipe qui démarre en 2026, je recommande le codegen : syntaxe unique pour tous les types, paramètres de family nommés et multiples, autoDispose par défaut (le bon défaut), et les lints de scoping de riverpod_lint. Le coût est réel : build_runner à faire tourner et des fichiers .g.dart en revue de code. Ma recommandation suppose que le projet génère déjà du code (freezed, json_serializable) : la documentation Riverpod elle-même estime qu'adopter build_runner uniquement pour Riverpod ne vaut probablement pas la peine, la génération restant lente. Sur un petit module ou une équipe qui refuse la génération, la syntaxe manuelle est tout à fait acceptable. Ce chapitre montre surtout la syntaxe manuelle, parce qu'elle rend visibles les types que le codegen cache, ce qu'on vous demandera d'expliquer.

ref.watch, ref.read, ref.listen

C'est la question la plus fréquente en entretien Riverpod. Chaque méthode a un rôle précis.

MéthodeEffetOù l'appeler
ref.watch(p)Lit la valeur et s'abonne : le widget ou le provider est reconstruit quand p changeDans build d'un widget, dans le corps d'un provider ou le build d'un Notifier
ref.read(p)Lit une fois, sans abonnementDans les callbacks (onPressed) et les méthodes d'un Notifier
ref.listen(p, cb)Appelle cb(previous, next) à chaque changement, sans reconstruireDans build pour les effets de bord (SnackBar, navigation), ou dans un provider
@override
Widget build(BuildContext context, WidgetRef ref) {
  // Lu une seule fois : l'écran ne se mettra jamais à jour.
  final visible = ref.read(balanceVisibleProvider);
  return Text(visible ? 'Solde visible' : 'Masqué');
}
class BalanceLabel extends ConsumerWidget {
  const BalanceLabel({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final visible = ref.watch(balanceVisibleProvider);
    final balance = ref.watch(walletProvider.select((w) => w.value?.balance));
    return GestureDetector(
      // Dans un callback : read, jamais watch.
      onTap: () => ref.read(balanceVisibleProvider.notifier).toggle(),
      child: Text(visible ? '${balance ?? '-'}' : '••••'),
    );
  }
}

Les erreurs typiques :

  • read dans build pour « optimiser ». La documentation est explicite : n'utilisez pas ref.read pour éviter ref.watch. Si vous voulez moins de reconstructions, utilisez select.
  • watch dans un callback ou après un await. Un abonnement n'a de sens que pendant un build : dans un callback, le résultat est imprévisible et le code ne passera pas une revue.
  • listen pour afficher une donnée. listen sert aux effets de bord, pas à produire l'UI.
  • listen dans initState. Interdit : utilisez ref.listenManual, qui renvoie une subscription à fermer si besoin.
@override
Widget build(BuildContext context) {
  ref.listen(beneficiariesProvider, (previous, next) {
    if (next case AsyncError(:final error)) {
      ScaffoldMessenger.of(context)
          .showSnackBar(SnackBar(content: Text('Échec : $error')));
    }
  });
  // ... reste du build
}
Piège d'entretien

« Pourquoi pas toujours ref.read, c'est plus performant ? » Non. read ne s'abonne pas : l'UI affichera une valeur périmée. La performance s'obtient avec select, en découpant les widgets, ou avec un Consumer plus bas dans l'arbre. read n'a sa place que là où un abonnement n'a pas de sens : un callback ou une méthode de Notifier.

AsyncValue

FutureProvider, StreamProvider et AsyncNotifier exposent un AsyncValue<T>. En Riverpod 3, c'est une sealed class à trois sous-types : AsyncLoading, AsyncData, AsyncError. Le switch Dart 3 est donc exhaustif : le compilateur vous signale le cas oublié.

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final wallet = ref.watch(walletProvider);
    return RefreshIndicator(
      // refresh renvoie le Future du nouveau calcul : l'indicateur attend la fin.
      onRefresh: () => ref.refresh(walletProvider.future),
      child: ListView(
        children: [
          switch (wallet) {
            AsyncData(:final value) => BalanceCard(wallet: value),
            AsyncError(:final error) => ErrorView(
                error: error,
                onRetry: () => ref.invalidate(walletProvider),
              ),
            AsyncLoading() => const Center(child: CircularProgressIndicator()),
          },
          if (wallet.isRefreshing) const LinearProgressIndicator(),
        ],
      ),
    );
  }
}

La méthode when reste disponible et donne le même résultat, avec des options skipLoadingOnRefresh (vrai par défaut) et skipLoadingOnReload (faux par défaut). Le switch a l'avantage d'être du Dart standard, lisible par quelqu'un qui ne connaît pas Riverpod.

Un AsyncValue porte plusieurs informations à la fois

Un AsyncValue n'est pas un simple trois-états. Pendant un rafraîchissement, l'état conserve la valeur précédente et signale un chargement. Comportement vérifié sur flutter_riverpod 3.4.3 :

SituationType à l'exécutionvalueisLoading
Premier chargementAsyncLoadingnullvrai
ref.invalidate / refresh avec des données déjà làAsyncData (isRefreshing vrai)ancienne valeurvrai
Notifier qui fait state = const AsyncLoading()AsyncLoading (isReloading vrai)ancienne valeurvrai
Retry automatique en cours après une erreurAsyncLoading avec hasError vraiéventuelle valeur précédentevrai

Conséquence pratique : dans le switch ci-dessus, un pull-to-refresh garde la carte du solde affichée (branche AsyncData) et la barre fine signale le chargement. C'est le comportement attendu par un utilisateur.

value, requireValue, hasValue

  • value : la donnée ou null, y compris pendant un chargement ou une erreur si une donnée précédente existe. En Riverpod 3, valueOrNull a été renommé value (l'ancien value qui relançait l'erreur a disparu).
  • requireValue : la donnée, ou une exception s'il n'y en a pas. À réserver aux endroits où vous avez la garantie qu'elle existe. Depuis 3.1, il sert aussi à combiner de façon synchrone des providers asynchrones dans un FutureProvider ou un AsyncNotifier.
  • hasValue, hasError, isLoading, isRefreshing, isReloading : des drapeaux indépendants, pas exclusifs.
Piège d'entretien

« Votre écran affiche un spinner plein écran à chaque pull-to-refresh, pourquoi ? » Souvent parce que le code teste isLoading en premier (if (state.isLoading) return Spinner()). isLoading est vrai pendant un refresh alors que la donnée est là. Tester le type (AsyncData d'abord) ou hasValue, et réserver isRefreshing à un indicateur discret.

Notifier et AsyncNotifier

Un Notifier est une classe qui possède un état et expose des méthodes pour le modifier. C'est l'équivalent conceptuel d'un view model. Trois règles :

  • build() calcule l'état initial. C'est le seul endroit où l'on watch d'autres providers.
  • state est l'état courant. L'affecter notifie les écouteurs (si la nouvelle valeur est différente selon ==, nouveauté de la 3.0 pour tous les providers).
  • Les méthodes publiques sont les seules façons de modifier l'état depuis l'extérieur. L'état doit être immuable : on remplace, on ne modifie pas en place.
final balanceVisibleProvider =
    NotifierProvider<BalanceVisibleNotifier, bool>(BalanceVisibleNotifier.new);

class BalanceVisibleNotifier extends Notifier<bool> {
  @override
  bool build() => true; // état initial

  void toggle() => state = !state;
}
@riverpod
class BalanceVisible extends _$BalanceVisible {
  @override
  bool build() => true;

  void toggle() => state = !state;
}
// Utilisation : ref.watch(balanceVisibleProvider)
//               ref.read(balanceVisibleProvider.notifier).toggle()
void add(Beneficiary b) {
  state.add(b); // même instance : == renvoie vrai, personne n'est notifié
}
void add(Beneficiary b) {
  state = [...state, b];
}

AsyncNotifier, AsyncValue.guard et Ref.mounted

final beneficiariesProvider =
    AsyncNotifierProvider.autoDispose<BeneficiariesNotifier, List<Beneficiary>>(
  BeneficiariesNotifier.new,
);

class BeneficiariesNotifier extends AsyncNotifier<List<Beneficiary>> {
  @override
  Future<List<Beneficiary>> build() {
    return ref.watch(beneficiaryRepositoryProvider).fetchAll();
  }

  Future<void> add({required String name, required String phone}) async {
    state = const AsyncLoading(); // la liste précédente reste dans state.value
    final result = await AsyncValue.guard(() async {
      final repo = ref.read(beneficiaryRepositoryProvider);
      await repo.add(name: name, phone: phone);
      return repo.fetchAll();
    });
    if (!ref.mounted) return; // le provider a pu être détruit pendant l'await
    state = result;
  }
}

AsyncValue.guard exécute le Future et renvoie AsyncData en cas de succès ou AsyncError (avec la stack trace) en cas d'exception. Il remplace un try/catch répétitif. Un second paramètre optionnel permet de ne capturer que certaines erreurs et de relancer les autres.

Le test ref.mounted est indispensable en Riverpod 3 : utiliser un Ref ou affecter state après la destruction du provider lève désormais une exception (UnmountedRefException). Avec un provider autoDispose, l'utilisateur qui quitte l'écran pendant la requête suffit à déclencher le cas.

Piège d'entretien

« ref.mounted protège-t-il contre un refresh concurrent ? » Non. Vérifié sur 3.4.3 : après un ref.invalidate, le même Notifier est réutilisé, build est rappelé et ref.mounted reste vrai. Il ne détecte que la destruction. Pour ignorer une réponse devenue obsolète, comparez l'état avant et après l'await (exemple au chapitre 07 avec la pagination).

À dire en entretien

« Mes Notifiers jouent le rôle de view models : build charge l'état initial en watchant les repositories, les méthodes publiques sont les commandes de l'écran. Dans une méthode asynchrone, je passe en AsyncLoading en gardant la valeur précédente, j'enveloppe l'appel dans AsyncValue.guard, et je vérifie ref.mounted avant d'écrire l'état. »

Invalidation et refresh

  • ref.invalidate(p) : marque l'état comme périmé. S'il a des écouteurs, il est recalculé (au plus tard à la prochaine lecture) ; sinon il est simplement détruit. Ne renvoie rien.
  • ref.refresh(p) : invalide puis relit immédiatement, et renvoie la nouvelle valeur. Avec p.future, renvoie le Future du nouveau calcul, idéal pour RefreshIndicator.onRefresh.
  • ref.invalidateSelf() : depuis l'intérieur d'un provider ou d'un Notifier.

Préférez invalidate quand vous n'utilisez pas le résultat : c'est plus explicite et cela évite un calcul si plus personne n'écoute. Après un transfert réussi, invalider walletProvider suffit : l'accueil se mettra à jour.

À retenir

Pour un Notifier, l'invalidation rappelle build : l'état local accumulé est perdu. C'est voulu (retour à une source de vérité) mais à garder en tête si le Notifier contient des données saisies.

select pour limiter les reconstructions

Par défaut, ref.watch reconstruit à chaque nouvel état. select ne reconstruit que si la partie sélectionnée change (comparaison par ==).

// Reconstruit seulement si le booléen change, pas à chaque item ajouté.
final isSaving = ref.watch(beneficiariesProvider.select((s) => s.isLoading));

// Sur un AsyncValue : sélectionner une partie de la donnée.
final balance = ref.watch(walletProvider.select((w) => w.value?.balance));

Dans un provider asynchrone, selectAsync joue le même rôle et renvoie un Future. Le sélecteur doit renvoyer une valeur comparable : renvoyer une nouvelle liste à chaque appel annule le bénéfice.

Cycle de vie : autoDispose, keepAlive, onDispose

Riverpod compte les abonnements (watch et listen) sur chaque provider. Quand le compteur tombe à zéro, ref.onCancel est appelé. Si le provider est autoDispose et toujours inutilisé à la frame suivante, son état est détruit et ref.onDispose est appelé.

  • Codegen : autoDispose par défaut. Désactivation : @Riverpod(keepAlive: true).
  • Sans codegen : pas d'autoDispose par défaut. Activation : .autoDispose (ou le paramètre isAutoDispose: true).
  • ref.keepAlive() renvoie un KeepAliveLink : l'état est conservé jusqu'à link.close().
  • ref.onDispose : fermer un StreamController, annuler un Timer, un CancelToken Dio.
extension CacheForExtension on Ref {
  void cacheFor(Duration duration) {
    final link = keepAlive();
    final timer = Timer(duration, link.close);
    onDispose(timer.cancel);
  }
}

final cachedWalletProvider = FutureProvider.autoDispose<Wallet>((ref) async {
  final wallet = await ref.watch(walletRepositoryProvider).fetchWallet();
  ref.cacheFor(const Duration(minutes: 2)); // seulement après un succès
  return wallet;
});
Piège d'entretien

« Votre écran de détail affiche les données d'un autre utilisateur après déconnexion. » Cause classique : un provider keepAlive qui garde des données de session. Solution : faire dépendre ces providers d'un provider de session (ref.watch(authProvider)) pour qu'ils soient recalculés au changement d'utilisateur, ou les invalider explicitement à la déconnexion. En contexte bancaire, c'est une question de sécurité, pas de confort.

Les nouveautés de Riverpod 3

Nouveau depuis Riverpod 3.0 (septembre 2025)

Version stable actuelle : flutter_riverpod 3.4.3 (septembre 2026), riverpod_generator 4.x, riverpod_lint 3.1.x installé comme plugin natif de l'analyzer (section plugins: de analysis_options.yaml), sans custom_lint.

Retry automatique

Un provider dont le calcul échoue est relancé automatiquement avec un backoff exponentiel. Valeurs par défaut lues dans le code source 3.4.3 : 200 ms, doublé à chaque essai, plafonné à 6,4 s, au plus 10 essais. Les sous-types de Error (erreurs de programmation comme StateError) et les ProviderException ne sont pas réessayés.

void main() {
  runApp(
    ProviderScope(
      retry: (retryCount, error) {
        if (error is AuthFailure) return null; // inutile de réessayer
        if (retryCount >= 3) return null; // on abandonne
        return Duration(milliseconds: 200 * (1 << retryCount));
      },
      child: const WalletApp(),
    ),
  );
}

// Par provider : désactiver.
final noRetryProvider = FutureProvider.autoDispose<Wallet>(
  (ref) => ref.watch(walletRepositoryProvider).fetchWallet(),
  retry: (retryCount, error) => null,
);
// Avec codegen : @Riverpod(retry: maFonctionDeRetry)
Piège d'entretien

Pendant les essais, l'état reste AsyncLoading (avec hasError vrai) : l'utilisateur voit un spinner, pas l'erreur. Avec les valeurs par défaut, l'erreur finale peut n'apparaître qu'après plus de 30 secondes. Sur une app de paiement, limitez le retry, excluez les erreurs métier (solde insuffisant, 401, 422) et vérifiez que le back-end supporte les relances. Un retry automatique sur un POST non idempotent serait dangereux, mais il ne concerne que le calcul d'un provider, donc normalement des lectures.

Ref.mounted

Équivalent de BuildContext.mounted : vaut faux une fois le provider détruit. À tester après chaque await avant d'utiliser ref ou state.

Pause des providers non visibles

Les listeners d'un widget sont mis en pause quand le widget n'est plus visible, en s'appuyant sur TickerMode (écran recouvert par une route, onglet inactif). Un provider dont tous les abonnés sont en pause est lui-même en pause : un StreamProvider qui interroge le serveur cesse de travailler pour un écran caché. Pour garder un écran actif malgré tout, l'envelopper dans TickerMode(enabled: true, ...). ref.listen renvoie aussi une subscription avec pause() et resume().

ProviderContainer.test

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

void main() {
  test('walletProvider expose le solde du repository', () async {
    // Disposé automatiquement à la fin du test.
    final container = ProviderContainer.test(
      overrides: [
        walletRepositoryProvider.overrideWithValue(FakeWalletRepository()),
      ],
    );
    // listen garde le provider autoDispose en vie pendant le test.
    container.listen(walletProvider, (previous, next) {});

    final wallet = await container.read(walletProvider.future);
    expect(wallet.balance, 125000);
  });
}

En widget test, tester.container() renvoie le container du ProviderScope pompé. La documentation recommande de ne jamais partager un container entre tests et de mocker les repositories plutôt que les Notifiers.

Autres changements à connaître

  • AsyncValue est sealed ; valueOrNull devient value.
  • Tous les providers filtrent les notifications avec == (surcharge possible via updateShouldNotify).
  • Les erreurs relues via ref.read / watch sont enveloppées dans ProviderException ; AsyncError garde l'erreur d'origine.
  • FamilyNotifier et les interfaces AutoDispose* disparaissent (voir chapitre 07).
  • Mutations et persistance hors ligne existent en expérimental (chapitre 07).

De GetX à Riverpod : table de correspondance

Si vous venez de GetX, vous connaissez déjà les besoins : un objet qui porte l'état, un widget qui réagit, un moyen de retrouver l'objet. Riverpod répond aux mêmes besoins avec deux différences de fond : l'état est immuable et remplacé (pas des variables .obs mutées), et les dépendances sont déclarées (pas enregistrées à l'exécution).

GetXRiverpod 3Différence à expliquer
GetxControllerNotifier / AsyncNotifierUn seul objet d'état immuable au lieu de plusieurs Rx
.obs, RxInt, RxListstate du NotifierOn remplace l'état, on ne le mute pas
Obx(() => ...)ConsumerWidget + ref.watch, ou ConsumerAbonnement explicite, pas de traçage implicite des lectures
GetBuilder + update()Affectation de stateNotification automatique, filtrée par ==
Get.put / Get.lazyPutDéclaration d'un providerToujours lazy, jamais « pas encore enregistré »
Get.find<T>()ref.watch / ref.readVérifié à la compilation
onInit / onReadybuild()build peut être rappelé (invalidation)
onCloseref.onDisposeLié au cycle de vie du provider
ever, once, workersref.listenReçoit previous et next
permanent: truekeepAliveLe défaut codegen est inverse : libéré si inutilisé
SmartManagement, Get.deleteautoDispose, ref.invalidateComptage des abonnés, pas des routes
BindingsGraphe de providers + overridesPas de liaison à la route
Get.snackbar dans le controllerref.listen dans le widget + ScaffoldMessengerLe Notifier ne connaît pas l'UI
Get.testMode, Get.reset()ProviderContainer.testContainer isolé par test
À dire en entretien

« J'ai livré en production avec GetX, et j'en connais les forces : très peu de code et une prise en main immédiate. J'en connais aussi les limites sur la durée : dépendances résolues à l'exécution, cycle de vie lié aux routes, tests qui demandent de réinitialiser un état global. C'est pour ça que je me suis formé à Riverpod et que je le recommande aujourd'hui pour une app qui doit vivre plusieurs années avec plusieurs équipes. Les concepts se transposent bien : un controller devient un Notifier, Obx devient ref.watch. »

Questions d'entretien

Quelle est la différence entre ref.watch, ref.read et ref.listen ?

watch lit et s'abonne : à utiliser dans build et dans le corps des providers. read lit une fois sans abonnement : callbacks et méthodes de Notifier. listen exécute un callback à chaque changement sans reconstruire : effets de bord comme une SnackBar ou une navigation. En dehors de build dans un widget, on utilise listenManual.

Pourquoi Riverpod plutôt que provider ou GetX ?

Par rapport à provider : indépendance du BuildContext, pas d'erreur « provider not found » à l'exécution, combinaison de providers simple. Par rapport à GetX : dépendances vérifiées à la compilation, état immuable, cycle de vie maîtrisé, overrides pour les tests. Je nuance : BLoC reste un très bon choix, surtout là où l'équipe l'utilise déjà.

Qu'est-ce qu'un AsyncValue et comment gérez-vous un refresh sans spinner plein écran ?

Une sealed class (AsyncLoading, AsyncData, AsyncError) qui peut porter à la fois une valeur précédente et un chargement. Lors d'un refresh, l'état reste AsyncData avec isRefreshing vrai : je matche d'abord sur le type, j'affiche les données, et j'ajoute un indicateur discret si isRefreshing. Je ne teste jamais isLoading en premier.

Notifier ou FutureProvider : comment choisissez-vous ?

Lecture seule, éventuellement rafraîchie : FutureProvider. Dès que l'UI doit modifier l'état (ajout, suppression, pagination, soumission), un AsyncNotifier qui expose des méthodes. Je commence souvent par un FutureProvider et je le transforme quand une mutation apparaît.

À quoi sert autoDispose et quand utilisez-vous keepAlive ?

autoDispose libère l'état quand plus personne ne l'écoute : mémoire maîtrisée, données fraîches au retour sur l'écran, requêtes annulables. C'est le défaut avec le codegen. keepAlive pour ce qui doit survivre à la navigation (client HTTP, session), ou ref.keepAlive() conditionnel pour un cache temporaire après succès.

Que fait ref.mounted et pourquoi est-il apparu en Riverpod 3 ?

Il indique si le provider est toujours monté. En Riverpod 3, utiliser un Ref détruit lève une exception, ce qui arrive typiquement quand l'utilisateur quitte l'écran pendant un await dans un Notifier autoDispose. Je le teste après chaque await avant d'écrire state.

Comment limitez-vous les reconstructions avec Riverpod ?

select pour ne dépendre que d'une partie de l'état, Consumer pour descendre l'abonnement au plus bas de l'arbre, découpage en petits widgets, état immuable avec == correct pour que le filtrage fonctionne. Et je mesure avec DevTools avant d'optimiser.

Quelle différence entre ref.invalidate et ref.refresh ?

invalidate marque l'état périmé et ne renvoie rien ; le recalcul a lieu si quelqu'un écoute. refresh invalide et relit tout de suite, en renvoyant la nouvelle valeur. Avec .future, refresh renvoie le Future attendu par RefreshIndicator.

Qu'apporte le retry automatique de Riverpod 3 et quels sont ses risques ?

Résilience gratuite face aux erreurs réseau passagères. Risques : un spinner long au lieu d'une erreur claire, des relances inutiles sur des erreurs métier, de la charge sur le back-end. Je configure une fonction retry globale qui exclut les erreurs non transitoires et limite le nombre d'essais.

Comment testez-vous un provider ?

ProviderContainer.test avec des overrides sur les repositories (fakes ou mocks mocktail), container.listen pour garder les providers autoDispose en vie, puis assertions sur container.read(...). En widget test, ProviderScope(overrides: ...) et tester.container(). Pas de container partagé entre tests.

Exercices

Exercice 1 · Sans IA · 25 min

Sur papier puis dans l'éditeur, sans autocomplétion IA : écrivez un Notifier<int> de compteur avec increment et reset, son provider en syntaxe manuelle, un ConsumerWidget qui l'affiche et un test avec ProviderContainer.test. Puis réécrivez-le en codegen.

Critères : compile du premier coup ou presque ; read dans le callback, watch dans build ; le test passe ; vous savez dire quel nom de provider le générateur produit.

Exercice 2 · Sans IA · 35 min

Écrivez un AsyncNotifier<List<String>> de tâches avec build qui simule une requête (Future.delayed), et add / remove qui utilisent AsyncValue.guard et ref.mounted. L'écran utilise un switch exhaustif, un pull-to-refresh, et une SnackBar via ref.listen en cas d'erreur.

Critères : pas de spinner plein écran au refresh ; bouton désactivé pendant l'ajout via select ; aucune mutation en place de la liste.

Exercice 3 · Oral · 10 min

Chronomètre en main, expliquez à voix haute : (1) état éphémère et applicatif, (2) watch / read / listen, (3) votre traduction d'un écran GetX en Riverpod. Enregistrez-vous et réécoutez. Critère : moins de 2 minutes par point, sans « euh » sur les termes.

Projet fil rouge · étape 06

Dans l'app Wallet (voir le projet) :

  • walletRepositoryProvider et transactionRepositoryProvider (implémentation fake en mémoire pour l'instant, Dio au chapitre 09).
  • walletProvider (GET /wallet) et recentTransactionsProvider (5 dernières transactions) en autoDispose.
  • Écran Accueil : switch sur AsyncValue, pull-to-refresh qui rafraîchit les deux providers, solde masquable via un Notifier<bool>.
  • Une fonction retry globale qui ne réessaie pas les erreurs 4xx.
  • Deux tests avec ProviderContainer.test : solde chargé, erreur propagée en AsyncError.

Critères : montants en centimes (int), aucun ref.read dans un build, aucun import de legacy.dart.

Quiz

12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.

Pour aller plus loin