Chapitre 07 · Semaine 2

Riverpod avancé, BLoC et GetX

Family, pagination infinie, soumission de formulaire sans double débit, overrides, codegen, puis comparaison argumentée avec BLoC et GetX. En entretien, on vous jugera moins sur la solution choisie que sur votre capacité à expliquer ses compromis et à travailler dans le choix d'une autre équipe.

Durée 3 séances de 2 h Niveau intermédiaire à avancé Prérequis chapitre 06

Objectifs

  • Écrire une family correcte et expliquer pourquoi l'égalité des arguments compte.
  • Implémenter sans IA une pagination par cursor avec un AsyncNotifier, erreurs de page comprises.
  • Soumettre un transfert sans double débit et gérer les effets de bord avec ref.listen.
  • Écrire le même écran en Cubit et en Notifier, et dire quand BLoC est préférable.
  • Parler de GetX en entretien sans le renier ni le défendre aveuglément, et proposer un plan de migration progressive.

Family : des providers paramétrés

Une family transforme un provider en fabrique : un état indépendant par valeur de paramètre. On peut la voir comme une Map dont les clés sont les arguments. Cas typiques : le détail d'une transaction par id, un historique par filtre de type.

final transactionDetailProvider =
    FutureProvider.autoDispose.family<Transaction, String>((ref, id) {
  return ref.watch(transactionRepositoryProvider).fetchById(id);
});

// Lecture : un état par id.
ref.watch(transactionDetailProvider('tx_42'));
ref.invalidate(transactionDetailProvider); // toutes les combinaisons
ref.invalidate(transactionDetailProvider('tx_42')); // une seule
@riverpod
Future<Transaction> transactionDetail(Ref ref, String id) {
  return ref.watch(transactionRepositoryProvider).fetchById(id);
}

@riverpod
class History extends _$History {
  @override
  Future<List<Transaction>> build({TxType? type}) async {
    // type est aussi exposé en champ : this.type
    final page = await ref.watch(transactionRepositoryProvider).fetchPage(type: type);
    return page.items;
  }
}

// ref.watch(transactionDetailProvider('tx_42'));
// ref.watch(historyProvider(type: TxType.payment));

Pour un Notifier en syntaxe manuelle, Riverpod 3 a supprimé FamilyNotifier : l'argument passe par le constructeur et build() ne prend plus de paramètre.

final historyProvider = AsyncNotifierProvider.autoDispose
    .family<HistoryNotifier, HistoryState, TxType?>(HistoryNotifier.new);

class HistoryNotifier extends AsyncNotifier<HistoryState> {
  HistoryNotifier(this.type); // l'argument de la family
  final TxType? type;

  @override
  Future<HistoryState> build() async { /* voir la pagination plus bas */ }
}

L'égalité des arguments

Riverpod retrouve l'état d'une family en comparant les arguments avec == et hashCode. Si l'argument change d'identité à chaque build, chaque build crée un nouvel état : requête relancée en boucle, fuite mémoire si le provider n'est pas autoDispose.

// Nouvelle liste à chaque build : jamais égale à la précédente.
ref.watch(transactionsByIdsProvider(['tx_1', 'tx_2']));

Les arguments sûrs : types primitifs, enum, classes avec == redéfini (freezed, equatable), et les records. Un record a une égalité structurelle : (type: TxType.payment, limit: 20) == (type: TxType.payment, limit: 20) est vrai, à condition que ses champs aient eux-mêmes une égalité par valeur. Un record qui contient une List retombe dans le piège.

typedef HistoryQuery = ({TxType? type, int limit});

final historyPageProvider = FutureProvider.autoDispose
    .family<TransactionPage, HistoryQuery>((ref, query) {
  return ref
      .watch(transactionRepositoryProvider)
      .fetchPage(type: query.type, limit: query.limit);
});

ref.watch(historyPageProvider((type: TxType.payment, limit: 20)));

Avec le codegen, inutile de construire un record : déclarez plusieurs paramètres. La règle provider_parameters de riverpod_lint détecte les arguments sans égalité.

Piège d'entretien

« Votre écran de détail relance la requête en boucle. » Cherchez un argument de family recréé à chaque build (liste littérale, objet sans ==). Deuxième question attendue : « et la mémoire ? ». Une family sans autoDispose garde un état par argument rencontré ; sur une liste de 2 000 transactions consultées, cela s'accumule. Family et autoDispose vont presque toujours ensemble.

Combiner des providers

Un provider peut en watcher d'autres. C'est la façon idiomatique de dériver un état, plutôt que de recopier des données dans un Notifier.

// Dérivé synchrone : recalculé seulement si le solde change.
final canTransferProvider = Provider.autoDispose<bool>((ref) {
  final balance = ref.watch(walletProvider.select((w) => w.value?.balance));
  return balance != null && balance > 0;
});

