Best Practices
Query Keys
Use arrays, not strings
Query keys are arrays. The first element is a domain namespace; subsequent elements are parameters.
// Good: structured, invalidatable by prefix
['users', userId]
['users', userId, 'posts']
['posts', {'page': 2, 'tag': 'flutter'}]
// Avoid: opaque, hard to invalidate by group
'user-$userId'
'posts-page-2'
Centralise keys in a file
Defining keys in one place prevents typos and makes bulk invalidation easy:
abstract final class QueryKeys {
static List<Object?> users() => ['users'];
static List<Object?> user(int id) => ['users', id];
static List<Object?> userPosts(int userId) => ['users', userId, 'posts'];
static List<Object?> posts() => ['posts'];
static List<Object?> post(int id) => ['posts', id];
}
QoraBuilder<User>(
queryKey: QueryKeys.user(userId),
fetcher: () => api.getUser(userId),
builder: (context, state, fetchStatus) { ... },
)
// Invalidate all user queries after a profile update
client.invalidateWhere((key) => key.firstOrNull == 'users');
Widget API Quick Reference
| Widget | Key param | Fetcher param | Builder signature |
|---|---|---|---|
QoraBuilder<T> | queryKey | fetcher | (BuildContext, QoraState<T>, FetchStatus) |
QoraStateBuilder<T> | queryKey | : | (BuildContext, QoraState<T>) |
QoraMutationBuilder<D,V,C> | : | mutator | (BuildContext, MutationState<D,V>, mutate) |
InfiniteQueryBuilder<D,P> | queryKey | fetcher | (BuildContext, InfiniteQueryState<D,P>, InfiniteQueryController<D,P>) |
QoraPrefetch<T> | queryKey | fetcher | : (wraps child) |
Fetcher Placement
Keep fetchers in repository classes, not widgets
class UserRepository {
final QoraClient _client;
final UserApi _api;
UserRepository(this._client, this._api);
Stream<QoraState<User>> watchUser(int id) => _client.watchQuery(
key: QueryKeys.user(id),
fetcher: () => _api.getUser(id),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
);
Future<void> updateUser(int id, String name) async {
final snapshot = _client.getQueryData<User>(QueryKeys.user(id));
_client.setQueryData(QueryKeys.user(id), User(id: id, name: name));
try {
await _api.updateUser(id, name);
} catch (_) {
_client.restoreQueryData(QueryKeys.user(id), snapshot);
rethrow;
}
}
}
Widget code becomes a thin binding:
QoraBuilder<User>(
queryKey: QueryKeys.user(widget.userId),
fetcher: () => repo.fetchUser(widget.userId),
builder: (context, state, fetchStatus) { ... },
)
staleTime Guidelines
Set staleTime based on how often the data changes server-side:
| Data type | staleTime |
|---|---|
| Live prices or feeds | Duration.zero |
| User profile / session | Duration(minutes: 5) |
| Article content | Duration(minutes: 10) |
| App configuration | Duration(hours: 1) |
| Static reference data | Duration(days: 1) |
Set global defaults in QoraClientConfig and override per query only when needed:
QoraClientConfig(
defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)),
)
FetchStatus: Offline Awareness
The third arg of QoraBuilder.builder is FetchStatus. Use it to show an offline indicator instead of a spinner:
QoraBuilder<User>(
queryKey: ['users', userId],
fetcher: () => api.getUser(userId),
builder: (context, state, fetchStatus) {
if (fetchStatus == FetchStatus.paused) {
return OfflineBanner(staleData: state.dataOrNull);
}
return switch (state) {
Success(:final data) => UserCard(data),
Loading() => const CircularProgressIndicator(),
Failure(:final error) => ErrorWidget('$error'),
Initial() => const SizedBox.shrink(),
};
},
)
Avoid Over-fetching
Use QoraStateBuilder for derived UI
QoraStateBuilder subscribes to a query's state without triggering a fetch. Use it for components that display data but are not responsible for loading it:
QoraStateBuilder<User>(
queryKey: QueryKeys.user(userId),
builder: (context, state) => switch (state) {
Success(:final data) => Avatar(url: data.avatarUrl),
_ => const DefaultAvatar(),
},
)
Use QoraPrefetch before navigation
QoraPrefetch is a widget that pre-warms the cache as soon as it is mounted: no fetch triggers in build callbacks:
MouseRegion(
onEnter: (_) => setState(() => _hover = true),
child: _hover
? QoraPrefetch<User>(
queryKey: QueryKeys.user(user.id),
fetcher: () => api.getUser(user.id),
child: UserListTile(user: user),
)
: UserListTile(user: user),
)
Mutations
Correct widget: QoraMutationBuilder
QoraMutationBuilder<User, String, User?>(
mutator: (name) => api.updateUser(userId, name),
options: MutationOptions(
onMutate: (name) async =>
QoraScope.of(context).getQueryData<User>(['users', userId]),
onError: (error, name, snapshot) async =>
QoraScope.of(context).restoreQueryData(['users', userId], snapshot),
onSuccess: (data, name, _) async {
QoraScope.of(context).setQueryData(['users', userId], data);
QoraScope.of(context).invalidateWhere((k) => k.firstOrNull == 'users');
},
),
builder: (context, state, mutate) => ElevatedButton(
onPressed: state.isPending ? null : () => mutate(nameController.text),
child: state.isPending
? const CircularProgressIndicator()
: const Text('Save'),
),
)
Use retryCount: 0 on mutations
Mutations are not idempotent by default. Avoid automatic retries unless your API is designed to handle them:
MutationOptions(retryCount: 0)
Performance
Scope QoraScope near the root
final client = QoraClient(
config: const QoraClientConfig(debugMode: kDebugMode),
);
runApp(
QoraScope(
client: client,
lifecycleManager: FlutterLifecycleManager(qoraClient: client),
connectivityManager: FlutterConnectivityManager(),
child: const MyApp(),
),
);
QoraScope disposes the client, lifecycle manager, and connectivity manager automatically on unmount: do not call client.dispose() manually when using QoraScope.
One client per app
Create QoraClient (or PersistQoraClient) once at startup and reuse it everywhere via QoraScope. Multiple instances do not share a cache.
Dispose clients in tests
In tests that instantiate a client directly (without QoraScope), call client.dispose() in tearDown:
late QoraClient client;
setUp(() => client = QoraClient());
tearDown(() => client.dispose());
Persistence
Register serializers before hydrate()
final client = PersistQoraClient(storage: storage);
client.registerSerializer<User>(
QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson),
name: 'User', // stable name: safe under --obfuscate
);
await client.hydrate();
runApp(...);
Always pass an explicit name
T.toString() is mangled by --obfuscate and minification on Flutter Web. A stable name survives across releases:
client.registerSerializer<List<Post>>(serializer, name: 'List<Post>');