Flutter Integration

Best Practices

Patterns and conventions for building reliable Flutter apps with Qora: keys, query placement, mutation wiring, and performance.

Query Keys

Use arrays, not strings

Query keys are arrays. The first element is a domain namespace; subsequent elements are parameters.

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

lib/data/query_keys.dart
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];
}
main.dart
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

WidgetKey paramFetcher paramBuilder signature
QoraBuilder<T>queryKeyfetcher(BuildContext, QoraState<T>, FetchStatus)
QoraStateBuilder<T>queryKey:(BuildContext, QoraState<T>)
QoraMutationBuilder<D,V,C>:mutator(BuildContext, MutationState<D,V>, mutate)
InfiniteQueryBuilder<D,P>queryKeyfetcher(BuildContext, InfiniteQueryState<D,P>, InfiniteQueryController<D,P>)
QoraPrefetch<T>queryKeyfetcher: (wraps child)

Fetcher Placement

Keep fetchers in repository classes, not widgets

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

user_screen.dart
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 typestaleTime
Live prices or feedsDuration.zero
User profile / sessionDuration(minutes: 5)
Article contentDuration(minutes: 10)
App configurationDuration(hours: 1)
Static reference dataDuration(days: 1)

Set global defaults in QoraClientConfig and override per query only when needed:

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

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

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

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

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

main.dart
MutationOptions(retryCount: 0)

Performance

Scope QoraScope near the root

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

test/user_test.dart
late QoraClient client;

setUp(() => client = QoraClient());
tearDown(() => client.dispose());

Persistence

Register serializers before hydrate()

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

main.dart
client.registerSerializer<List<Post>>(serializer, name: 'List<Post>');

Copyright © 2026