Intégrations tierces

Avec RxDart

Gestion réactive des clés avec RxDart et QoraClient, commutation dynamique de requêtes avec nettoyage garanti des ressources.

Vue d'ensemble

RxDart étend les streams Dart avec des opérateurs composables. Combiné à QoraClient.watchQuery, l'opérateur switchMap fournit un pattern déclaratif pour la commutation dynamique de clés de requête : l'abonnement actif est annulé atomiquement à chaque émission upstream, et un nouvel abonnement est ouvert pour la nouvelle clé.

Ce pattern remplace la gestion manuelle des abonnements dans les ViewModels et les notifiers d'état qui doivent répondre aux changements d'une valeur pilote (un ID sélectionné, un filtre, un index d'onglet).


Configuration

pubspec.yaml
dependencies:
  qora: ^1.3.1
  rxdart: ^0.28.0

Gestion réactive des clés

Le pattern

lib/features/user/user_view_model.dart
import 'package:rxdart/rxdart.dart';
import 'package:qora/qora.dart';

class UserViewModel {
  UserViewModel(this._client);

  final QoraClient _client;

  // Subject pilote: émet l'ID utilisateur actuellement sélectionné.
  final _idSubject = BehaviorSubject<String>.seeded('1');

  // Stream dérivé: commute l'abonnement watchQuery actif à chaque
  // nouvel ID. L'abonnement précédent est annulé avant l'ouverture du suivant.
  late final Stream<QoraState<User>> userState = _idSubject
      .switchMap(
        (id) => _client.watchQuery<User>(
          key: ['users', id],
          fetcher: () => api.getUser(id),
          options: const QoraOptions(staleTime: Duration(minutes: 5)),
        ),
      )
      .doOnData((state) {
        // Les effets de bord s'exécutent après chaque émission d'état, avant le rebuild du widget.
        if (state case Success(:final data)) {
          analytics.track('user_loaded', {'id': data.id});
        }
      });

  // Avancer le subject pilote. switchMap gère le reste.
  void selectUser(String id) => _idSubject.add(id);

  void dispose() => _idSubject.close();
}

Binding du widget

lib/features/user/user_screen.dart
class UserScreen extends StatefulWidget {
  const UserScreen({super.key});

  @override
  State<UserScreen> createState() => _UserScreenState();
}

class _UserScreenState extends State<UserScreen> {
  late final UserViewModel _vm;
  late final StreamSubscription<QoraState<User>> _sub;
  QoraState<User> _state = const Initial();

  @override
  void initState() {
    super.initState();
    _vm = UserViewModel(context.qora);
    _sub = _vm.userState.listen((state) {
      setState(() => _state = state);
    });
  }

  @override
  void dispose() {
    _sub.cancel();
    _vm.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return switch (_state) {
      Initial() => const SizedBox.shrink(),
      Loading(:final previousData) => previousData != null
          ? UserCard(previousData)
          : const CircularProgressIndicator(),
      Success(:final data) => Column(
          children: [
            UserCard(data),
            Row(
              children: [
                TextButton(
                  onPressed: () => _vm.selectUser('1'),
                  child: const Text('Utilisateur 1'),
                ),
                TextButton(
                  onPressed: () => _vm.selectUser('2'),
                  child: const Text('Utilisateur 2'),
                ),
              ],
            ),
          ],
        ),
      Failure(:final error) => ErrorView(error),
    };
  }
}

Analyse du cycle de vie

Ce qui se passe lors d'un changement de clé

Quand _idSubject.add('2') est appelé, la séquence suivante s'exécute atomiquement :

  1. switchMap appelle .cancel() sur le StreamSubscription vers watchQuery(key: ['users', '1'], ...).
  2. Le runtime du générateur asynchrone Dart déclenche le bloc finally à l'intérieur de watchQuery :
    // packages/dart: qora_client.dart, bloc finally de watchQuery
    } finally {
      depSub?.cancel();          // annuler l'abonnement de dépendance, le cas échéant
      localRefetchTimer?.cancel(); // annuler tout timer de polling actif
      entry.removeSubscriber();   // décrémenter CacheEntry._subscriberCount
      _scheduleGC(normalized);    // démarrer le timer GC s'il n'y a plus d'abonnés
    }
    
  3. removeSubscriber() décrémente CacheEntry._subscriberCount et appelle touch() (met à jour lastAccessedAt). Si le compteur atteint zéro, entry.isActive devient false.
  4. _scheduleGC(key) inspecte entry.isActive. Si false, il démarre un Timer avec une durée de gcTime (défaut : Duration(minutes: 5)). Si entry.isActive est toujours true, parce qu'une autre partie de l'app maintient un abonnement séparé à la même clé, _scheduleGC est un no-op.
  5. switchMap ouvre un nouvel abonnement vers watchQuery(key: ['users', '2'], ...). addSubscriber() incrémente le _subscriberCount de la nouvelle entrée.