// Combinaison asynchrone : les deux requêtes partent en parallèle.
final homeSummaryProvider =
    FutureProvider.autoDispose<(Wallet, List<Transaction>)>((ref) async {
  final walletF = ref.watch(walletProvider.future);
  final recentF = ref.watch(recentTransactionsProvider.future);
  return (await walletF, await recentF);
});

Deux points de vigilance. D'abord, les ref.watch se font avant le premier await : on déclare toutes les dépendances d'abord, on attend ensuite. Ensuite, un Notifier ne doit pas lire l'état interne d'un autre Notifier (state, ref) : il passe par son provider. La règle protected_notifier_properties le signale.

Pagination infinie avec AsyncNotifier

L'API Wallet pagine par cursor : GET /transactions?cursor=&limit=20&type= renvoie items et nextCursor (null à la fin). Les exigences d'un pattern complet :

  • La première page charge l'écran : chargement et erreur plein écran.
  • Les pages suivantes s'ajoutent sans perdre les éléments affichés.
  • Une erreur de page suivante n'efface pas la liste : un bouton « Réessayer » en bas.
  • Pas de double chargement si l'utilisateur fait défiler vite.
  • Un pull-to-refresh repart de la première page, et une page en vol ne doit pas écraser le résultat du refresh.
@immutable
class HistoryState {
  const HistoryState({
    required this.items,
    required this.nextCursor,
    this.isLoadingMore = false,
    this.pageError,
  });

  final List<Transaction> items;
  final String? nextCursor;
  final bool isLoadingMore;
  final Object? pageError; // erreur de la page suivante, pas de l'écran

  bool get hasMore => nextCursor != null;
}
class HistoryNotifier extends AsyncNotifier<HistoryState> {
  HistoryNotifier(this.type);
  final TxType? type;

  @override
  Future<HistoryState> build() async {
    final page =
        await ref.watch(transactionRepositoryProvider).fetchPage(type: type);
    return HistoryState(items: page.items, nextCursor: page.nextCursor);
  }

  Future<void> loadMore() async {
    final current = state.value;
    // Garde-fous : rien à charger, déjà en cours, ou écran en (re)chargement.
    if (current == null ||
        !current.hasMore ||
        current.isLoadingMore ||
        state.isLoading) {
      return;
    }
    state = AsyncData(HistoryState(
      items: current.items,
      nextCursor: current.nextCursor,
      isLoadingMore: true,
    ));
    try {
      final page = await ref
          .read(transactionRepositoryProvider)
          .fetchPage(cursor: current.nextCursor, type: type);
      if (!ref.mounted) return;
      // Un refresh a pu remplacer l'état pendant l'await : on ignore.
      if (state.value?.nextCursor != current.nextCursor) return;
      state = AsyncData(HistoryState(
        items: [...current.items, ...page.items],
        nextCursor: page.nextCursor,
      ));
    } catch (e) {
      if (!ref.mounted) return;
      state = AsyncData(HistoryState(
        items: current.items,
        nextCursor: current.nextCursor,
        pageError: e,
      ));
    }
  }
}

Pourquoi l'erreur de page est un champ de HistoryState et pas un AsyncError : AsyncError signifie « l'écran a échoué ». Le matcher en premier dans le switch remplacerait la liste par un message d'erreur. Deux niveaux d'erreur, deux représentations.

Le contrôle sur nextCursor après l'await traite la course avec un refresh : comme vu au chapitre 06, ref.mounted reste vrai après un invalidate. Si l'état a été remplacé entre-temps, la page reçue est obsolète. Une alternative plus robuste est un compteur de génération incrémenté dans build.

class HistoryList extends ConsumerWidget {
  const HistoryList({super.key, this.type});
  final TxType? type;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final provider = historyProvider(type);
    final history = ref.watch(provider);
    return switch (history) {
      AsyncData(:final value) => RefreshIndicator(
          onRefresh: () => ref.refresh(provider.future),
          child: ListView.builder(
            itemCount: value.items.length + (value.hasMore ? 1 : 0),
            itemBuilder: (context, index) {
              if (index == value.items.length) {
                if (value.pageError != null) {
                  return TextButton(
                    onPressed: () => ref.read(provider.notifier).loadMore(),
                    child: const Text('Réessayer'),
                  );
                }
                // Dernière ligne construite : on demande la page suivante
                // après le build (on ne modifie pas un état pendant un build).
                Future.microtask(() => ref.read(provider.notifier).loadMore());
                return const Center(child: CircularProgressIndicator());
              }
              final tx = value.items[index];
              return ListTile(key: ValueKey(tx.id), title: Text(tx.counterparty));
            },
          ),
        ),
      AsyncError(:final error) => Center(child: Text('$error')),
      AsyncLoading() => const Center(child: CircularProgressIndicator()),
    };
  }
}

