Caching
How the Cache Works
Every query result is stored as a CacheEntry<T> in an in-memory QueryCache keyed by the normalised query key. The cache drives:
- Instant responses: fresh data is returned without a network call
- SWR: stale data is returned immediately while fresh data is fetched in the background
- Reactive updates: every
CacheEntryholds a broadcast stream; any state change reaches all active subscribers instantly
fetchQuery(['users', 1])
│
├─ Cache HIT (fresh) → return data immediately
├─ Cache HIT (stale) → return stale data + revalidate in background
└─ Cache MISS → fetch → store → return data
Cache Entry Lifecycle
A CacheEntry<T> goes through the following stages:
Created (Initial)
│ fetchQuery / watchQuery triggered
▼
Loading → Success / Failure
│ staleTime elapsed
▼
Stale (still in cache, accessible as previousData)
│ last subscriber unsubscribes
▼
GC timer starts (= gcTime)
│ timer fires, no active subscribers
▼
Evicted (removed from cache)
A background eviction sweep also runs every minute to remove expired inactive entries.
staleTime vs gcTime
These two durations control independent behaviors:
| Property | Default | Controls |
|---|---|---|
staleTime | 0 (zero) | When to trigger a background revalidation |
gcTime | 5 minutes | When to remove an unused entry from the cache |
QoraOptions(
staleTime: Duration(minutes: 5), // data is fresh for 5 minutes
gcTime: Duration(minutes: 10), // unused data stays in cache for 10 min
)
staleTime: Duration.zero (the default) means data is always considered stale. Qora still serves cached data immediately; it also revalidates in the background on every access.staleTime in Detail
staleTime defines the freshness window. During this window, data is returned from cache without any network activity:
// staleTime: 5 minutes
// t = 0:00 → fetch triggers, Success stored
// t = 0:01 → fetchQuery → Cache HIT (fresh): no network call
// t = 5:01 → fetchQuery → Cache HIT (stale): returns cached data + revalidates
// t = 5:01 → network request fires in background
// t = 5:02 → Success updated, stream subscribers notified
Set a long staleTime for data that rarely changes:
// Configuration data: valid for 1 hour
options: QoraOptions(staleTime: Duration(hours: 1)),
// User profile: valid for 10 minutes
options: QoraOptions(staleTime: Duration(minutes: 10)),
// Live prices: always stale (default)
options: QoraOptions(staleTime: Duration.zero),
gcTime in Detail
gcTime determines how long inactive data lingers in cache after all subscribers leave. It preserves data for fast re-navigation:
// User navigates away → last subscriber cancels → GC timer starts
// User navigates back within gcTime → entry still in cache → instant display
// User navigates back after gcTime → entry evicted → full fetch required
// Posts list: keep in cache for 2 minutes after leaving the page
options: QoraOptions(gcTime: Duration(minutes: 2)),
gcTime: Duration.zero removes the entry as soon as all subscribers leave. The next visit always starts from Initial. This is the correct behavior for sensitive data that must not outlive the current session.Structural Sharing
Structural sharing preserves referential equality for unchanged nested data across fetches. When a fetch returns data that is deeply equal to the existing cached data, Qora keeps the previous data reference instead of replacing it.
This is enabled by default (QoraOptions.structuralSharing: true). It directly addresses unnecessary widget rebuilds in deeply nested UIs:
// Without structural sharing: every fetch creates a new data reference,
// causing all widgets consuming this query to rebuild.
//
// With structural sharing (default): if the returned data is structurally
// identical to the cached data, the old reference is preserved. Widgets
// that check identical(oldData, newData) or == skip re-rendering.
final client = QoraClient();
await client.fetchQuery<Map<String, dynamic>>(
key: ['config'],
fetcher: () => api.getConfig(),
options: QoraOptions(structuralSharing: true),
);
How It Works
The deep equality comparison is recursive:
- Map: compares key presence and values recursively
- List: compares element count and elements recursively
- Set: compares membership
- Other types: uses
==(override==andhashCodeon custom model classes for best results)
final oldData = {'user': {'name': 'Alice', 'roles': ['admin']}};
final newData = {'user': {'name': 'Alice', 'roles': ['admin']}};
// structuralShare returns oldData: they are deeply equal.
// Any widget holding oldData will see identical(oldData, newData) == true.
When to Disable
Disable structural sharing only when the deep-equality check is a measured bottleneck with very large data structures:
QoraOptions(structuralSharing: false),
Scopes
Structural sharing applies to all cache write paths:
fetchQueryafter a successful network responsesetQueryDatafor manual cache writessetInfiniteQueryDataandupdateInfiniteQueryStatefor infinite querieshydrateQuerywhen restoring persisted data into a non-initial entry
Transformation Pipeline
Qora lets you decouple data transformation from the fetcher via QoraOptions.transform and QoraOptions.transformError. This keeps the fetcher simple (raw API call) and the transformation pure (testable in isolation).
transform: success path
Called after the fetcher succeeds but before the data enters the cache (and before structural sharing). The transformed value is what gets cached and returned to callers.
fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId), // returns Map<String, dynamic>
options: QoraOptions(
transform: (raw) => User.fromJson(raw as Map<String, dynamic>),
),
);
transformError: error path
Called after all retries fail but before the error enters the cache and before QoraClientConfig.errorMapper. Use it to normalise error types at the query level.
fetchQuery<Post>(
key: ['post', postId],
fetcher: () => api.getPost(postId),
options: QoraOptions(
transformError: (error) => AppError.fromException(error),
),
);
Keep Previous Data
Use QoraOptions.keepPreviousData to suppress the loading flash during refetches. The entry stays in Success state with the previous data while the fetch is in progress, then transitions to Success with fresh data when the fetch completes.
This is especially useful for offset-based pagination, where changing the page parameter triggers a fetch but you want the current page visible until the next one is ready:
fetchQuery<List<Post>>(
key: ['posts', 'page', page],
fetcher: () => api.getPosts(page: page, limit: 20),
options: QoraOptions(
keepPreviousData: true,
staleTime: Duration.zero,
),
);
On fetch failure, the entry stays in Success with the previous data instead of transitioning to Failure. The error is still propagated to imperative callers (fetchQuery) so error handling (e.g. toasts, retry buttons) works as usual.
LRU Eviction
When maxCacheSize is set on QoraClientConfig, the cache enforces a limit using an LRU (Least Recently Used) strategy:
- The entry that has been accessed least recently and has no active subscribers is evicted to make room
- Active entries (with subscribers) are never forcibly evicted
final client = QoraClient(
config: QoraClientConfig(maxCacheSize: 100),
);
Each call to fetchQuery, watchQuery, or getQueryData updates lastAccessedAt, keeping entries alive in the LRU ranking.
Manual Cache Control
Read the cache
// Get just the data (null if not Success / no previousData)
final user = client.getQueryData<User>(['users', userId]);
// Get the full state
final state = client.getQueryState<User>(['users', userId]);
// Inspect all cached keys
for (final key in client.cachedKeys) {
print(key);
}
Write to the cache
// Inject data directly: all active watchQuery streams are notified
client.setQueryData<User>(['users', userId], updatedUser);
// Roll back to a previous snapshot
final snapshot = client.getQueryData<User>(['users', userId]);
client.setQueryData(['users', userId], optimisticUser);
try {
await api.updateUser(userId, payload);
} catch (_) {
client.restoreQueryData(['users', userId], snapshot);
}
Invalidate (mark stale)
// Single key: active streams transition to Loading immediately
client.invalidate(['users', userId]);
// Multiple keys via predicate
client.invalidateWhere((key) => key.firstOrNull == 'users');
// Tag-based: refetch all queries providing the 'post' tag
client.invalidateTags([QueryTag('post')]);
// Specific resource: refetch only queries providing QueryTag('post', '123')
client.invalidateTags([QueryTag('post', '123')]);
Tag-based invalidation is the most flexible approach. Queries declare tags via providesTags in QoraOptions. Mutations declare tags via invalidatesTags in MutationOptions. Tags decouple invalidation from query-key structure: you can invalidate queries across different key patterns with a single tag.
options: QoraOptions(
providesTags: [QueryTag('post'), QueryTag('user', userId)],
)
See MutationOptions.invalidatesTags and QoraOptions.providesTags.
Remove and clear
// Remove a single entry (GC is immediate)
client.removeQuery(['users', userId]);
// Wipe the entire cache (e.g. on logout)
client.clear();
onCacheEvict Callback
React to cache evictions for logging or storage sync:
final client = QoraClient(
config: QoraClientConfig(
onCacheEvict: (key) {
analytics.track('cache_evict', {'key': key.toString()});
},
),
);
Debug Snapshot
print(client.debugInfo());
// {
// total_queries: 12,
// active_queries: 3,
// inactive_queries: 9,
// non_success_queries: 1,
// pending_requests: 2,
// max_size: 100,
// keys: [['users', 1], ['posts'], ...]
// }
Global vs Per-query Config
gcTime and staleTime can be set globally via QoraClientConfig.defaultOptions and overridden per query:
final client = QoraClient(
config: QoraClientConfig(
defaultOptions: QoraOptions(
staleTime: Duration(minutes: 5), // global default
gcTime: Duration(minutes: 10),
),
),
);
// This query overrides staleTime only
await client.fetchQuery<Config>(
key: ['config'],
fetcher: api.getConfig,
options: QoraOptions(staleTime: Duration(hours: 1)),
// gcTime still inherits 10 minutes from global
);
Next Steps
- Stale-While-Revalidate: How background revalidation works
- Deduplication: One network request for many callers
- QoraClientConfig API: Full configuration reference
Query States
Query states represent the lifecycle of your data fetching operations. Qora uses sealed classes to provide exhaustive, type-safe state handling that eliminates entire categories of runtime errors.
Stale-While-Revalidate
Qora SWR returns cached data immediately and revalidates in the background to keep UIs responsive and data current.