Queries
Anatomy of a Query
Every Qora query is composed of three elements:
await client.fetchQuery<User>(
// 1. Query Key: unique identifier for this data
key: ['users', userId],
// 2. Fetcher: async function that returns the data
fetcher: () => api.getUser(userId),
// 3. Options (optional): per-query configuration
options: QoraOptions(
staleTime: Duration(minutes: 5),
retryCount: 3,
),
);
1. Query Key
The query key uniquely identifies data in the cache. It drives:
- Cache lookups (hit / miss)
- Background revalidation
- Targeted invalidation after mutations
// Single segment
['users']
// Entity + ID
['users', userId]
// Entity + filters (nested map)
['posts', {'status': 'published', 'page': page}]
// Typed wrapper (identical semantics)
QoraKey.withId('users', userId)
QoraKey.withFilter('posts', {'status': 'published'})
['users', 1] and ['users', 1] are identical even when created independently.See Query Keys for a complete guide.
2. Fetcher
The fetcher is any Future<T> Function() that returns your data:
// Inline: simple case
fetcher: () => api.getUser(userId),
// Async closure with dependencies
fetcher: () async {
final token = await authService.getToken();
return api.getUser(userId, token: token);
},
null or swallowing errors prevents Qora from transitioning to Failure state and triggering retries.3. Options
QoraOptions configures caching, retries, and polling per query:
options: QoraOptions(
// Data freshness window (0 = always stale)
staleTime: Duration(minutes: 5),
// How long unused data stays in cache
gcTime: Duration(minutes: 10),
// Automatic retry on failure
retryCount: 3,
retryDelay: Duration(seconds: 1), // base delay for exponential backoff
// Disable the query conditionally
enabled: userId != null,
// Polling interval (watchQuery only)
refetchInterval: Duration(seconds: 30),
)
See QoraOptions for all fields.
Two Ways to Fetch
fetchQuery: One-shot
Returns a Future<T>. Suitable for imperative calls (button tap, page load):
final user = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
);
Cache behaviour:
| State | Action |
|---|---|
| Fresh (not stale) | Return cached data, no network call |
| Stale | Return stale data immediately, refetch in background |
| Missing | Fetch, await, and return result |
watchQuery: Reactive stream
Returns a Stream<QoraState<T>>. Suitable for UI bindings:
StreamBuilder<QoraState<User>>(
stream: client.watchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
),
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) => Text('Error: $error'),
};
},
)
The stream:
- Emits immediately with the current cached state on subscription
- Auto-fetches if data is missing or stale (controlled by
refetchOnMount) - Pushes updates from any source: fetch,
setQueryData,invalidate - Polls when
refetchIntervalis set and the stream is active - Schedules GC when the last subscriber cancels
prefetch: Background preloading
Populate the cache before the user navigates to a screen:
// On hover / likely tap
onEnter: (_) => client.prefetch<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
),
No-op if the data is already fresh. Does not create a stream.
Query Lifecycle
[Initial]
│ first watchQuery / fetchQuery
▼
[Loading(previousData: null)]
│ fetcher succeeds
▼
[Success(data, updatedAt)]
│ staleTime elapsed → background refetch
▼
[Loading(previousData: data)] ← stale data visible during refetch
│ fetcher succeeds
▼
[Success(newData, updatedAt)]
│ staleTime elapsed → fetcher fails after all retries
▼
[Failure(error, previousData: data)] ← previous data still accessible
See Query States for full documentation.
Invalidation
Mark queries as stale after a mutation. Active watchQuery streams transition to Loading immediately:
// After creating a post
await api.createPost(payload);
// Invalidate exact key
client.invalidate(['posts', postId]);
// Invalidate all posts queries
client.invalidateWhere((key) => key.firstOrNull == 'posts');
Manual Cache Updates
Push data directly into the cache. All active streams receive the update instantly:
// Snapshot before update (for rollback)
final snapshot = client.getQueryData<User>(['users', userId]);
// Optimistic update: UI reflects change immediately
client.setQueryData(['users', userId], user.copyWith(name: 'Alice'));
try {
await api.updateUser(userId, payload);
} catch (_) {
// Roll back on failure
client.restoreQueryData(['users', userId], snapshot);
}
Complete Example
import 'package:qora/qora.dart';
class UserRepository {
final QoraClient _client;
final UserApi _api;
UserRepository(this._client, this._api);
Future<User> getUser(int userId) => _client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => _api.getUser(userId),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
);
Stream<QoraState<User>> watchUser(int userId) => _client.watchQuery<User>(
key: ['users', userId],
fetcher: () => _api.getUser(userId),
options: const QoraOptions(
staleTime: Duration(minutes: 5),
refetchInterval: Duration(minutes: 1),
),
);
void invalidateUser(int userId) => _client.invalidate(['users', userId]);
void patchLocally(User updated) =>
_client.setQueryData(['users', updated.id], updated);
}
Best Practices
Do
// Consistent key structure: entity first, then ID/filters
['users', userId]
['posts', {'status': 'published', 'page': page}]
// Always throw on error: never return null
fetcher: () => api.getUser(userId),
// Handle all four states exhaustively
switch (state) {
case Initial(): ...
case Loading(): ...
case Success(): ...
case Failure(): ...
}
Avoid
// Inconsistent key order
['users', userId]
[userId, 'users'] // Different cache entry
// Swallowing errors
fetcher: () async {
try { return await api.getUser(userId); }
catch (_) { return null; } // Qora never sees the failure
},
// Objects without == override in keys
['users', UserFilter(status: 'active')] // Always a cache miss
Next Steps
- Query Keys: Key structure, equality, and invalidation patterns
- Query States: Deep dive into the sealed class state machine
- Caching: LRU, stale time, GC, and cache management
- Stale-While-Revalidate: How background revalidation works