Le déclenchement depuis la dernière ligne est simple et suffisant ici, les garde-fous de loadMore évitant les doublons. Un ScrollController ou un NotificationListener<ScrollNotification> avec un seuil (charger à 80 % du défilement) donne une expérience plus fluide.

À dire en entretien

« Ma pagination tient dans un AsyncNotifier paramétré par le filtre. build charge la première page, loadMore ajoute les suivantes. Je distingue l'erreur de l'écran, qui est un AsyncError, de l'erreur d'une page suivante, qui est un champ de l'état pour ne pas perdre la liste. J'ai des garde-fous contre les appels concurrents et je jette une page arrivée après un refresh. »

Formulaire et mutation de transfert

Un transfert d'argent est l'exemple type de mutation (action qui modifie l'état serveur). Les exigences sont plus fortes qu'un simple chargement :

  • États de soumission explicites : rien d'envoyé, envoi en cours, succès, échec.
  • Pas de double submit : bouton désactivé pendant l'envoi et garde dans la méthode (un double tap peut arriver avant la reconstruction).
  • Idempotence côté serveur : l'en-tête Idempotency-Key est généré une fois par intention de transfert et réutilisé si l'utilisateur réessaie après un timeout. Le bouton désactivé protège l'UI ; seule la clé protège contre le double débit réseau.
  • Conséquences : après succès, invalider le solde et l'historique.
final transferProvider =
    AsyncNotifierProvider.autoDispose<TransferNotifier, Transaction?>(
  TransferNotifier.new,
);

class TransferNotifier extends AsyncNotifier<Transaction?> {
  // Une clé par intention de transfert, réutilisée si l'utilisateur réessaie.
  String _idempotencyKey = const Uuid().v4();

  @override
  FutureOr<Transaction?> build() => null; // AsyncData(null) : rien d'envoyé

  Future<void> submit(TransferDraft draft) async {
    if (state.isLoading) return; // double tap
    state = const AsyncLoading();
    final result = await AsyncValue.guard(
      () => ref
          .read(transferRepositoryProvider)
          .send(draft, idempotencyKey: _idempotencyKey),
    );
    if (!ref.mounted) return;
    if (result.hasValue) {
      _idempotencyKey = const Uuid().v4(); // prochain transfert : nouvelle clé
      ref.invalidate(walletProvider); // le solde a changé
    }
    state = result;
  }
}

Effets de bord : ref.listen pour SnackBar et navigation

Le Notifier ne connaît ni le BuildContext, ni le router, ni le ScaffoldMessenger. Il expose un état ; le widget réagit aux transitions avec ref.listen.

class TransferConfirmButton extends ConsumerWidget {
  const TransferConfirmButton({super.key, required this.draft});
  final TransferDraft draft;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(transferProvider, (previous, next) {
      switch (next) {
        case AsyncData(value: final tx?):
          Navigator.of(context).pop(tx); // ou context.go avec go_router
        case AsyncError(:final error):
          ScaffoldMessenger.of(context)
              .showSnackBar(SnackBar(content: Text('$error')));
        default:
          break;
      }
    });
    final isSending = ref.watch(transferProvider.select((s) => s.isLoading));
    return FilledButton(
      onPressed: isSending
          ? null
          : () => ref.read(transferProvider.notifier).submit(draft),
      child: isSending
          ? const CircularProgressIndicator()
          : const Text('Confirmer'),
    );
  }
}
Piège d'entretien

« Pourquoi ne pas naviguer directement dans le Notifier ? » Parce que la logique deviendrait dépendante de l'UI, impossible à tester en test unitaire pur, et qu'un Notifier peut être utilisé par plusieurs écrans. C'est exactement le reproche fait au Get.snackbar ou au Get.to appelés depuis un GetxController. Le Notifier décrit ce qui s'est passé, le widget décide comment le montrer.

Les mutations expérimentales de Riverpod 3

Expérimental depuis Riverpod 3.0

Riverpod 3 propose un objet Mutation<T> qui porte l'état d'une action (MutationIdle, MutationPending, MutationSuccess, MutationError) sans créer de Notifier dédié. La documentation prévient que l'API peut changer de façon cassante sans version majeure. Import : package:flutter_riverpod/experimental/mutation.dart.

import 'package:flutter_riverpod/experimental/mutation.dart';

final sendTransferMutation = Mutation<Transaction>();

