Core Concepts

Query Keys

Query keys are the foundation of Qora's caching system. They uniquely identify queries and enable automatic cache management, invalidation, and refetching.

Overview

A query key is a serializable array that Qora uses to:

  • Cache data uniquely per query
  • Trigger background revalidation when keys change
  • Share cache state across services and widgets
  • Invalidate and update related queries after mutations

Qora supports two syntaxes:

// Direct List (frictionless)
client.fetchQuery(key: ['users', userId], fetcher: () => api.getUser(userId));

// Typed Wrapper (type-safe)
client.fetchQuery(key: QoraKey.withId('users', userId), fetcher: () => api.getUser(userId));

Both forms are 100% interchangeable. They resolve to the same internal representation.


Query Key Basics

Simple Keys

// Fetch all users
final users = await client.fetchQuery<List<User>>(
  key: ['users'],
  fetcher: fetchUsers,
);

// Fetch app settings
final settings = await client.fetchQuery<Settings>(
  key: ['settings'],
  fetcher: fetchSettings,
);

Keys with Variables

// Fetch a specific user
final user = await client.fetchQuery<User>(
  key: ['users', userId],
  fetcher: () => fetchUser(userId),
);

// Fetch filtered posts
final posts = await client.fetchQuery<List<Post>>(
  key: ['posts', {'status': 'published', 'author': authorId}],
  fetcher: () => fetchPosts(status: 'published', author: authorId),
);

Hierarchical Keys

// User profile
['users', userId, 'profile']

// User's posts
['users', userId, 'posts']

// Specific post comments
['posts', postId, 'comments']

// Paginated data
['posts', {'page': 2, 'limit': 20}]

Key Equality

Qora uses deep equality to compare query keys. Two keys are equal if their content is identical, not their reference.

Primitive Values

['users', 1] == ['users', 1]  // true
['users', 1] == ['users', 2]  // false

Lists and Nesting

['users', [1, 2, 3]] == ['users', [1, 2, 3]]  // true
['users', [1, 2]] == ['users', [2, 1]]        // false (order matters)

Maps (Order-Independent)

Maps are compared by their content, regardless of key insertion order:

['posts', {'status': 'active', 'limit': 10}]
  ==
['posts', {'limit': 10, 'status': 'active'}]  // true

This differs from TanStack Query (JavaScript), where object key order matters.

Null Values

['users', null] == ['users', null]  // true
['users', null] == ['users', 1]     // false

Query Key Structure

Pattern 1: Entity + ID

['users', userId]
['posts', postId]
['products', sku]

Pattern 2: Entity + Filters

['posts', {'status': 'published'}]
['users', {'role': 'admin', 'active': true}]
['products', {'category': 'electronics', 'inStock': true}]

Pattern 3: Entity + ID + Relation

['users', userId, 'posts']
['posts', postId, 'comments']
['orders', orderId, 'items']

Pattern 4: Pagination

// Offset-based
['posts', {'page': pageNumber}]

// Cursor-based
['messages', {'cursor': lastMessageId, 'limit': 50}]

Anti-Patterns

Non-serializable objects cause silent cache misses:

['users', StreamController()]  // Non-serializable instance
['data', widget]               // Widget instance

Non-deterministic values cause cache misses on every call:

['posts', DateTime.now()]     // Non-deterministic
['users', Random().nextInt()] // Changes on every call

Excessive nesting reduces readability without benefit:

['a', ['b', ['c', ['d', 'e']]]]  // Avoid
['posts', {'status': 'published', 'limit': 10}]  // Prefer flat structure

Using Custom Objects in Keys

Custom objects are valid key parts only when they override == and hashCode.

With Equatable

post_filter.dart
import 'package:equatable/equatable.dart';

@immutable
class PostFilter extends Equatable {
  final String status;
  final int limit;

  const PostFilter({required this.status, required this.limit});

  @override
  List<Object?> get props => [status, limit];
}

// Usage
final posts = await client.fetchQuery<List<Post>>(
  key: ['posts', PostFilter(status: 'published', limit: 10)],
  fetcher: () => fetchPosts(status: 'published', limit: 10),
);

Manual Override

user_query.dart
@immutable
class UserQuery {
  final String role;
  final int minAge;

  const UserQuery({required this.role, required this.minAge});

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is UserQuery &&
      role == other.role &&
      minAge == other.minAge;

  @override
  int get hashCode => Object.hash(role, minAge);
}
Without == and hashCode overrides, caching fails silently. Each object instance is treated as a distinct key, resulting in a cache miss on every call.

The QoraKey Class (Optional)

QoraKey is a typed wrapper that makes key construction explicit and reusable.

Factory Constructors

// Single-part key
client.fetchQuery(key: QoraKey.single('settings'), fetcher: fetchSettings);

// Entity + ID
client.fetchQuery(key: QoraKey.withId('users', userId), fetcher: () => fetchUser(userId));

// Entity + filter map
client.fetchQuery(
  key: QoraKey.withFilter('posts', {'status': 'published'}),
  fetcher: () => fetchPosts(status: 'published'),
);

// Custom parts
final key = QoraKey(['custom', 'key', 'parts']);

When to Use QoraKey

Use QoraKey when:

  • Building a shared key factory used across multiple services
  • Working in a large team where self-documenting APIs reduce coordination overhead
  • Explicit type safety is preferred over brevity

