Core Concepts

Queries

Queries are the foundation of Qora. A query is an async request bound to a key, with automatic caching, deduplication, retries, and reactive state.

Anatomy of a Query

Every Qora query is composed of three elements:

user_repository.dart
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
keys.dart
// 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'})
Keys use deep structural equality. ['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:

user_repository.dart
// Inline: simple case
fetcher: () => api.getUser(userId),

// Async closure with dependencies
fetcher: () async {
  final token = await authService.getToken();
  return api.getUser(userId, token: token);
},
The fetcher must throw on failure. Returning null or swallowing errors prevents Qora from transitioning to Failure state and triggering retries.

3. Options

QoraOptions configures caching, retries, and polling per query:

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

user_repository.dart
final user = await client.fetchQuery<User>(
  key: ['users', userId],
  fetcher: () => api.getUser(userId),
);

Cache behaviour:

StateAction
Fresh (not stale)Return cached data, no network call
StaleReturn stale data immediately, refetch in background
MissingFetch, await, and return result

watchQuery: Reactive stream

Returns a Stream<QoraState<T>>. Suitable for UI bindings:

user_page.dart
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 refetchInterval is 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:

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

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

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

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

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

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

Copyright © 2026