class TransferMutationButton extends ConsumerWidget {
  const TransferMutationButton({super.key, required this.draft, required this.idempotencyKey});
  final TransferDraft draft;
  final String idempotencyKey;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final status = ref.watch(sendTransferMutation);
    return FilledButton(
      onPressed: status is MutationPending
          ? null
          : () => sendTransferMutation.run(ref, (tsx) async {
                // tsx.get garde le provider en vie pendant la mutation.
                final repo = tsx.get(transferRepositoryProvider);
                return repo.send(draft, idempotencyKey: idempotencyKey);
              }),
      child: switch (status) {
        MutationIdle() => const Text('Confirmer'),
        MutationPending() => const CircularProgressIndicator(),
        MutationError(:final error) => Text('Réessayer ($error)'),
        MutationSuccess(:final value) => Text('Envoyé : ${value.id}'),
      },
    );
  }
}

Ma position : bon à connaître et à citer, mais je ne l'utiliserais pas sur un flux de paiement en production tant que l'API est expérimentale. Un AsyncNotifier fait le travail avec une API stable. Les mutations peuvent être indexées par clé (sendTransferMutation(beneficiaryId)) pour suivre plusieurs actions indépendantes.

Scoping et overrides

Un override remplace l'implémentation d'un provider pour un ProviderScope ou un ProviderContainer. Trois usages :

  • Tests : remplacer le repository par un fake.
  • Flavors et configuration : main_dev.dart et main_prod.dart surchargent un envProvider (détail au chapitre 08).
  • Scoping : donner une valeur différente à un sous-arbre, par exemple l'id de la transaction pour chaque ligne d'une liste.
ProviderScope(
  overrides: [
    // Valeur fixe
    transactionRepositoryProvider.overrideWithValue(FakeTransactionRepository()),
    // Un AsyncValue figé pour un widget test ou un golden
    walletProvider.overrideWithValue(
      const AsyncValue.data(Wallet(balance: 125000, currency: 'MAD')),
    ),
    // Toute une family, en recevant l'argument
    transactionDetailProvider.overrideWith((ref, id) => throw StateError(id)),
    // Ne remplacer que le build d'un Notifier (nouveau en 3.0)
    balanceVisibleProvider.overrideWithBuild((ref, notifier) => false),
  ],
  child: const WalletApp(),
);

Pour une family de Notifiers, overrideWith est déprécié depuis 3.2 au profit de overrideWith2((arg) => HistoryNotifier(arg)), qui reçoit l'argument ; le changelog annonce son renommage en overrideWith en 4.0.

final currentTxIdProvider = Provider<String>(
  (ref) => throw UnimplementedError('Surchargé par chaque ligne'),
  dependencies: const [], // obligatoire pour un provider surchargé hors racine
);

// Chaque ligne a son propre scope, la ligne peut rester const.
for (final id in ids)
  ProviderScope(
    key: ValueKey(id),
    overrides: [currentTxIdProvider.overrideWithValue(id)],
    child: const TxRow(), // TxRow fait ref.watch(currentTxIdProvider)
  ),

Le scoping est puissant mais rarement indispensable : un paramètre de constructeur ou une family suffit le plus souvent. Un provider qui watch un provider scopé doit lister celui-ci dans ses dependencies ; le lint scoped_providers_should_specify_dependencies aide en codegen.

Codegen : avantages et coût

AvantagesCoûts
Une syntaxe unique : fonction ou classe annotéebuild_runner à lancer (dart run build_runner watch -d)
Family à paramètres multiples, nommés, optionnelsFichiers .g.dart : à committer ou générer en CI, conflits de merge
autoDispose par défautTemps de génération sur un gros monorepo
Providers génériques (multiply<T extends num>)Une étape de plus pour un débutant, erreurs parfois indirectes
Lints de scoping, hot reload avec état, métadonnées de débogageDépendance à l'outillage de génération

Contexte 2026 : les macros Dart ont été abandonnées en janvier 2025, et la feuille de route Dart mise sur un build_runner plus rapide et les augmentations. Le codegen reste donc la voie officielle. Si le projet utilise déjà freezed et json_serializable, build_runner est là de toute façon et le coût marginal de riverpod_generator est faible : c'est mon argument principal pour le recommander. À l'inverse, la documentation Riverpod déconseille d'introduire build_runner uniquement pour Riverpod. Elle cite aussi deux avantages moins connus : le hot reload avec conservation de l'état des providers, et de meilleures métadonnées de débogage.

Persistance hors ligne expérimentale

Expérimental depuis Riverpod 3.0

Un Notifier peut appeler persist(...) dans build pour sauvegarder et restaurer son état via un Storage. Riverpod ne fournit que les interfaces ; l'implémentation officielle est riverpod_sqflite (JsonSqFliteStorage). Options : clé unique et stable, StorageOptions(cacheTime: ..., destroyKey: ...), cache de 2 jours par défaut, @JsonPersist() en codegen. AsyncValue.isFromCache indique une valeur restaurée. Import : package:flutter_riverpod/experimental/persist.dart.

