Core Concepts

Caching

Qora's cache enables instant data access. Learn how cache entries are created, aged, and garbage collected, how structural sharing prevents unnecessary rebuilds, and how to control each behavior.

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 CacheEntry holds 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:

PropertyDefaultControls
staleTime0 (zero)When to trigger a background revalidation
gcTime5 minutesWhen to remove an unused entry from the cache
main.dart
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:

main.dart
// 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:

main.dart
// 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:

main.dart
// 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
main.dart
// 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:

main.dart
// 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 == and hashCode on custom model classes for best results)
main.dart
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:

main.dart
QoraOptions(structuralSharing: false),

Scopes

Structural sharing applies to all cache write paths:

  • fetchQuery after a successful network response
  • setQueryData for manual cache writes
  • setInfiniteQueryData and updateInfiniteQueryState for infinite queries
  • hydrateQuery when 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.

user_repository.dart
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.

post_repository.dart
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:

post_list.dart
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
main.dart
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

main.dart
// 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

main.dart
// 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)

main.dart
// 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.

main.dart
options: QoraOptions(
  providesTags: [QueryTag('post'), QueryTag('user', userId)],
)

See MutationOptions.invalidatesTags and QoraOptions.providesTags.

Remove and clear

main.dart
// 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:

main.dart
final client = QoraClient(
  config: QoraClientConfig(
    onCacheEvict: (key) {
      analytics.track('cache_evict', {'key': key.toString()});
    },
  ),
);

Debug Snapshot

main.dart
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:

main.dart
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

Copyright © 2026