Query Keys
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
Recommended Patterns
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
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
@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);
}
== 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.
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
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, andFailurestate machine - Caching: LRU, GC, and cache lifecycle
- Queries:
fetchQuery,watchQuery, andprefetch
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.
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.