Réserve pour un contexte bancaire : la documentation la présente comme une simple surcouche de base de données, sans vraie migration de schéma (seulement destroyKey pour tout jeter), et les données sont stockées en clair dans SQLite sauf chiffrement de votre part. Pour l'historique du Wallet, le chapitre 11 utilise drift avec un cache explicite, plus maîtrisable et auditable.

Anti-patterns Riverpod

Anti-patternPourquoi c'est un problèmeCorrection
Variable globale mutable à côté des providers (User? currentUser;)Personne n'est notifié, invisible pour les tests et les overridesUn Notifier, ou un provider dérivé
Objet mutable comme état (liste modifiée en place)Pas de notification (filtrage par ==), bugs fantômesÉtat immuable, freezed, copyWith
Logique métier dans onPressedNon testable sans widget, dupliquée entre écransMéthode de Notifier ou use case
ref.watch dans un callback ou après un awaitAbonnement hors build, comportement imprévisibleref.read ; les watch avant le premier await
ref.read dans buildUI figéeref.watch + select
Passer BuildContext ou WidgetRef à un NotifierCouplage à l'UI, fuite de contexteref.listen côté widget
Un Notifier géant par featureReconstructions massives, conflits de mergeUn Notifier par responsabilité, providers dérivés
Family sans autoDispose sur un idCroissance mémoire.autoDispose ou codegen par défaut
Nouveau code avec StateNotifier / StateProviderLegacy en Riverpod 3Notifier

BLoC et Cubit

