With RxDart
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
dependencies:
qora: ^ 1.3.1
rxdart: ^0.28.0
Reactive Key Management
The Pattern
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
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:
switchMapcalls.cancel()on theStreamSubscriptiontowatchQuery(key: ['users', '1'], ...).- Dart's async generator runtime fires the
finallyblock insidewatchQuery:// 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 } removeSubscriber()decrementsCacheEntry._subscriberCountand callstouch()(updateslastAccessedAt). If the count reaches zero,entry.isActivebecomesfalse._scheduleGC(key)inspectsentry.isActive. Iffalse, it starts aTimerwith a duration ofgcTime(default:Duration(minutes: 5)). Ifentry.isActiveis stilltrue, because another part of the app holds a separate subscription to the same key,_scheduleGCis a no-op.switchMapopens a new subscription towatchQuery(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 lastSuccessstate. - If
selectUser('1')is called again before the timer fires, a newwatchQuerysubscription callsaddSubscriber(),entry.isActivebecomestrue, and_scheduleGCis 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 operator | Role in this pattern |
|---|---|
BehaviorSubject | Holds the current key value; replays the latest emission to new listeners. |
switchMap | Cancels the previous watchQuery subscription on each emission; opens a new one. |
doOnData | Runs side effects (logging, analytics) on each emitted state without modifying the stream. |
debounceTime | Optional: debounce rapid key changes (e.g. search input) to avoid launching a fetch per keystroke. |
distinctUnchanged | Optional: 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:
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.