Use a raw List when:

  • Prototyping or writing one-off queries
  • Minimal syntax is preferred

Query Key Factories

Centralized key factories ensure consistency and enable refactoring of key structure in one place.

query_keys.dart
class QueryKeys {
  QueryKeys._();

  // Static keys (no allocation on repeated calls)
  static const settings = QoraKey(['settings']);
  static const currentUser = QoraKey(['users', 'current']);

  // Dynamic keys
  static QoraKey user(String id) => QoraKey.withId('users', id);

  static QoraKey userPosts(String userId) =>
    QoraKey(['users', userId, 'posts']);

  static QoraKey posts({String? status, int? limit}) =>
    QoraKey.withFilter('posts', {
      if (status != null) 'status': status,
      if (limit != null) 'limit': limit,
    });
}

// Usage
await client.fetchQuery<User>(
  key: QueryKeys.user('123'),
  fetcher: () => fetchUser('123'),
);

await client.fetchQuery<List<Post>>(
  key: QueryKeys.posts(status: 'published', limit: 10),
  fetcher: () => fetchPosts(status: 'published', limit: 10),
);

Factory with Prefix Invalidation

post_query_keys.dart
class PostQueryKeys {
  static const _prefix = ['posts'];

  static List<dynamic> get all => _prefix;

  static List<dynamic> list([Map<String, dynamic>? filters]) =>
    filters != null ? [..._prefix, 'list', filters] : [..._prefix, 'list'];

  static List<dynamic> detail(String id) => [..._prefix, 'detail', id];

  static List<dynamic> comments(String id) =>
    [..._prefix, 'detail', id, 'comments'];
}

// Invalidate all post-related queries after a mutation
client.invalidateWhere((key) =>
  key.isNotEmpty && key[0] == 'posts'
);

Invalidation Strategies

Exact Match

client.invalidate(['users', userId]);

Predicate-Based Invalidation

invalidateWhere receives the normalized key as List<dynamic>:

// Invalidate all user queries
client.invalidateWhere((key) => key.firstOrNull == 'users');

// Invalidate all queries for a specific user
client.invalidateWhere((key) =>
  key.length >= 2 && key[0] == 'users' && key[1] == userId
);

Filter-Based Invalidation

invalidateQueries receives key, current state, and last options for richer selection:

// Invalidate all failing queries that use a refetch interval
client.invalidateQueries(
  filter: (key, state, opts) =>
      state is Failure && opts?.refetchInterval != null,
);

Hierarchical Invalidation

Structure keys hierarchically to batch-invalidate related queries:

// Invalidate all data associated with a specific user
client.invalidateWhere((key) =>
  key.length >= 2 && key[0] == 'users' && key[1] == userId
);

Cross-Pattern Compatibility

List and QoraKey are 100% interchangeable:

// Set with List
await client.fetchQuery<User>(
  key: ['users', 123],
  fetcher: () => fetchUser(123),
);

// Cache hit with QoraKey (same entry)
await client.fetchQuery<User>(
  key: QoraKey.withId('users', 123),
  fetcher: () => throw Exception('Not called'),
);

// Invalidate with either form
client.invalidate(['users', 123]);
client.invalidate(QoraKey.withId('users', 123));

Performance Considerations

Every key is deep-copied and made unmodifiable on ingestion. Mutating the original list after passing it has no effect:

final key = ['users', 123];
client.fetchQuery(key: key, fetcher: () => fetchUser(123));
key.add('extra'); // No effect: the internal copy is already immutable

For frequently referenced static keys, declare them const to eliminate repeated allocations:

class QueryKeys {
  static const settings = QoraKey(['settings']);
  static const currentUser = QoraKey(['users', 'current']);
}

Keep keys shallow. Maps incur a sort pass for equality checks. Nesting beyond three levels adds overhead without benefit.


Best Practices Summary

Do

// Primitives
['users', 123, 'profile']

// Maps for filters
['posts', {'status': 'published', 'limit': 10}]

// Hierarchical structure for batch invalidation
['entity', id, 'relation']

// Centralized factories
QueryKeys.user(userId)

// const for static keys
static const settings = QoraKey(['settings']);

Avoid

// Non-serializable instances
['users', StreamController()]

// Non-deterministic values
['posts', DateTime.now()]

// Excessive nesting
['a', ['b', ['c', ['d']]]]

// Objects without == override
['filter', UnequatableClass()]

Troubleshooting

Cache always misses

Symptom: every fetch triggers a network request.

Diagnosis: custom objects in the key do not override == and hashCode.

// Confirm key equality
print(['users', customObject1] == ['users', customObject2]);
// Should print true if the objects represent the same data

Fix: add == and hashCode overrides, or use Equatable.


ArgumentError on key

Symptom: ArgumentError: Key must be QoraKey or List<dynamic>

Cause: a non-List, non-QoraKey value was passed as the key.

client.fetchQuery(key: 'string', ...); // Wrong
client.fetchQuery(key: ['string'], ...); // Correct

Unexpected refetches

Symptom: query refetches even though the key appears identical.

Cause: the key contains a non-deterministic value that changes on every build.

['posts', DateTime.now()] // Rebuilds create a new key each time

Fix: use stable, deterministic values in keys.


Next Steps

  • Query States: Loading, Success, and Failure state machine
  • Caching: LRU, GC, and cache lifecycle
  • Queries: fetchQuery, watchQuery, and prefetch
Copyright © 2026