BLoC (Business Logic Component, package flutter_bloc, version 9.x en 2026) est l'autre grand standard, très présent dans les banques, assurances et grands comptes. Vous le croiserez en mission même si vous préférez Riverpod : il faut savoir le lire, l'écrire et le défendre.

  • Cubit : une classe avec un état et des méthodes qui appellent emit(nouvelEtat). Très proche d'un Notifier.
  • Bloc : reçoit des events (objets) et produit des states. Chaque event est traité par un handler on<Event>. Plus verbeux, mais chaque intention utilisateur est un objet nommé, traçable et journalisable.
  • Widgets : BlocProvider (fournit l'instance par le contexte), BlocBuilder (reconstruit, filtrable avec buildWhen), BlocListener (effets de bord, listenWhen), BlocConsumer (les deux), BlocSelector.
  • Transformers (bloc_concurrency) : par défaut, les events d'un même type sont traités en concurrence. sequential(), droppable() (ignore les events pendant un traitement), restartable() (annule le précédent) changent ce comportement.

Le même écran en Cubit et en Notifier

sealed class BalanceState {
  const BalanceState();
}

final class BalanceLoading extends BalanceState {
  const BalanceLoading();
}

final class BalanceLoaded extends BalanceState {
  const BalanceLoaded(this.wallet);
  final Wallet wallet;
}

final class BalanceFailure extends BalanceState {
  const BalanceFailure(this.error);
  final Object error;
}

class BalanceCubit extends Cubit<BalanceState> {
  BalanceCubit(this._repo) : super(const BalanceLoading());
  final WalletRepository _repo;

  Future<void> load() async {
    emit(const BalanceLoading());
    try {
      final wallet = await _repo.fetchWallet();
      if (isClosed) return; // emit après close lève une StateError
      emit(BalanceLoaded(wallet));
    } catch (e) {
      if (isClosed) return;
      emit(BalanceFailure(e));
    }
  }
}

// UI
BlocProvider(
  create: (context) => BalanceCubit(context.read<WalletRepository>())..load(),
  child: BlocBuilder<BalanceCubit, BalanceState>(
    builder: (context, state) => switch (state) {
      BalanceLoading() => const CircularProgressIndicator(),
      BalanceLoaded(:final wallet) => Text('${wallet.balance}'),
      BalanceFailure() => TextButton(
          onPressed: () => context.read<BalanceCubit>().load(),
          child: const Text('Réessayer'),
        ),
    },
  ),
);
final balanceProvider =
    AsyncNotifierProvider.autoDispose<BalanceNotifier, Wallet>(BalanceNotifier.new);

class BalanceNotifier extends AsyncNotifier<Wallet> {
  @override
  Future<Wallet> build() => ref.watch(walletRepositoryProvider).fetchWallet();

  Future<void> reload() async {
    state = const AsyncLoading();
    final result = await AsyncValue.guard(
      () => ref.read(walletRepositoryProvider).fetchWallet(),
    );
    if (!ref.mounted) return; // l'équivalent de isClosed
    state = result;
  }
}
// UI : ref.watch(balanceProvider) puis switch sur AsyncData / AsyncError / AsyncLoading

Les différences visibles : le Cubit définit ses propres états (plus explicite, plus de code), le Notifier réutilise AsyncValue (moins de code, valeur précédente conservée gratuitement). Le Cubit reçoit son repository par constructeur et se trouve via le BuildContext ; le Notifier lit le graphe via ref. Les deux se testent sans widget : bloc_test d'un côté, ProviderContainer.test de l'autre.

EventTransformer<E> debounceRestartable<E>(Duration duration) {
  // stream_transform fournit debounce ; restartable annule la recherche en cours.
  return (events, mapper) =>
      restartable<E>().call(events.debounce(duration), mapper);
}

class BeneficiarySearchBloc extends Bloc<SearchEvent, SearchState> {
  BeneficiarySearchBloc(this._repo) : super(const SearchIdle()) {
    on<QueryChanged>(
      _onQueryChanged,
      transformer: debounceRestartable(const Duration(milliseconds: 300)),
    );
  }

  final BeneficiaryRepository _repo;

  Future<void> _onQueryChanged(
    QueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    if (event.query.trim().isEmpty) return emit(const SearchIdle());
    emit(const SearchLoading());
    try {
      emit(SearchResults(await _repo.search(event.query)));
    } catch (e) {
      emit(SearchFailure(e));
    }
  }
}

// Pour la soumission d'un transfert : les taps pendant l'envoi sont ignorés.
// on<TransferSubmitted>(_onSubmitted, transformer: droppable());

Quand BLoC est préféré

  • Traçabilité : chaque event est un objet ; un BlocObserver global journalise toutes les intentions et transitions. Apprécié pour l'audit et le support dans une banque.
  • Conventions strictes : une seule façon de faire, facile à imposer à 30 développeurs de plusieurs prestataires.
  • Concurrence déclarative : droppable, restartable en une ligne.
  • Existant : l'équipe et le code sont déjà en BLoC. C'est la raison la plus fréquente, et la meilleure.

Le revers : beaucoup de code (events, states, handlers), et la composition entre blocs est moins naturelle que le graphe de providers.

À dire en entretien

« Je recommande Riverpod pour un nouveau projet, mais je suis à l'aise en BLoC. Sur une app bancaire où l'équipe est déjà en BLoC, je m'aligne : la cohérence du code vaut plus que mes préférences. J'apprécie dans BLoC la traçabilité des events et les transformers de bloc_concurrency, par exemple droppable sur la soumission d'un paiement. »

GetX : en parler sans dogmatisme

GetX (get 4.7.3 en stable ; une 5.0 reste en release candidate depuis longtemps) regroupe dans un seul package state management, injection de dépendances, navigation, snackbars, traductions, client HTTP. Il est très populaire sur pub.dev, et souvent mal vu des recruteurs seniors. Vous devez connaître les deux faces.

Ce qu'on lui reproche

  • Couplage : service locator, navigation, état et DI dans un seul package. Le remplacer revient à toucher toute l'app.
  • Magie implicite : Obx trace les observables lus pendant son build ; une lecture conditionnelle ou hors du builder casse la réactivité sans erreur claire. Get.find échoue à l'exécution si l'instance n'a pas été enregistrée, ou a été supprimée par SmartManagement à la sortie d'une route.
  • Navigation sans contexte : pratique, mais elle encourage la logique de navigation dans les controllers et cohabite mal avec le routage déclaratif (go_router, deep links).
  • Maintenance : rythme de publication irrégulier, une 5.0 longtemps en RC, beaucoup d'issues ouvertes. Risque perçu pour une app qui doit vivre dix ans.
  • Testabilité : état global à réinitialiser (Get.reset()), dépendances implicites, controllers qui appellent Get.snackbar ou Get.to.

Ce qui est défendable

  • Une productivité réelle sur des apps de taille moyenne, avec une prise en main rapide.
  • Des apps en production qui fonctionnent : le state management n'est presque jamais la cause d'un échec de projet.
  • Avec de la discipline (logique métier dans des classes pures, repositories injectés par constructeur, controllers fins), une base GetX reste testable et migrable.
À dire en entretien : « vous avez surtout fait du GetX ? »

« Oui, sur plusieurs apps en production, dont une app de paiement. GetX nous a permis d'aller vite au début. Avec le temps, j'ai vu ses limites : dépendances résolues à l'exécution, cycle de vie lié aux routes, tests plus lourds. On avait compensé en gardant la logique métier dans des use cases et des repositories purs, ce qui rendait les controllers fins. Aujourd'hui je recommande Riverpod, ou BLoC si c'est le standard de l'équipe, et je sais migrer progressivement une base GetX sans tout réécrire. »

Piège d'entretien

Deux réponses perdantes. Dénigrer GetX (« c'était une erreur ») fait douter de votre jugement passé et de votre loyauté envers vos anciennes équipes. Le défendre en bloc (« GetX est le meilleur, les autres sont trop verbeux ») vous classe comme réfractaire aux standards. La bonne posture : contexte, bénéfices, limites constatées, ce que vous avez appris, ce que vous recommandez maintenant.

Migrer progressivement de GetX vers Riverpod

Une réécriture complète est rarement acceptée par un client : risque, coût, gel fonctionnel. La stratégie qui fonctionne est incrémentale, écran par écran, avec coexistence temporaire.

Étape 1 : isoler la logique métier dans des classes pures

class BalanceController extends GetxController {
  BalanceController(this._repo);
  final WalletRepository _repo;

  final balance = RxnInt();
  final isLoading = false.obs;

  @override
  void onInit() {
    super.onInit();
    load();
  }

  Future<void> load() async {
    isLoading.value = true;
    balance.value = (await _repo.fetchWallet()).balance;
    isLoading.value = false;
  }
}
sealed class AmountError {
  const AmountError();
}

final class AmountTooLow extends AmountError {
  const AmountTooLow();
}

final class AmountAboveBalance extends AmountError {
  const AmountAboveBalance();
}

class TransferRules {
  const TransferRules({this.minAmount = 100});
  final int minAmount; // centimes

  AmountError? validate({required int amount, required int balance}) {
    if (amount < minAmount) return const AmountTooLow();
    if (amount > balance) return const AmountAboveBalance();
    return null;
  }
}

Cette étape apporte de la valeur même si la migration s'arrête là : les règles sont testées unitairement, et un controller GetX comme un Notifier ne font plus que déléguer.

Étape 2 : faire coexister les deux

// Un container unique, accessible au code Riverpod et au code GetX.
final container = ProviderContainer();

void main() {
  runApp(
    UncontrolledProviderScope(
      container: container,
      child: GetMaterialApp(home: const HomePage()),
    ),
  );
}

// Pont Riverpod vers GetX : un service encore enregistré dans GetX.
final legacyBalanceControllerProvider = Provider<BalanceController>((ref) {
  return Get.find<BalanceController>();
});

// Pont GetX vers Riverpod : un controller legacy lit un provider.
class LegacyTransferController extends GetxController {
  bool isValid(int amount, int balance) {
    final rules = container.read(transferRulesProvider);
    return rules.validate(amount: amount, balance: balance) == null;
  }
}

Étape 3 : migrer écran par écran

  1. Commencer par les fondations : repositories et client HTTP deviennent des providers (le pont ci-dessus permet aux controllers GetX de les lire).
  2. Migrer un écran feuille, peu couplé, avec des tests avant et après.
  3. Remplacer Obx par ConsumerWidget, le controller par un Notifier qui délègue aux mêmes classes pures.
  4. Migrer la navigation séparément (GetX vers go_router), en dernier ou en parallèle : c'est le chantier le plus transverse.
  5. Supprimer les ponts et la dépendance get quand le dernier Get.find a disparu. Un lint ou un script CI qui compte les usages restants rend la progression visible.
À retenir

Ordre de migration : logique pure, puis données, puis état par écran, puis navigation. Chaque étape est livrable et réversible. C'est ce discours qu'un client grand compte veut entendre.

Comparatif Riverpod, BLoC, GetX, Provider

CritèreRiverpod 3BLoC 9GetX 4Provider
Accès aux dépendancesGraphe de providers, vérifié à la compilationPar BuildContext (context.read)Service locator global, à l'exécutionPar BuildContext
Modèle d'étatImmuable, AsyncValue intégréImmuable, états explicitesObservables mutablesChangeNotifier mutable
BoilerplateFaible à moyenÉlevé (Bloc), moyen (Cubit)Très faibleFaible
TestabilitéExcellente (overrides)Excellente (bloc_test)Moyenne (état global)Bonne
TraçabilitéProviderObserverEvents + BlocObserverFaibleFaible
Async et cacheNatif (retry, autoDispose)ManuelManuelManuel
PérimètreÉtat + DIÉtatTout (état, DI, navigation...)DI + état simple
Image en entretienModerne, recommandéStandard grands comptesÀ justifierCorrect mais daté pour l'état complexe

Questions d'entretien

Qu'est-ce qu'une family et quel est le piège principal ?

Un provider paramétré : un état par argument. Le piège est l'égalité : l'argument doit avoir un == et un hashCode stables (primitif, enum, record de valeurs, classe freezed), sinon chaque build crée un nouvel état. Et on l'associe à autoDispose pour ne pas accumuler les états.

Comment implémentez-vous une pagination infinie avec Riverpod ?

Un AsyncNotifier dont l'état contient les items, le cursor suivant, un drapeau de chargement de page et une erreur de page. build charge la première page, loadMore ajoute la suivante avec des garde-fous (pas de doublon, pas pendant un refresh), et ignore une réponse arrivée après un refresh. L'erreur de page ne remplace pas la liste.

Comment empêchez-vous un double paiement ?

Trois niveaux. UI : bouton désactivé pendant l'envoi. Logique : garde if (state.isLoading) return dans le Notifier, ou droppable() en BLoC. Réseau : Idempotency-Key générée une fois par intention et réutilisée en cas de nouvelle tentative, pour que le serveur ne débite qu'une fois même si la réponse s'est perdue.

Où placez-vous la navigation et les SnackBars ?

Dans la couche UI, via ref.listen (ou BlocListener) qui réagit aux transitions d'état. Le Notifier expose l'état et ne connaît pas le contexte. Cela garde la logique testable sans widget et réutilisable.

Cubit ou Bloc : quand choisissez-vous l'un ou l'autre ?

Cubit par défaut : moins de code, suffisant pour la majorité des écrans. Bloc quand la traçabilité des intentions compte (audit, analytics) ou quand j'ai besoin des transformers : debounce d'une recherche, droppable sur une soumission, restartable pour annuler une requête obsolète.

Quels reproches fait-on à GetX, et que répondez-vous ?

Couplage de l'état, de la DI et de la navigation, magie implicite d'Obx et de Get.find, état global difficile à tester, maintenance irrégulière. Je réponds avec mon expérience : productif au démarrage, limites constatées à l'échelle, compensées par une logique métier isolée, et je recommande aujourd'hui Riverpod ou BLoC selon l'équipe.

Comment migreriez-vous une app GetX vers Riverpod ?

Progressivement : extraire la logique en classes pures testées, exposer repositories et services comme providers, faire coexister les deux via un ProviderContainer partagé (UncontrolledProviderScope) et des ponts temporaires, migrer écran par écran en commençant par les feuilles, la navigation à part, puis supprimer get. Chaque étape est livrable.

Utilisez-vous le codegen Riverpod ? Pourquoi ?

Oui sur les nouveaux projets : syntaxe unifiée, families à paramètres multiples, autoDispose par défaut, lints de scoping. Le coût est build_runner, souvent déjà présent pour freezed et json_serializable. Sur une équipe qui refuse la génération, la syntaxe manuelle est parfaitement valable.

Que pensez-vous des mutations et de la persistance hors ligne de Riverpod 3 ?

Deux fonctionnalités prometteuses mais expérimentales : l'API peut casser sans version majeure. Je les cite, je les teste sur un prototype, mais sur un flux de paiement en production je reste sur des AsyncNotifier et un cache drift explicite, chiffrable et migrable.

À quoi servent les overrides en dehors des tests ?

Configuration par flavor (un envProvider surchargé dans chaque main_*.dart), injection d'une valeur initialisée de façon asynchrone avant runApp (SharedPreferences, base de données), et scoping d'une valeur pour un sous-arbre, comme l'id d'une ligne de liste.

Exercices

Exercice 1 · Sans IA · 40 min

Écrivez la pagination par cursor complète (état, Notifier, écran) contre un faux repository qui renvoie 3 pages de 20 éléments, avec une latence de 500 ms et une erreur sur la page 2 une fois sur deux.

Critères : la liste ne disparaît jamais sur une erreur de page ; « Réessayer » reprend au bon cursor ; défiler vite ne produit aucun doublon (vérifiez les ids) ; un pull-to-refresh pendant un chargement de page ne mélange pas les résultats.

Exercice 2 · Sans IA · 30 min

Écrivez un écran « solde » deux fois : en Cubit avec bloc_test, puis en AsyncNotifier avec ProviderContainer.test. Comptez les lignes de chaque version.

Critères : les deux versions ont le même comportement (chargement, succès, erreur, réessai) ; vous pouvez expliquer en une minute les différences de structure et de test.

Exercice 3 · Oral · 15 min

Préparez et chronométrez votre réponse à « Vous avez surtout utilisé GetX, pourquoi devrions-nous vous faire confiance sur une base BLoC ? », puis votre plan de migration GetX vers Riverpod en 5 étapes. Critères : moins de 90 secondes chacune, aucun dénigrement, au moins un exemple concret.

Projet fil rouge · étape 07

Dans l'app Wallet (voir le projet) :

  • Écran Historique : historyProvider en family sur le filtre de type, pagination par cursor (GET /transactions?cursor=&limit=20&type=), pull-to-refresh, erreur de page avec réessai.
  • Écran Détail : transactionDetailProvider(id) en family autoDispose.
  • TransferNotifier : soumission avec Idempotency-Key conservée entre deux tentatives, garde anti double tap, invalidation du solde et de l'historique après succès, navigation vers l'écran Résultat via ref.listen.
  • Test : avec X-Chaos-Fail-Rate: 0.3 côté backend, enchaîner 10 transferts avec réessais et vérifier qu'aucun n'est débité deux fois.

Quiz

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

Pour aller plus loin