Third Party

With RxDart

Reactive key management with RxDart and QoraClient, dynamic query switching with guaranteed resource cleanup.

Overview

RxDart extends Dart streams with composable operators. When combined with QoraClient.watchQuery, the switchMap operator provides a declarative pattern for dynamic query key switching: the active subscription is cancelled atomically on each upstream emission, and a new subscription is opened against the new key.

This pattern replaces manual subscription management in ViewModels and state notifiers that must respond to changes in a driving value (a selected ID, a filter, a tab index).


Setup

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

Reactive Key Management

The 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;

  // Driving subject: emits the currently selected user ID.
  final _idSubject = BehaviorSubject<String>.seeded('1');

  // Derived stream: switches the active watchQuery subscription on every
  // new ID. The previous subscription is cancelled before the next opens.
  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) {
        // Side effects run after each state emission, before widget rebuild.
        if (state case Success(:final data)) {
          analytics.track('user_loaded', {'id': data.id});
        }
      });

  // Advance the driving subject. switchMap handles the rest.
  void selectUser(String id) => _idSubject.add(id);

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

Widget Binding

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('User 1'),
                ),
                TextButton(
                  onPressed: () => _vm.selectUser('2'),
                  child: const Text('User 2'),
                ),
              ],
            ),
          ],
        ),
      Failure(:final error) => ErrorView(error),
    };
  }
}

Lifecycle Analysis

What Happens on Key Switch

When _idSubject.add('2') is called, the following sequence executes atomically:

  1. switchMap calls .cancel() on the StreamSubscription to watchQuery(key: ['users', '1'], ...).
  2. Dart's async generator runtime fires the finally block inside watchQuery:
    // packages/dart: qora_client.dart, watchQuery finally block
    } finally {
      depSub?.cancel();          // cancel dependency subscription, if any
      localRefetchTimer?.cancel(); // cancel any active polling timer
      entry.removeSubscriber();   // decrement CacheEntry._subscriberCount
      _scheduleGC(normalized);    // start GC timer if no more subscribers
    }
    
  3. removeSubscriber() decrements CacheEntry._subscriberCount and calls touch() (updates lastAccessedAt). If the count reaches zero, entry.isActive becomes false.
  4. _scheduleGC(key) inspects entry.isActive. If false, it starts a Timer with a duration of gcTime (default: Duration(minutes: 5)). If entry.isActive is still true, because another part of the app holds a separate subscription to the same key, _scheduleGC is a no-op.
  5. switchMap opens a new subscription to watchQuery(key: ['users', '2'], ...). addSubscriber() increments the new entry's _subscriberCount.

Why the Old Key's Data Survives

The GC timer fires gcTime after the last subscriber cancels, not immediately. During this window:

  • The cache entry for ['users', '1'] remains in memory with its last Success state.
  • If selectUser('1') is called again before the timer fires, a new watchQuery subscription calls addSubscriber(), entry.isActive becomes true, and _scheduleGC is a no-op, the timer is cancelled if one was already running.
  • The next access serves the cached data immediately, then revalidates in the background if the data is stale.

This means navigation between two user records with staleTime: Duration(minutes: 5) results in at most one network request per 5-minute window per key, regardless of how many times the user switches.

Re-check at GC Expiry

The GC timer re-checks entry.isActive before removing the entry:

entry.gcTimer = Timer(config.defaultOptions.gcTime, () {
  if (!entry.isActive) {   // re-check: a new subscriber may have attached
    _cache.remove(key);
  }
});

A new subscriber that attaches after the timer was scheduled but before it fires will find the entry intact and prevent its removal.


Operators Reference

RxDart operatorRole in this pattern
BehaviorSubjectHolds the current key value; replays the latest emission to new listeners.
switchMapCancels the previous watchQuery subscription on each emission; opens a new one.
doOnDataRuns side effects (logging, analytics) on each emitted state without modifying the stream.
debounceTimeOptional: debounce rapid key changes (e.g. search input) to avoid launching a fetch per keystroke.
distinctUnchangedOptional: suppress re-subscription when the same key is emitted twice in succession.

Debouncing Rapid Key Changes

For search inputs or other high-frequency drivers, add debounceTime before 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 still cancels the in-flight subscription on each emission, so a 300 ms debounce is sufficient to prevent launching a fetch per character while preserving instant cancellation of any request that does start.


Hooks Equivalent

If qora_hooks is available, the same behaviour is expressed without 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 re-subscribes automatically when the key changes.
    // The previous subscription is cancelled in its useEffect cleanup.

    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 uses useEffect([Object.hashAll(key)]) to cancel and re-subscribe on key change. The GC lifecycle is identical: the cancellation path is the same watchQuery finally block.

Use the RxDart pattern in non-HookWidget contexts or when the key-driving logic lives in a ViewModel layer outside the widget tree.

Copyright © 2026