QoraClient
Constructor
QoraClient({QoraClientConfig? config})
Creates a new client instance. All queries share the same cache and deduplication map.
// Minimal (all defaults)
final client = QoraClient();
// With full configuration
final client = QoraClient(
config: QoraClientConfig(
defaultOptions: QoraOptions(
staleTime: Duration(minutes: 5),
retryCount: 2,
),
debugMode: kDebugMode,
maxCacheSize: 200,
),
);
Properties
config
final QoraClientConfig config
The resolved configuration for this client instance. Read-only.
cachedKeys
Iterable<List<dynamic>> get cachedKeys
All currently cached query keys (normalised). Useful for debugging or bulk invalidation.
for (final key in client.cachedKeys) {
print(key); // [users, 1], [posts], ...
}
Fetching
fetchQuery
Future<T> fetchQuery<T>({
required Object key,
required Future<T> Function() fetcher,
QoraOptions? options,
CancelToken? cancelToken,
})
Fetch data once and return the result.
Cache behaviour:
| Cache state | Action |
|---|---|
| Fresh (not stale) | Return cached data: no network call |
| Stale | Return stale data immediately; revalidate in background |
| Missing | Fetch, await, and return result |
Concurrent calls with the same key are deduplicated: all share a single in-flight request. Failed fetches are retried with exponential backoff up to retryCount times.
final user = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: QoraOptions(staleTime: Duration(minutes: 5)),
);
Throws:
StateErrorifQoraOptions.enabledisfalseStateErrorifQoraOptions.dependsOnis set and the dependency has no data yetQoraCancelExceptionifcancelTokenis cancelled before or during the fetch- The fetcher's exception (after all retries) on failure
watchQuery
Stream<QoraState<T>> watchQuery<T>({
required Object key,
required Future<T> Function() fetcher,
QoraOptions? options,
CancelToken? cancelToken,
})
Create a reactive Stream<QoraState<T>> for a query.
On subscription:
- Emits the current cached state immediately
- Triggers a fetch if data is missing or stale (per
refetchOnMount) - Sets up a polling timer if
refetchIntervalis set - Pushes all future state changes: from any source
On cancel (last subscriber):
- Cancels the polling timer
- Schedules a GC timer for the cache entry
StreamBuilder<QoraState<User>>(
stream: client.watchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: QoraOptions(refetchInterval: Duration(seconds: 30)),
),
builder: (context, snapshot) {
return switch (snapshot.data) {
null || Initial() => const SizedBox.shrink(),
Loading(:final previousData) => previousData != null
? UserCard(previousData)
: const CircularProgressIndicator(),
Success(:final data) => UserCard(data),
Failure(:final error) => ErrorWidget('$error'),
};
},
)
prefetch
Future<void> prefetch<T>({
required Object key,
required Future<T> Function() fetcher,
QoraOptions? options,
CancelToken? cancelToken,
})
Pre-warm the cache without creating a stream. No-op if data is already fresh.
onEnter: (_) => client.prefetch<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
),
Cache Reads
getQueryData
T? getQueryData<T>(Object key)
Returns the best available data for key, or null. Returns Success.data, or previousData from Loading/Failure.
final user = client.getQueryData<User>(['users', userId]);
getQueryState
QoraState<T> getQueryState<T>(Object key)
Returns the full QoraState<T>, or Initial<T>() if not cached.
final state = client.getQueryState<User>(['users', userId]);
if (state is Success<User>) {
print('Age: ${state.age}');
}
Cache Writes
setQueryData
void setQueryData<T>(Object key, T data)
Inject data directly into the cache. Instantly pushes Success to all active watchQuery streams.
client.setQueryData(['users', userId], updatedUser);
restoreQueryData
void restoreQueryData<T>(Object key, T? snapshot)
Roll back to a previous snapshot. If snapshot is null, the entry is removed from cache.
final snapshot = client.getQueryData<User>(['users', userId]);
client.setQueryData(['users', userId], optimisticUser);
try {
await api.updateUser(userId, payload);
} catch (_) {
client.restoreQueryData(['users', userId], snapshot);
}
Invalidation
invalidate
void invalidate(Object key)
Mark a query as stale. Active watchQuery streams receive Loading(previousData: ...) immediately. The next access triggers a fresh fetch.
await api.createPost(payload);
client.invalidate(['posts']);
invalidateWhere
void invalidateWhere(bool Function(List<dynamic> key) predicate)
Invalidate all queries whose key satisfies predicate.
// All user queries
client.invalidateWhere((key) => key.firstOrNull == 'users');
// Specific user + their sub-resources
client.invalidateWhere(
(key) => key.length >= 2 && key[0] == 'users' && key[1] == userId,
);
invalidatePrefix
void invalidatePrefix(Object prefix)
Invalidate every query whose key starts with prefix. Segments are compared with the same deep equality as query keys (maps and nested lists match structurally). An empty prefix matches every query. Active watchers refetch.
// ['posts'], ['posts', 1], ['posts', 1, 'comments'], ...
client.invalidatePrefix(['posts']);
// Every query under user 42
client.invalidatePrefix(['users', 42]);
invalidate(key) is exact. Use invalidatePrefix when you want TanStack-style partial matching.invalidateQueries
void invalidateQueries({required QueryFilter filter})
Bulk invalidation with a QueryFilter: a richer predicate than invalidateWhere that also receives the current state and the options from the last successful fetch.
typedef QueryFilter = bool Function(
String key,
QoraState<dynamic> state,
QoraOptions? lastOptions,
);
// Invalidate every online-mode query not yet in Success
client.invalidateQueries(
filter: (key, state, opts) =>
opts?.networkMode == NetworkMode.online && state is! Success,
);
// Invalidate all user queries whose data is older than 10 minutes
client.invalidateQueries(
filter: (key, state, opts) {
if (state is! Success) return false;
return key.contains('users') &&
DateTime.now().difference(state.updatedAt) > const Duration(minutes: 10);
},
);
invalidateTags
void invalidateTags(List<QueryTag> tags)
Invalidate all cached queries that provide at least one of the given tags. Queries register tags via QoraOptions.providesTags. Matching follows two rules:
- Wildcard:
QueryTag('post')matches every query that provides any tag withtype: 'post', regardless ofid. - Specific:
QueryTag('post', '123')matches only queries that provide a tag withtype: 'post'andid: '123'.
// Refetch all post queries
client.invalidateTags([QueryTag('post')]);
// Refetch a specific post + its author
client.invalidateTags([QueryTag('post', postId), QueryTag('user', authorId)]);
See QoraOptions.providesTags and MutationOptions.invalidatesTags.
Cache Removal
removeQuery
void removeQuery(Object key)
Remove an entry from cache and cancel any pending request. The next access starts from Initial.
await api.deletePost(postId);
client.removeQuery(['posts', postId]);
clearCache
void clearCache()
Clear the in-memory cache only. Persisted storage is untouched.
Use clear() to wipe both (if using persistence), or clearStorage() to wipe storage only.
clear
void clear()
Remove all cached queries and cancel all pending requests.
For QoraClient, this wipes the in-memory cache (identical to clearCache).
For PersistQoraClient, this wipes BOTH in-memory cache and storage.
// On logout
client.clear();
Hydration
queueHydration
void queueHydration(Object key, dynamic data, {DateTime? updatedAt})
Enqueue a pre-deserialized value for lazy injection into the typed cache. The value is injected on the first typed API call (fetchQuery<T>, watchQuery<T>, etc.) for that key, deferring the Dart cast until the type T is known.
This is the shared mechanism used by PersistQoraClient.hydrate() and SsrHydrator.hydrate(). Direct use is for advanced scenarios (e.g. custom hydration sources).
// Manually pre-populate the cache before runApp
client.queueHydration(['users', 1], User(id: 1, name: 'Ada'));
// With a custom timestamp so staleTime evaluates correctly
client.queueHydration(
['config'],
appConfig,
updatedAt: DateTime.now().subtract(const Duration(minutes: 2)),
);
hydrateQuery
void hydrateQuery<T>(Object key, T data, {DateTime? updatedAt})
Inject a typed Success<T> directly into an Initial cache entry. No-op if the entry already has data. Used internally; prefer queueHydration when the type is not known at call time.
Debugging
debugInfo
Map<String, dynamic> debugInfo()
Returns a debug snapshot of the current cache state.
print(client.debugInfo());
// {
// total_queries: 12,
// active_queries: 3,
// inactive_queries: 9,
// pending_requests: 1,
// max_size: 200,
// keys: [['users', 1], ['posts'], ...]
// }
Lifecycle
attachLifecycleManager
void attachLifecycleManager(LifecycleManager manager)
Registers a LifecycleManager with the client. When the app transitions to LifecycleState.resumed, the client revalidates all stale entries that have active subscribers. QoraScope calls this automatically when a FlutterLifecycleManager is provided. Direct use is only necessary in custom embedding scenarios.
dispose
void dispose()
Release all resources. Stops the eviction timer, disposes all cache entries, cancels pending requests. The client must not be used after this call.
// In a DI teardown or test tearDown
client.dispose();
Recommended Setup
import 'package:flutter/foundation.dart';
import 'package:qora/qora.dart';
/// Singleton: create once, inject everywhere via DI or QoraScope.
final qoraClient = QoraClient(
config: QoraClientConfig(
defaultOptions: QoraOptions(
staleTime: Duration(minutes: 5),
gcTime: Duration(minutes: 10),
retryCount: 3,
),
debugMode: kDebugMode,
maxCacheSize: 200,
refetchOnMount: true,
errorMapper: (error, stack) => QoraException(
'$error',
originalError: error,
stackTrace: stack,
),
),
);