Pourquoi les données de l'ancienne clé survivent

Le timer GC se déclenche gcTime après l'annulation du dernier abonné, pas immédiatement. Pendant cette fenêtre :

  • L'entrée de cache pour ['users', '1'] reste en mémoire avec son dernier état Success.
  • Si selectUser('1') est rappelé avant le déclenchement du timer, un nouvel abonnement watchQuery appelle addSubscriber(), entry.isActive redevient true, et _scheduleGC est un no-op, le timer est annulé s'il était déjà en cours.
  • Le prochain accès sert les données en cache immédiatement, puis revalide en arrière-plan si les données sont périmées.

Cela signifie que la navigation entre deux enregistrements utilisateurs avec staleTime: Duration(minutes: 5) résulte en au maximum une requête réseau par fenêtre de 5 minutes par clé, quel que soit le nombre de fois que l'utilisateur commute.

Re-vérification à l'expiration du GC

Le timer GC re-vérifie entry.isActive avant de supprimer l'entrée :

entry.gcTimer = Timer(config.defaultOptions.gcTime, () {
  if (!entry.isActive) {   // re-vérifier: un nouvel abonné a pu s'attacher
    _cache.remove(key);
  }
});

Un nouvel abonné qui s'attache après la planification du timer mais avant son déclenchement trouvera l'entrée intacte et empêchera sa suppression.


Référence des opérateurs

Opérateur RxDartRôle dans ce pattern
BehaviorSubjectMaintient la valeur de clé actuelle ; rejoue la dernière émission aux nouveaux listeners.
switchMapAnnule l'abonnement watchQuery précédent à chaque émission ; ouvre un nouveau.
doOnDataExécute des effets de bord (journalisation, analytics) sur chaque état émis sans modifier le stream.
debounceTimeOptionnel : débouce les changements de clé rapides (ex. saisie de recherche) pour éviter de lancer un fetch par frappe.
distinctUnchangedOptionnel : supprime le re-abonnement quand la même clé est émise deux fois consécutivement.

Debouce des changements de clé rapides

Pour les saisies de recherche ou autres pilotes à haute fréquence, ajouter debounceTime avant switchMap :

late final Stream<QoraState<List<Product>>> searchResults = _querySubject
    .debounceTime(const Duration(milliseconds: 300))
    .distinct()
    .switchMap(
      (query) => _client.watchQuery<List<Product>>(
        key: ['products', 'search', query],
        fetcher: () => api.searchProducts(query),
      ),
    );

switchMap annule toujours l'abonnement en cours à chaque émission, donc un debouce de 300 ms est suffisant pour éviter de lancer un fetch par caractère tout en préservant l'annulation instantanée de toute requête qui démarre.


Équivalent avec les hooks

Si qora_hooks est disponible, le même comportement s'exprime sans RxDart :

lib/features/user/user_screen.dart
class UserScreen extends HookWidget {
  const UserScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final selectedId = useState('1');
    final state = useQuery<User>(
      key: ['users', selectedId.value],
      fetcher: () => api.getUser(selectedId.value),
      options: const QoraOptions(staleTime: Duration(minutes: 5)),
    );

    // useQuery se ré-abonne automatiquement quand la clé change.
    // L'abonnement précédent est annulé dans son nettoyage useEffect.

    return switch (state) {
      Initial() => const SizedBox.shrink(),
      Loading(:final previousData) => previousData != null
          ? UserCard(previousData)
          : const CircularProgressIndicator(),
      Success(:final data) => UserCard(data),
      Failure(:final error) => ErrorView(error),
    };
  }
}

useQuery utilise useEffect([Object.hashAll(key)]) pour annuler et se ré-abonner lors du changement de clé. Le cycle de vie GC est identique : le chemin d'annulation est le même bloc finally de watchQuery.

Utiliser le pattern RxDart dans les contextes non-HookWidget ou quand la logique de pilotage des clés réside dans une couche ViewModel en dehors de l'arbre de widgets.

Copyright © 2026