# What is Qora? **Qora** is a server-state library for Flutter and Dart. It fetches, caches and synchronizes the data your app loads from an API, and keeps the app usable when the network is not. If you know [TanStack Query](https://tanstack.com/query){rel=""nofollow""}, you already know the model: server data is a cache with a lifecycle, not global UI state. Qora brings that model to Dart 3 with sealed classes and pattern matching, and no code generation. ## The problem it solves Data that comes from a server is not like a counter or a form field. It can be stale, it is shared between screens, it changes when someone writes to it, and it disappears when the network does. Handled by hand, every screen ends up with the same code: ```dart [without_qora.dart] class _UserScreenState extends State { User? _user; Object? _error; bool _loading = false; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() => _loading = true); try { _user = await api.getUser(widget.userId); // fetched again on every visit _error = null; } catch (e) { _error = e; // and the previous user is lost on error } finally { setState(() => _loading = false); } } // ...and nothing refreshes this screen when another one edits the user. } ``` With Qora, the screen declares which data it needs and how to render each state: ```dart [user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => api.getUser(userId), builder: (context, state, fetchStatus) => switch (state) { Success(:final data) => UserCard(data), Failure(:final error, :final previousData) => previousData != null ? UserCard(previousData) : ErrorView(error), Loading(:final previousData) => previousData != null ? UserCard(previousData) : const LoadingView(), Initial() => const SizedBox.shrink(), }, ) ``` The second visit renders the cached user on the first frame and refreshes it in the background. Two widgets asking for `['users', userId]` share one request. After `client.invalidate(['users', userId])`, every screen showing that user refetches. The compiler checks that every state is handled. ## What you get **Fetching and caching** - Stale-while-revalidate: cached data first, fresh data in the background - Request deduplication and automatic retries with exponential backoff - Invalidation by exact key, key prefix, predicate or tag - Infinite queries, dependent queries, polling, prefetching **Offline** - `PersistQoraClient`: cached data saved to storage and restored before the first frame - Queries that pause while offline, report it through `FetchStatus.paused`, and resume on reconnect - An offline mutation queue that replays writes in order when the network returns (held in memory; persisted across restarts in 1.4) **Writes** - Mutations with `onMutate` / `onError` / `onSuccess` for optimistic updates and rollback - Automatic invalidation after a successful mutation **Tooling** - An in-app DevTools overlay showing every query and mutation in debug builds ## Packages | Package | Use it for | | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`qora_flutter`](https://pub.dev/packages/qora_flutter){rel=""nofollow""} | Flutter apps. Widgets (`QoraScope`, `QoraBuilder`, `QoraMutationBuilder`, `InfiniteQueryBuilder`), lifecycle and connectivity managers. Includes `qora`. | | [`qora_hooks`](https://pub.dev/packages/qora_hooks){rel=""nofollow""} | Apps using `flutter_hooks`: `useQuery`, `useMutation`, `useInfiniteQuery`, `useFetchStatus`. | | [`qora`](https://pub.dev/packages/qora){rel=""nofollow""} | Pure Dart: CLIs, servers, shared packages, tests. No Flutter dependency. | | [`qora_devtools_overlay`](https://pub.dev/packages/qora_devtools_overlay){rel=""nofollow""} | The in-app debug panel, as a dev dependency. | ## Server state and client state Qora manages **server state**: data that lives on a server and is fetched, cached and re-synchronized. It does not replace your state management for **client state**: the selected tab, form input, the current theme. Keep Riverpod, BLoC or Provider for those; the [integration guides](https://qora.meragix.dev/../third-party/with-riverpod) show how they fit together. ## When not to use Qora - **A read-only app that is always online and never shows the same data twice.** A `FutureBuilder` is enough. - **Real-time data pushed over a socket.** You can write socket events into the cache with `setQueryData`, but the stream itself is not Qora's job. For a side-by-side look at `cached_query` and `flutter_query`, see the [comparison](https://qora.meragix.dev/comparison). ## Requirements - Dart **3.5** or later - Flutter **3.24** or later (for `qora_flutter` and `qora_hooks`) ## Next steps - [Installation](https://qora.meragix.dev/installation): add Qora and wrap your app - [Quick Start](https://qora.meragix.dev/quick-start): a list and a detail screen, then take them offline - [Coming from TanStack Query](https://qora.meragix.dev/coming-from-tanstack-query): the API mapped option by option # Installation ## Flutter ::steps{level="3"} ### Add the dependency ```bash [Terminal] flutter pub add qora_flutter ``` This adds `qora_flutter: ^1.3.1` to `pubspec.yaml`. It brings `qora` and `connectivity_plus` with it; no other entry is needed. ### Wrap your app Create one `QoraClient` and put it above your app with `QoraScope.auto`: ```dart [main.dart] import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; void main() { final client = QoraClient( config: QoraClientConfig( defaultOptions: const QoraOptions(staleTime: Duration(minutes: 5)), debugMode: kDebugMode, ), ); runApp(QoraScope.auto(client: client, child: const MyApp())); } ``` `QoraScope.auto` also starts two managers, so you get these behaviours without extra code: - **Lifecycle**: when the app comes back to the foreground after at least 5 seconds, stale queries that are on screen refetch. - **Connectivity**: queries pause while the device is offline and refetch when it reconnects. ### Use it Any widget below `QoraScope` can now use `QoraBuilder`, `QoraMutationBuilder`, or `context.qora` for imperative calls. The [Quick Start](https://qora.meragix.dev/quick-start) builds a first screen. :: ### Configuration notes - **`staleTime`** is how long fetched data counts as fresh. The default is zero: data is always stale and revalidated in the background when a widget mounts. Five minutes is a reasonable starting point for most screens. - **`debugMode: kDebugMode`** prints cache activity in debug builds only. - To tune the managers (for example a longer background delay), pass them yourself with the default constructor: ```dart [main.dart] runApp( QoraScope( client: client, lifecycleManager: FlutterLifecycleManager( qoraClient: client, minBackgroundDuration: const Duration(seconds: 30), ), connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ); ``` `QoraScope` without managers works too, but then nothing refetches on resume or reconnect. --- ## Hooks If your app uses `flutter_hooks`, add `qora_hooks` as well: ```bash [Terminal] flutter pub add qora_hooks flutter_hooks ``` Setup is the same `QoraScope.auto`. See [Hooks](https://qora.meragix.dev/../flutter-integration/hooks). --- ## Pure Dart For CLIs, servers and shared packages, use the core package. It has no Flutter dependency: ```bash [Terminal] dart pub add qora ``` ```dart [bin/main.dart] import 'package:qora/qora.dart'; Future main() async { final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)), ), ); // One-shot fetch: cached, deduplicated, retried on failure. final user = await client.fetchQuery( key: ['users', 1], fetcher: () => api.getUser(1), ); print('User: ${user.name}'); // Still fresh: served from the cache, no network call. await client.fetchQuery(key: ['users', 1], fetcher: () => api.getUser(1)); client.dispose(); } ``` `watchQuery` returns a stream of every state transition when you need reactivity outside Flutter. See [Queries](https://qora.meragix.dev/../core-concepts/queries). --- ## DevTools overlay (optional) ```bash [Terminal] flutter pub add qora_devtools_overlay ``` See [In-App Overlay](https://qora.meragix.dev/../devtools/overlay) for setup. It is a regular dependency because your `main.dart` imports it; in release builds it renders nothing and its UI is removed by tree shaking. --- ## Next Steps - [Quick Start](https://qora.meragix.dev/quick-start): build a list and a detail screen, then take them offline - [QoraScope](https://qora.meragix.dev/../flutter-integration/qora-scope): all scope options - [Persistence](https://qora.meragix.dev/../recipes/persistence): keep cached data across app restarts # Quick Start In ten minutes you will have two screens that load users from a real API, open instantly on the second visit, refresh in the background, and keep showing data when the network drops. The code below runs as is against [JSONPlaceholder](https://jsonplaceholder.typicode.com){rel=""nofollow""}, a free fake REST API. ## 1. Install and wrap your app ```bash [Terminal] flutter pub add qora_flutter http ``` ```dart [lib/main.dart] import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; import 'user_list_screen.dart'; void main() { final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)), ), ); runApp(QoraScope.auto(client: client, child: const MyApp())); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) => const MaterialApp(home: UserListScreen()); } ``` `staleTime: 5 minutes` means fetched data counts as fresh for five minutes: within that window, opening a screen again costs no network call. ## 2. Describe the data and how to fetch it ```dart [lib/user_api.dart] import 'dart:convert'; import 'package:http/http.dart' as http; class User { final int id; final String name; final String email; const User({required this.id, required this.name, required this.email}); factory User.fromJson(Map json) => User( id: json['id'] as int, name: json['name'] as String, email: json['email'] as String, ); } class UserApi { static const _base = 'https://jsonplaceholder.typicode.com'; static Future> getUsers() async { final res = await http.get(Uri.parse('$_base/users')); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); final list = jsonDecode(res.body) as List; return list.map((j) => User.fromJson(j as Map)).toList(); } static Future getUser(int id) async { final res = await http.get(Uri.parse('$_base/users/$id')); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); return User.fromJson(jsonDecode(res.body) as Map); } } ``` ::warning A fetcher must **throw** on failure. If it returns `null` or swallows the error, Qora sees a success and never retries or shows the error. :: ## 3. The list screen `QoraBuilder` fetches on mount, caches the result under its key, and rebuilds on every change. The builder receives the state, which you match exhaustively, and a `FetchStatus` that says whether a request is running. ```dart [lib/user_list_screen.dart] import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; import 'user_api.dart'; import 'user_detail_screen.dart'; class UserListScreen extends StatelessWidget { const UserListScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Users')), body: QoraBuilder>( queryKey: const ['users'], fetcher: UserApi.getUsers, builder: (context, state, fetchStatus) => switch (state) { Success(:final data) => _UserList(users: data), // A refetch or an error keeps the previous data: show it. Loading(:final previousData?) => _UserList(users: previousData), Failure(:final previousData?) => _UserList(users: previousData), Failure(:final error) => Center(child: Text('Could not load users: $error')), _ => const Center(child: CircularProgressIndicator()), }, ), ); } } class _UserList extends StatelessWidget { const _UserList({required this.users}); final List users; @override Widget build(BuildContext context) { return RefreshIndicator( // Mark the list stale: the mounted QoraBuilder refetches it. onRefresh: () async => context.qora.invalidate(const ['users']), child: ListView.builder( itemCount: users.length, itemBuilder: (context, i) => ListTile( title: Text(users[i].name), subtitle: Text(users[i].email), onTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => UserDetailScreen(userId: users[i].id), )), ), ), ); } } ``` ## 4. The detail screen Each user gets its own key, `['users', id]`. The key is the cache address: same key, same data, wherever it is used. ```dart [lib/user_detail_screen.dart] import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; import 'user_api.dart'; class UserDetailScreen extends StatelessWidget { const UserDetailScreen({super.key, required this.userId}); final int userId; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('User #$userId')), body: QoraBuilder( queryKey: ['users', userId], fetcher: () => UserApi.getUser(userId), builder: (context, state, fetchStatus) { final user = state.dataOrNull; // kept during refetches and errors if (user == null) { return switch (state) { Failure(:final error) => Center(child: Text('Error: $error')), _ => const Center(child: CircularProgressIndicator()), }; } return ListTile(title: Text(user.name), subtitle: Text(user.email)); }, ), ); } } ``` Run the app. Open a user, go back, open it again: the second time it appears instantly, with no spinner. It came from the cache. ## 5. Take it offline `QoraScope.auto` already tracks connectivity. While the device is offline, queries do not fail: they **pause**, and `fetchStatus` becomes `FetchStatus.paused`. Show it in the list screen: ```dart [lib/user_list_screen.dart] builder: (context, state, fetchStatus) => Column( children: [ if (fetchStatus == FetchStatus.paused) Container( width: double.infinity, padding: const EdgeInsets.all(8), color: Colors.amber.shade100, child: const Text('You are offline. Showing saved data.'), ), Expanded( child: switch (state) { Success(:final data) => _UserList(users: data), Loading(:final previousData?) => _UserList(users: previousData), Failure(:final previousData?) => _UserList(users: previousData), Failure(:final error) => Center(child: Text('Could not load users: $error')), _ => const Center(child: CircularProgressIndicator()), }, ), ], ), ``` Turn on airplane mode and pull to refresh: the list stays, the banner appears. Turn the network back on: the paused fetch runs by itself and the banner goes away. ## What you got for free | Situation | What Qora does | | ------------------------ | ------------------------------------------------------------------- | | First visit | `Loading`, then `Success`; the result is cached under its key | | Visit within 5 minutes | Cached data on the first frame, no network call | | Visit after 5 minutes | Cached data on the first frame, refreshed in the background | | Pull to refresh | `invalidate` marks the key stale; the mounted builder refetches | | Two widgets, same key | One request, shared by both | | Request fails | Retried 3 times with backoff, then `Failure` with the previous data | | Device offline | Fetches pause and resume on reconnect; cached data stays on screen | | App back from background | Stale queries on screen refetch | ## Next steps - [Mutations](https://qora.meragix.dev/../recipes/mutations): write data and invalidate what changed - [Persistence](https://qora.meragix.dev/../recipes/persistence): keep the cache across app restarts - [Query Keys](https://qora.meragix.dev/../core-concepts/query-keys): how to structure keys so invalidation stays easy - [Offline-First case study](https://qora.meragix.dev/../examples/offline-first): a full app that queues writes while offline # Comparison Qora, `flutter_query`, and `cached_query` are server-state libraries built for data that resides on a remote server. Provider and BLoC target client state; comparing them to Qora conflates two distinct problem domains. This page documents the meaningful differences between the three server-state solutions. Claims about other libraries are based on their public documentation as of November 2026; open an issue if something is out of date. --- ## Selection Criteria | If you need... | Best fit | | ------------------------------------------------------------ | ------------------------------------ | | A solid SWR cache with minimal setup | Any of the three | | Writes made offline, queued and replayed on reconnect | **Qora** | | Queries that pause offline and say so (`FetchStatus.paused`) | **Qora** | | Per-query network mode (online / always / offlineFirst) | **Qora** | | Thundering-herd control on reconnect | **Qora** | | Tag-based invalidation | **Qora** | | Disk persistence with a ready-made adapter | **cached\_query** (`cached_storage`) | | Disk persistence on your own storage backend | **Qora** or **cached\_query** | | A hooks-only API, closest to TanStack Query on the web | **flutter\_query** | | Hooks or widget builders, your choice | **Qora** | | Pure Dart core usable outside Flutter | **Qora** or **cached\_query** | | A tab in Flutter DevTools today | **cached\_query** | | An in-app debug overlay | **Qora** | --- ## Qora Differentiators ### Offline Architecture Offline behaviour in Qora is a set of cooperating pieces rather than a single refetch-on-reconnect flag: - **`NetworkMode` per query**: declare whether a query runs `online` only, `always` (local DB), or `offlineFirst` (serve cache, refresh in background when online). - **Offline mutation queue**: writes submitted while offline return an optimistic result, are queued in order and replayed on reconnect. The queue is held in memory: it survives connectivity changes, not an app restart. - **`FetchStatus`**: a second-axis enum (`fetching / paused / idle`) emitted independently of `QoraState`, allowing the UI to distinguish "no network" from "loading". - **`ReconnectStrategy`**: limits the thundering herd on reconnect with a maximum number of concurrent refetches and random jitter between batches. ```dart [user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => api.getUser(userId), options: const QoraOptions(networkMode: NetworkMode.offlineFirst), 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(), }; }, ) ``` ### DevTools Qora's in-app overlay (`qora_devtools_overlay`) runs inside your app in debug builds and shows every query with its state, data and staleness, plus the mutation queue. A Flutter DevTools extension sharing the same event protocol is in development; the version on pub.dev is not compatible with Qora 1.x yet. ### Pure Dart Core The `qora` package carries no Flutter dependency. It operates in CLI applications, Dart server-side code, and test suites without pulling in the Flutter SDK. `qora_flutter` is a thin opt-in widget binding layer on top. ### Advanced Invalidation `invalidateQueries({required QueryFilter filter})` invalidates by state, last-used options, or any combination, not just by key prefix: ```dart [main.dart] // Invalidate all online-mode queries that have not yet succeeded client.invalidateQueries( filter: (key, state, opts) => opts?.networkMode == NetworkMode.online && state is! Success, ); ``` --- ## `cached_query`: Ready-Made Persistence `cached_query` ships `cached_storage`, a sqflite-based storage implementation maintained alongside the library, and a Flutter DevTools extension. Qora's `PersistQoraClient` covers the same use case through a `StorageAdapter` interface with an in-memory implementation included; adapters for specific backends (Hive, Isar, SharedPreferences) are implemented in your app for now. --- ## Complementarity with BLoC and Riverpod Qora, BLoC, and Riverpod address different concerns and are designed to be used together: | Layer | Appropriate tool | | ------------------------------------------------------------ | ---------------------------- | | Data that lives on a server (queries, mutations, pagination) | **Qora** | | Complex UI state machines (checkout flows, multi-step forms) | **BLoC** | | Client state and DI (theme, auth session, service graph) | **Riverpod** or **Provider** | A production application commonly uses all three in combination. Refer to the integration guides when adding Qora to an existing BLoC or Riverpod codebase. ::tip Qora manages server state. BLoC and Riverpod manage client state. The two domains are complementary; each library operates within its intended scope. :: --- ## Related - [With Provider](https://qora.meragix.dev/../third-party/with-provider) - [With BLoC](https://qora.meragix.dev/../third-party/with-bloc) - [With Riverpod](https://qora.meragix.dev/../third-party/with-riverpod) - [Setup](https://qora.meragix.dev/installation) # Coming from TanStack Query Qora shares the same conceptual model as TanStack Query: query keys, stale-while-revalidate, background refetch, mutation lifecycle hooks, and offline pause-and-replay. Most concepts translate directly. This page maps what you already know to the Qora API. --- ## Concept Map | TanStack Query concept | Qora equivalent | | ---------------------- | ------------------------------------------------------------ | | `QueryClient` | `QoraClient` | | `QueryClientProvider` | `QoraScope` | | `useQuery` | `QoraBuilder` (widget) or `useQuery` (hook) | | `useMutation` | `QoraMutationBuilder` (widget) or `useMutation` (hook) | | `useInfiniteQuery` | `InfiniteQueryBuilder` (widget) or `useInfiniteQuery` (hook) | | `useQueryClient` | `context.qora` / `useQueryClient()` (hook) | | `useIsFetching` | `useIsFetching()` (hook) or `client.fetchingCountStream` | | `useIsMutating` | `useIsMutating()` (hook) or `client.activeMutations` | | `QueryObserver` | `client.watchQuery` / `InfiniteQueryObserver` | | `persistQueryClient` | `PersistQoraClient` | | `DevTools` | `qora_devtools_overlay` (in-app) | --- ## API Reference: Queries ### Options | TanStack Query option | Qora equivalent | Notes | | ----------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `queryKey` | `queryKey` | Same concept. TanStack uses arrays; Qora uses `List`. Structural equality in both. | | `queryFn` | `fetcher` | Same role. Renamed. | | `enabled` | `enabled` | Identical. | | `staleTime` | `staleTime` | Identical. `Duration.zero` = always stale. | | `gcTime` (`cacheTime` in v4) | `gcTime` | Now matches v5 naming. | | `retry` | `retryCount` | Renamed. Default is `3` in both. | | `retryDelay` | `retryDelay` / `retryDelayCalculator` | Exponential backoff is the default. `retryDelayCalculator` accepts a function receiving the attempt index. | | `refetchOnWindowFocus` | `refetchOnWindowFocus` | Identical. Requires `FlutterLifecycleManager`. | | `refetchOnReconnect` | `refetchOnReconnect` | Identical. Requires `FlutterConnectivityManager`. | | `refetchOnMount` | `refetchOnMount` | Identical semantics. `null` falls back to `QoraClientConfig.refetchOnMount`. | | `refetchInterval` | `refetchInterval` | Identical. Active while at least one subscriber is present. | | `networkMode` | `networkMode` | Same name, same three values: `online`, `always`, `offlineFirst`. | | `select` | `select` (rebuild filter) / `transform` / `state.map()` | Different semantics, see [below](https://qora.meragix.dev/#select-filters-rebuilds-it-does-not-transform). | | `placeholderData` | `placeholderData` | A function returning a placeholder, for example from another cached query. | | `placeholderData: keepPreviousData` | `previousData` on `Loading` / `Failure`, or `keepPreviousData: true` | `previousData` is always present. `keepPreviousData` keeps the entry in `Success` during a refetch. | | `initialData` | `initialData` | Seeded before the first frame. `client.hydrateQuery(key, data)` does the same imperatively. | | `initialDataUpdatedAt` | `initialDataUpdatedAt` | Defaults to the epoch, so initial data is stale and refetched on mount. | | `structuralSharing` | `structuralSharing` | Enabled by default. Unchanged nested data keeps its identity across fetches. | | `notifyOnChangeProps` | Not available | All state transitions emit on the stream. | ### Return values | TanStack Query field | Qora equivalent | | ------------------------- | ---------------------------------------------------------------- | | `status: 'pending'` | `state is Initial` or `state is Loading(previousData: null)` | | `status: 'error'` | `state is Failure` | | `status: 'success'` | `state is Success` | | `fetchStatus: 'fetching'` | `fetchStatus == FetchStatus.fetching` | | `fetchStatus: 'paused'` | `fetchStatus == FetchStatus.paused` | | `fetchStatus: 'idle'` | `fetchStatus == FetchStatus.idle` | | `isLoading` | `state.isLoading` | | `isFetching` | `fetchStatus == FetchStatus.fetching` | | `isError` | `state.isError` | | `isSuccess` | `state.isSuccess` | | `isPending` | `state is Initial || state is Loading(previousData: null)` | | `data` | `state.dataOrNull` or `Success(:final data)` via pattern match | | `error` | `state.errorOrNull` or `Failure(:final error)` via pattern match | | `dataUpdatedAt` | `state.updatedAt` (extension, non-null only on `Success`) | | `errorUpdatedAt` | Not available as a separate field | | `isFetchedAfterMount` | Not available | --- ## API Reference: Mutations ### Options | TanStack Query option | Qora equivalent | Notes | | ----------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------ | | `mutationFn` | `mutator` | Renamed. | | `onMutate` | `MutationOptions.onMutate` | Same role. Return value becomes `TContext`. | | `onSuccess` | `MutationOptions.onSuccess` | Same role. Signature: `(TData, TVariables, TContext?)`. | | `onError` | `MutationOptions.onError` | Same role. Signature: `(Object, TVariables, TContext?)`. | | `onSettled` | `MutationOptions.onSettled` | Same role. Signature: `(TData?, Object?, TVariables, TContext?)`. | | `retry` | `MutationOptions.retryCount` | Renamed. Default is `0` (no retry). | | `retryDelay` | `MutationOptions.retryDelay` | Exponential backoff. | | `networkMode` | `MutationOptions.networkMode` | Same values. Add `offlineQueue: true` to queue writes made offline and replay them on reconnect. | | `variables` | `TVariables` type parameter | Same concept. | | `context` (rollback snapshot) | `TContext` type parameter | Same concept. Returned by `onMutate`, forwarded to `onError`/`onSettled`. | ### Return values / state | TanStack Query field | Qora equivalent | | ------------------------ | -------------------------------------------------------------- | | `status: 'idle'` | `state is MutationIdle` | | `status: 'pending'` | `state is MutationPending` | | `status: 'error'` | `state is MutationFailure` | | `status: 'success'` | `state is MutationSuccess` | | `isIdle` | `state.isIdle` | | `isPending` | `state.isPending` | | `isError` | `state.isError` | | `isSuccess` | `state.isSuccess` | | `data` | `state.dataOrNull` | | `error` | `state.errorOrNull` | | `variables` | `state.variablesOrNull` | | `mutate(variables)` | `mutate(variables)`: fire-and-forget, errors captured in state | | `mutateAsync(variables)` | `mutateAsync(variables)`: awaitable, errors propagated | | `reset()` | `reset()`: transitions back to `MutationIdle` | --- ## API Reference: `QueryClient` → `QoraClient` | TanStack Query method | Qora equivalent | | --------------------------------- | ------------------------------------------------------------------------- | | `invalidateQueries({ queryKey })` | `client.invalidate(key)` | | `invalidateQueries(filter)` | `client.invalidateWhere(predicate)` or `client.invalidateQueries(filter)` | | `setQueryData(queryKey, updater)` | `client.setQueryData(key, data)` | | `getQueryData(queryKey)` | `client.getQueryData(key)`: returns `T?` | | `getQueryState(queryKey)` | `client.getQueryState(key)`: returns `QoraState` | | `prefetchQuery` | `client.prefetch(key, fetcher, options)` | | `resetQueries` | `client.removeQuery(key)` or `client.clear()` | | `cancelQueries` | Not available in Qora v1 | | `isFetching(queryKey)` | `client.isFetchingCount` (global count) | | `setDefaultOptions` | `QoraClientConfig.defaultOptions` (set at construction) | | `getDefaultOptions` | `client.config.defaultOptions` | | `mount` / `unmount` | Handled by `QoraScope` / widget lifecycle | | `clear()` | `client.clear()` | --- ## Key Differences ### Sealed classes replace status strings TanStack Query returns plain objects with string `status` fields and boolean shortcuts (`isLoading`, `isError`, etc.). Qora returns a sealed class `QoraState`. Dart 3 pattern matching makes exhaustive handling compile-time enforced: ```dart [user_screen.dart] // TanStack Query (React) if (isLoading) return ; if (isError) return ; return ; // Qora (Dart 3) return switch (state) { Initial() || Loading(previousData: null) => const CircularProgressIndicator(), Failure(:final error, previousData: null) => ErrorScreen(error), _ => UserCard(state.dataOrNull!), }; ``` The `_` arm covers `Success`, `Loading(previousData != null)`, and `Failure(previousData != null)`: all cases where data is available. --- ### `previousData` is always present TanStack Query v5 keeps the previous data with `placeholderData: keepPreviousData`. In Qora, `Loading` and `Failure` always carry `previousData: T?`, so stale data stays accessible during background refreshes and after errors without any option. ```dart [user_screen.dart] // Show stale data with a progress indicator during a background refresh case Loading(:final previousData) when previousData != null: return Stack(children: [ UserCard(previousData), const LinearProgressIndicator(minHeight: 2), ]); ``` If you prefer the entry to stay in `Success` while it refetches, for example to keep a paginated table stable, set `keepPreviousData: true`. --- ### `select` filters rebuilds, it does not transform In TanStack Query, `select` transforms the data returned to the component. In Qora, `select` on `QoraBuilder` and `useQuery` decides **when** the widget rebuilds: only when the state kind or the selected value changes. The state you receive is always the full one. To transform data, Qora offers two tools: ```dart [main.dart] // TanStack Query useQuery({ queryKey, queryFn, select: (data) => data.name }); // Qora: transform once, in the cache (every subscriber sees the result) QoraOptions(transform: (raw) => User.fromJson(raw as Map)); // Qora: transform per consumer, on the state final nameState = userState.map((user) => user.name); // QoraState → QoraState, previousData included // Qora: rebuild only when the name changes QoraBuilder( queryKey: ['users', id], fetcher: () => api.getUser(id), select: (user) => user.name, builder: (context, state, _) => Text(state.dataOrNull?.name ?? ''), ); ``` --- ### `gcTime` replaces `cacheTime` Qora uses the TanStack Query v5 name. `gcTime` is the time an inactive entry stays in memory after its last subscriber cancels. --- ### Widget API vs hook API React Query's primary API is hooks (`useQuery`, `useMutation`). Qora provides both a widget API and a hook API: - `QoraBuilder` and `QoraMutationBuilder` work in any `StatelessWidget` or `StatefulWidget`. - `useQuery` and `useMutation` require `HookWidget` from `flutter_hooks`. Both share the same `QoraClient` cache. If a `QoraBuilder` already fetched `['users', id]`, `useQuery` gets it on the first frame from the same entry. --- ### `QoraScope` is a `StatefulWidget`, not a `Provider` TanStack Query uses `QueryClientProvider` (React context). Qora uses `QoraScope`, a `StatefulWidget` that exposes the client to its subtree via an internal `InheritedWidget`. Access the client from any descendant via `context.qora` or `QoraScope.of(context)`. --- ### Persistence requires explicit serializer registration TanStack Query's persistence plugins (e.g. `createSyncStoragePersister`) use `JSON.stringify` / `JSON.parse` on the full query cache. Qora requires explicit serializer registration per type because Dart has no runtime reflection: ```dart [main.dart] // Required before hydrate() client.registerSerializer( QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson), name: 'User', ); await client.hydrate(); ``` The `name` string must be stable across app versions and build modes (including obfuscated builds). --- ### No React Suspense equivalent TanStack Query supports `suspense: true` to integrate with React Suspense. Flutter has no equivalent mechanism. All async state is handled through `QoraState` pattern matching in the widget tree. --- ## Infinite Queries | TanStack Query | Qora | | ------------------------------------- | ------------------------------------------------------------------- | | `useInfiniteQuery` | `InfiniteQueryBuilder` / `useInfiniteQuery` | | `queryFn({ pageParam })` | `fetcher(TPageParam pageParam)` | | `initialPageParam` | `InfiniteQueryOptions.initialPageParam` | | `getNextPageParam(lastPage, pages)` | `InfiniteQueryOptions.getNextPageParam(lastPage, pages)` | | `getPreviousPageParam` | `InfiniteQueryOptions.getPreviousPageParam` | | `maxPages` | `InfiniteQueryOptions.maxPages` | | `fetchNextPage()` | `observer.fetchNextPage()` | | `fetchPreviousPage()` | `observer.fetchPreviousPage()` | | `hasNextPage` | `InfiniteSuccess.hasNextPage` | | `hasPreviousPage` | `InfiniteSuccess.hasPreviousPage` | | `isFetchingNextPage` | `InfiniteSuccess.isFetchingNextPage` | | `isFetchingPreviousPage` | `InfiniteSuccess.isFetchingPreviousPage` | | `data.pages` | `InfiniteData.pages` | | `data.pageParams` | `InfiniteData.pageParams` | | `data.pages.flat()` | `InfiniteData.flatten((page) => page.items)` | | `queryClient.setQueryData` (infinite) | `client.setInfiniteQueryData(key, InfiniteData)` | | `queryClient.getQueryData` (infinite) | `client.getInfiniteQueryData(key)` | --- ## DevTools TanStack Query DevTools is a browser panel installed as a React component. Qora's equivalent is an in-app overlay: install `qora_devtools_overlay` and wrap the app with `QoraInspector`. It shows queries, mutations and a timeline, and renders nothing in release builds. See [DevTools overview](https://qora.meragix.dev/en/devtools/overview) for setup instructions. --- ## Related - [What is Qora](https://qora.meragix.dev/what-is-qora): conceptual overview - [Quick Start](https://qora.meragix.dev/quick-start): five-minute setup - [QoraState](https://qora.meragix.dev/../api-reference/qora-state): sealed class reference - [QoraOptions](https://qora.meragix.dev/../api-reference/qora-options): full per-query option reference - [QoraClientConfig](https://qora.meragix.dev/../api-reference/qora-client-config): global configuration reference # Migrating from Other Libraries This guide maps the concepts of [`flutter_query`](https://pub.dev/packages/flutter_query){rel=""nofollow""} and [`cached_query`](https://pub.dev/packages/cached_query){rel=""nofollow""} to Qora. Both libraries are solid; the goal here is not to criticise them but to make a migration predictable. ::note API details for the other libraries were checked against `flutter_query` 0.11 and `cached_query` 3.7. If you are on a different version, double-check names against their own documentation. :: ## Migrate incrementally You do not need a big-bang rewrite. A `QoraScope` can live next to another provider, so you can migrate one screen or one feature at a time: ```dart void main() { runApp( QoraScope.auto( client: QoraClient(), child: const MyApp(), // existing providers can stay inside ), ); } ``` --- ## From flutter\_query `flutter_query` is hook-based (`flutter_hooks`) and follows TanStack Query closely. The fastest path is [`qora_hooks`](https://pub.dev/packages/qora_hooks){rel=""nofollow""}, which keeps the same hook style. ### Concept mapping | Concept | flutter\_query | Qora | | ---------------------------- | ----------------------------------------------- | --------------------------------------------------- | | Provider | `QueryClientProvider(client: QueryClient())` | `QoraScope(client: QoraClient())` | | Query (hooks) | `useQuery(key, (context) => fn())` | `useQuery(key: key, fetcher: fn)` | | Query (widget) | not provided | `QoraBuilder` | | Mutation | `useMutation((vars, context) => fn(vars))` | `useMutation(mutator: fn)` or `QoraMutationBuilder` | | Freshness window | `staleDuration` | `QoraOptions.staleTime` | | Inactive cache retention | `gcDuration` | `QoraOptions.gcTime` | | Placeholder / seed | `placeholder`, `seed` | `placeholderData`, `initialData` | | Invalidate (prefix, default) | `client.invalidateQueries(queryKey: ['todos'])` | `client.invalidatePrefix(['todos'])` | | Invalidate (exact) | `invalidateQueries(queryKey: k, exact: true)` | `client.invalidate(k)` | | Read / write cache | `getQueryData`, `setQueryData` | `getQueryData`, `setQueryData` | ::warning `flutter_query.invalidateQueries` matches by **prefix** by default. Qora's `invalidate(key)` is **exact** . Use `invalidatePrefix` where you relied on the default behaviour. :: ### Reading state `flutter_query` exposes boolean flags on a result object. Qora returns a sealed `QoraState`, so the compiler checks that you handle every case, and stale data stays available during refetches and failures: ```dart // flutter_query final result = useQuery(const ['user'], (context) => api.getUser()); if (result.isLoading) return const CircularProgressIndicator(); if (result.isError) return Text('Error: ${result.error}'); return UserCard(result.data!); // Qora (qora_hooks) final state = useQuery(key: const ['user'], fetcher: api.getUser); return switch (state) { Success(:final data) => UserCard(data), Failure(:final error, :final previousData) => previousData != null ? UserCard(previousData) : Text('Error: $error'), Loading(:final previousData) => previousData != null ? UserCard(previousData) : const CircularProgressIndicator(), Initial() => const SizedBox.shrink(), }; ``` ### Mutations and invalidation ```dart // flutter_query final mutation = useMutation( (title, context) => api.createTodo(title), onSuccess: (data, variables, onMutateResult, context) { // invalidate ['todos'] through the QueryClient }, ); // Qora: declare what to refresh, no manual callback needed final mutation = useMutation( mutator: api.createTodo, options: const MutationOptions(invalidates: [['todos']]), ); mutation.mutate('Buy milk'); ``` --- ## From cached\_query `cached_query` is object-based: you create `Query` / `Mutation` instances (often in a repository) and listen to their streams, with Flutter widgets in `cached_query_flutter`. ### Concept mapping | Concept | cached\_query | Qora | | ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------ | | Global config | `CachedQuery.instance.config(...)` | `QoraClient(config: QoraClientConfig(...))` | | Query | `Query(key: 'user', queryFn: fn)` | `client.watchQuery(key: ['user'], fetcher: fn)` | | Stream | `query.stream` (`QueryStatus`) | `watchQuery` / `watchState` (`QoraState`) | | One-shot read | `await query.result` | `await client.fetchQuery(key: ..., fetcher: ...)` | | Widget | `QueryBuilder` | `QoraBuilder` | | Force refetch | `query.refetch()` | `client.invalidate(key)` (active watchers refetch) | | Invalidate many | `CachedQuery.instance.invalidateCache(...)` | `invalidatePrefix`, `invalidateWhere`, `invalidateTags` | | Freshness / retention | `staleDuration`, `cacheDuration` | `staleTime`, `gcTime` | | Infinite list | `InfiniteQuery` | `InfiniteQueryBuilder` | | Mutation | `Mutation(mutationFn: fn, invalidateQueries: [...])` | `QoraMutationBuilder` / `useMutation` with `MutationOptions.invalidates` | | Persistence | `cached_storage` (sqflite) | `PersistQoraClient` + your `StorageAdapter` | | Refetch on resume / reconnect | `refetchOnResume`, `refetchOnConnection` | `QoraScope.auto()` | ### Keys `cached_query` keys are usually strings. Qora keys are lists, which makes prefix and partial invalidation natural: ```dart // cached_query Query(key: 'user-$id', queryFn: () => api.getUser(id)); // Qora client.watchQuery(key: ['users', id], fetcher: () => api.getUser(id)); client.invalidatePrefix(['users']); // every user query ``` ### What you gain - **Offline mutation queue**: writes made offline are queued and replayed in order on reconnect, with optimistic UI in the meantime. - **Two-axis state**: `QoraState` (data) and `FetchStatus` (fetching / paused / idle) are independent, so "offline with stale data" is a first-class state. - **Tag-based invalidation**: mutations invalidate logical tags instead of hard-coded keys. - **In-app DevTools overlay** to inspect the cache, timelines and mutations. --- ## Error handling Both libraries let you inspect raw errors. In Qora, map them once with `QoraClientConfig.errorMapper`, which receives the error and its stack trace and returns a `QoraException`: ```dart import 'package:qora_flutter/qora_flutter.dart'; final client = QoraClient( config: QoraClientConfig( errorMapper: (Object error, StackTrace? stackTrace) => error is QoraException ? error : QoraException(error.toString(), originalError: error, stackTrace: stackTrace), ), ); ``` See [Dio integration](https://qora.meragix.dev/../third-party/with-dio) for a complete mapper. # AI Assistants Coding assistants know TanStack Query far better than Qora. Left alone, they write TanStack code with Dart syntax: `queryFn` instead of `fetcher`, `cacheTime` instead of `gcTime`, `state.when(...)`, which does not exist. Two files fix most of it: a short rules file the assistant loads in every session, and the documentation in a format it can read. ## 1. Add the Qora rules to your project [`qora-rules.md`](https://qora.meragix.dev/qora-rules.md){rel=""nofollow""} is a one-page summary written for assistants. It contains the setup, how to render states, the offline behaviour, mutations and invalidation, and a table of the mistakes assistants make most often, each with the correct Qora form. Download it to the root of your project: ```bash [Terminal] curl -o qora-rules.md https://qora.meragix.dev/qora-rules.md ``` Then reference it from the instruction file your assistant reads: | Assistant | Where | | ----------------------------------------- | ------------------------------------------------------------------------------ | | Claude Code | Add the line `@qora-rules.md` to `CLAUDE.md` | | Cursor | Save it as `.cursor/rules/qora.mdc` with the front matter below | | GitHub Copilot | Append its content to `.github/copilot-instructions.md` | | Codex and other tools reading `AGENTS.md` | Append its content to `AGENTS.md`, or tell `AGENTS.md` to read `qora-rules.md` | For Cursor, the file starts with: ```md [.cursor/rules/qora.mdc] --- description: Qora server-state library rules globs: "**/*.dart" alwaysApply: false --- ``` Re-download the file when you upgrade Qora: it states the version it describes. ## 2. Point the assistant at the documentation The documentation is published in formats meant for language models: | URL | Contents | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](https://qora.meragix.dev/llms.txt){rel=""nofollow""} | Index of every page, with a one-line description | | [`/llms-full.txt`](https://qora.meragix.dev/llms-full.txt){rel=""nofollow""} | The whole English documentation in one file | | `/raw/en/
/.md` | Any single page as Markdown, for example [`/raw/en/getting-started/quick-start.md`](https://qora.meragix.dev/raw/en/getting-started/quick-start.md){rel=""nofollow""} | For a focused task, pass the one or two relevant pages rather than the full file: the assistant follows a short, precise context better than a long one. ## 3. Check what it wrote - **Run `dart analyze`.** An invented method or parameter is a compile error, which is the quickest way to catch a TanStack habit. - **Look for a `switch` on `QoraState`.** Code that checks `state.isLoading` before reading data usually drops the `previousData` kept during refetches and errors. - **Test offline.** Code generated from other libraries tends to expect a `Failure` or `Initial` when the network is off. Qora pauses instead: `Loading` with `FetchStatus.paused`. When in doubt, compare with the [Quick Start](https://qora.meragix.dev/quick-start), which runs as is, or ask the assistant to read [Coming from TanStack Query](https://qora.meragix.dev/coming-from-tanstack-query), which maps every option. # Queries ## Anatomy of a Query Every Qora query is composed of three elements: ```dart [user_repository.dart] await client.fetchQuery( // 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 ```dart [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'}) ``` ::tip Keys use **deep structural equality** . `['users', 1]` and `['users', 1]` are identical even when created independently. :: See [Query Keys](https://qora.meragix.dev/query-keys) for a complete guide. --- ### 2. Fetcher The **fetcher** is any `Future Function()` that returns your data: ```dart [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); }, ``` ::warning 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: ```dart [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](https://qora.meragix.dev/en/api-reference/qora-options) for all fields. --- ## Two Ways to Fetch ### `fetchQuery`: One-shot Returns a `Future`. Suitable for imperative calls (button tap, page load): ```dart [user_repository.dart] final user = await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), ); ``` Cache behaviour: | State | Action | | ----------------- | ---------------------------------------------------- | | Fresh (not stale) | Return cached data, no network call | | Stale | Return stale data immediately, refetch in background | | Missing | Fetch, await, and return result | --- ### `watchQuery`: Reactive stream Returns a `Stream>`. Suitable for UI bindings: ```dart [user_page.dart] StreamBuilder>( stream: client.watchQuery( 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: ```dart [user_list_page.dart] // On hover / likely tap onEnter: (_) => client.prefetch( key: ['users', userId], fetcher: () => api.getUser(userId), ), ``` No-op if the data is already fresh. Does not create a stream. --- ## Query Lifecycle ```text [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](https://qora.meragix.dev/query-states) for full documentation. --- ## Invalidation Mark queries as stale after a mutation. Active `watchQuery` streams transition to `Loading` immediately: ```dart [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: ```dart [user_repository.dart] // Snapshot before update (for rollback) final snapshot = client.getQueryData(['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 ```dart [repositories/user_repository.dart] import 'package:qora/qora.dart'; class UserRepository { final QoraClient _client; final UserApi _api; UserRepository(this._client, this._api); Future getUser(int userId) => _client.fetchQuery( key: ['users', userId], fetcher: () => _api.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); Stream> watchUser(int userId) => _client.watchQuery( 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 ```dart [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 ```dart [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 - [Query Keys](https://qora.meragix.dev/query-keys): Key structure, equality, and invalidation patterns - [Query States](https://qora.meragix.dev/query-states): Deep dive into the sealed class state machine - [Caching](https://qora.meragix.dev/caching): LRU, stale time, GC, and cache management - [Stale-While-Revalidate](https://qora.meragix.dev/stale-while-revalidate): How background revalidation works # 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: ```dart // 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 ```dart // Fetch all users final users = await client.fetchQuery>( key: ['users'], fetcher: fetchUsers, ); // Fetch app settings final settings = await client.fetchQuery( key: ['settings'], fetcher: fetchSettings, ); ``` ### Keys with Variables ```dart // Fetch a specific user final user = await client.fetchQuery( key: ['users', userId], fetcher: () => fetchUser(userId), ); // Fetch filtered posts final posts = await client.fetchQuery>( key: ['posts', {'status': 'published', 'author': authorId}], fetcher: () => fetchPosts(status: 'published', author: authorId), ); ``` ### Hierarchical Keys ```dart // 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 ```dart ['users', 1] == ['users', 1] // true ['users', 1] == ['users', 2] // false ``` ### Lists and Nesting ```dart ['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: ```dart ['posts', {'status': 'active', 'limit': 10}] == ['posts', {'limit': 10, 'status': 'active'}] // true ``` This differs from TanStack Query (JavaScript), where object key order matters. ### Null Values ```dart ['users', null] == ['users', null] // true ['users', null] == ['users', 1] // false ``` --- ## Query Key Structure ### Recommended Patterns #### Pattern 1: Entity + ID ```dart ['users', userId] ['posts', postId] ['products', sku] ``` #### Pattern 2: Entity + Filters ```dart ['posts', {'status': 'published'}] ['users', {'role': 'admin', 'active': true}] ['products', {'category': 'electronics', 'inStock': true}] ``` #### Pattern 3: Entity + ID + Relation ```dart ['users', userId, 'posts'] ['posts', postId, 'comments'] ['orders', orderId, 'items'] ``` #### Pattern 4: Pagination ```dart // Offset-based ['posts', {'page': pageNumber}] // Cursor-based ['messages', {'cursor': lastMessageId, 'limit': 50}] ``` ### Anti-Patterns Non-serializable objects cause silent cache misses: ```dart ['users', StreamController()] // Non-serializable instance ['data', widget] // Widget instance ``` Non-deterministic values cause cache misses on every call: ```dart ['posts', DateTime.now()] // Non-deterministic ['users', Random().nextInt()] // Changes on every call ``` Excessive nesting reduces readability without benefit: ```dart ['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 ```dart [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 get props => [status, limit]; } // Usage final posts = await client.fetchQuery>( key: ['posts', PostFilter(status: 'published', limit: 10)], fetcher: () => fetchPosts(status: 'published', limit: 10), ); ``` ### Manual Override ```dart [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); } ``` ::warning 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 ```dart // 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. ```dart [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( key: QueryKeys.user('123'), fetcher: () => fetchUser('123'), ); await client.fetchQuery>( key: QueryKeys.posts(status: 'published', limit: 10), fetcher: () => fetchPosts(status: 'published', limit: 10), ); ``` ### Factory with Prefix Invalidation ```dart [post_query_keys.dart] class PostQueryKeys { static const _prefix = ['posts']; static List get all => _prefix; static List list([Map? filters]) => filters != null ? [..._prefix, 'list', filters] : [..._prefix, 'list']; static List detail(String id) => [..._prefix, 'detail', id]; static List 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 ```dart client.invalidate(['users', userId]); ``` ### Predicate-Based Invalidation `invalidateWhere` receives the normalized key as `List`: ```dart // 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: ```dart // 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: ```dart // 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: ```dart // Set with List await client.fetchQuery( key: ['users', 123], fetcher: () => fetchUser(123), ); // Cache hit with QoraKey (same entry) await client.fetchQuery( 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: ```dart 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: ```dart 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 ```dart // 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 ```dart // 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`. ```dart // 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` **Cause**: a non-`List`, non-`QoraKey` value was passed as the key. ```dart 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. ```dart ['posts', DateTime.now()] // Rebuilds create a new key each time ``` **Fix**: use stable, deterministic values in keys. --- ## Next Steps - [Query States](https://qora.meragix.dev/query-states): `Loading`, `Success`, and `Failure` state machine - [Caching](https://qora.meragix.dev/caching): LRU, GC, and cache lifecycle - [Queries](https://qora.meragix.dev/queries): `fetchQuery`, `watchQuery`, and `prefetch` # Query States ## Overview Every query in Qora is in one of four states at any given moment: ```dart [lib/src/state/qora_state.dart] sealed class QoraState { ... } // Query hasn't started yet final class Initial extends QoraState {} // Actively fetching (with optional stale data) final class Loading extends QoraState { final T? previousData; } // Data fetched successfully final class Success extends QoraState { final T data; final DateTime updatedAt; } // Fetch failed (with optional stale data for graceful degradation) final class Failure extends QoraState { final Object error; final StackTrace? stackTrace; final T? previousData; } ``` Dart 3 sealed classes guarantee **exhaustive pattern matching at compile time**: the compiler rejects any `switch` that does not handle all four variants. --- ## State Transitions ```text Initial │ first fetch triggered ▼ Loading(previousData: null) │ fetcher succeeds ▼ Success(data, updatedAt) │ staleTime elapsed → background refetch ▼ Loading(previousData: data) ← users see stale data during reload │ fetcher succeeds ▼ Success(newData, updatedAt) │ fetcher fails after all retries ▼ Failure(error, previousData: data) ← stale data still accessible ``` ### Key Principles 1. `previousData` is preserved across `Loading` and `Failure` states. The UI always has something to display. 2. `Failure` with `previousData` is not a terminal state. It supports graceful degradation. --- ## Pattern Matching ### Exhaustive switch (recommended) ```dart [user_page.dart] Widget build(BuildContext context) { return switch (state) { Initial() => const SizedBox.shrink(), Loading(:final previousData) => previousData != null ? Stack(children: [UserCard(previousData), const LinearProgressIndicator()]) : const CircularProgressIndicator(), Success(:final data) => UserCard(data), Failure(:final error, :final previousData) => Column( children: [ if (previousData != null) UserCard(previousData), ErrorBanner('$error'), ], ), }; } ``` ### `when`: side-effect dispatch All four callbacks are optional. `when` returns `void` and is designed for side effects: analytics events, snackbars, logging: ```dart [user_page.dart] state.when( onSuccess: (data, updatedAt) => analytics.track('loaded', {'id': data.id}), onError: (error, stackTrace, previousData) => showSnackBar('$error'), ); ``` ### `maybeWhen`: mapping to a value with fallback `maybeWhen` returns a value of type `R`. It accepts an `orElse` fallback and optional per-state handlers: ```dart [user_page.dart] final widget = state.maybeWhen( onSuccess: (data, _) => UserCard(data), orElse: () => const CircularProgressIndicator(), ); ``` --- ## `Initial` The query has never been fetched. This is the default state of any key that has not been loaded yet. ```dart [main.dart] final state = client.getQueryState(['users', 42]); print(state); // Initial() print(state.isInitial); // true print(state.hasData); // false print(state.dataOrNull); // null ``` Render a placeholder or nothing. A spinner is inappropriate here because no fetch is in progress. --- ## `Loading` A fetch is actively in progress. `previousData` contains the last successful data (if any), enabling the UI to remain useful during reloads. ```dart [user_page.dart] // First load: no previousData Loading(previousData: null) // Show full-screen spinner // Refetch / background reload: previousData available Loading(previousData: User(id: 1, name: 'Alice')) // Show stale UI + subtle indicator ``` ```dart [user_page.dart] case Loading(:final previousData): if (previousData != null) { return Stack( children: [ UserCard(previousData), const Positioned( top: 0, left: 0, right: 0, child: LinearProgressIndicator(), ), ], ); } return const CircularProgressIndicator(); ``` **Helper properties:** ```dart [main.dart] state.isLoading // true state.isFirstLoad // true when previousData == null state.isRefreshing // true when previousData != null state.hasData // true when previousData != null state.dataOrNull // returns previousData ``` --- ## `Success` Data was fetched successfully. Contains `data` and `updatedAt` for freshness tracking. ```dart [user_page.dart] case Success(:final data, :final updatedAt): return Column( children: [ UserCard(data), Text('Updated ${_timeAgo(updatedAt)}'), ], ); ``` ```dart [main.dart] final s = state as Success; s.data // the fetched User s.updatedAt // DateTime of the last successful fetch s.age // Duration since updatedAt s.isStale(Duration(minutes: 5)) // true if age > 5 min ``` **Helper properties:** ```dart [main.dart] state.isSuccess // true state.hasData // true state.dataOrNull // returns data state.successDataOrNull // returns data (null for non-Success states) state.updatedAt // DateTime? ``` --- ## `Failure` The fetch failed after all retry attempts. `previousData` gives access to the last successful data for graceful degradation. ```dart [user_page.dart] case Failure(:final error, :final previousData): return Column( children: [ if (previousData != null) ...[ UserCard(previousData), const SizedBox(height: 8), ], ErrorBanner( message: '$error', onRetry: () => client.invalidate(['users', userId]), ), ], ); ``` ```dart [main.dart] state.isError // true (alias: hasError) state.hasData // true when previousData != null state.dataOrNull // returns previousData state.errorOrNull // returns error Object ``` ::tip Always check `previousData` in `Failure` . Showing stale data alongside an error banner provides a better experience than a blank error screen. :: --- ## State Extensions Qora ships a set of extensions on `QoraState` defined in `state_extensions.dart`. ### Data access ```dart [main.dart] state.hasData // true for Success, or Loading/Failure with previousData state.dataOrNull // T?: best available data across all states state.requireData() // T: throws StateError if no data state.successDataOrNull // T?: only for Success state, null otherwise ``` ### Status flags ```dart [main.dart] state.isInitial // true if Initial state.isLoading // true if Loading state.isSuccess // true if Success state.isError // true if Failure state.hasError // alias for isError state.isFirstLoad // true if Loading with no previousData state.isRefreshing // true if Loading with previousData ``` ### Mapping ```dart [main.dart] // Transform data type, preserving state structure final namesState = usersState.map((users) => users.map((u) => u.name).toList()); // usersState: Success> → namesState: Success> // mapData behaves like map; use whichever name reads better in a chain final countState = usersState.mapData((users) => users.length); // Exhaustive fold to a single value final label = state.fold( onInitial: () => 'Not loaded', onLoading: (prev) => prev != null ? 'Refreshing...' : 'Loading...', onSuccess: (data, _) => 'Loaded: ${data.name}', onError: (error, stackTrace, prev) => 'Error: $error', ); ``` ### Combining multiple states ```dart [main.dart] // Combine two states: Success only when both are Success final combined = userState.combine( postsState, (user, posts) => UserWithPosts(user, posts), ); // Combine a list of states final allUsers = QoraStateUtils.combineList([state1, state2, state3]); // Combine exactly 2 or 3 states into a record final pair = QoraStateUtils.combine2(userState, settingsState); final triple = QoraStateUtils.combine3(userState, postsState, tagsState); ``` --- ## Stream Extensions `watchQuery` returns a `Stream>` with additional operators defined in `QoraStateStreamExtensions`: ```dart [user_page.dart] // Only emit states that have data client.watchQuery(...).whereHasData().listen(...); // Only emit Success states client.watchQuery(...).whereSuccess().listen((s) => print(s.data)); // Only emit Failure states client.watchQuery(...).whereError().listen((f) => logError(f.error)); // Extract data as T? (null for states without data) client.watchQuery(...).dataOrNull().listen(...); // Extract data: skip states without data client.watchQuery(...).data().listen((user) => render(user)); // Map data type client.watchQuery>(...).mapData((u) => u.length); // Delay Loading emissions to prevent flicker on fast connections client.watchQuery(...).delayLoading(Duration(milliseconds: 300)); ``` --- ## Serialization Persist and restore query states across sessions using `QoraStateCodec`: ```dart [cache_storage.dart] final codec = QoraStateCodec( encode: (user) => user.toJson(), decode: (json) => User.fromJson(json as Map), ); // Save to storage final json = codec.encodeState(state); await prefs.setString('user_state', jsonEncode(json)); // Restore from storage final jsonStr = prefs.getString('user_state'); if (jsonStr != null) { final restored = codec.decodeState(jsonDecode(jsonStr) as Map); client.setQueryData(['users', userId], (restored as Success).data); } ``` --- ## Testing ```dart [test/user_repository_test.dart] import 'package:test/test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:qora/qora.dart'; class MockUserApi extends Mock implements UserApi {} void main() { late QoraClient client; late MockUserApi api; setUp(() { api = MockUserApi(); client = QoraClient(); }); tearDown(() => client.dispose()); test('transitions to Success on successful fetch', () async { when(() => api.getUser(1)).thenAnswer((_) async => User(id: 1, name: 'Alice')); final states = >[]; final sub = client .watchQuery(key: ['users', 1], fetcher: () => api.getUser(1)) .listen(states.add); await Future.delayed(Duration.zero); await sub.cancel(); expect(states, [ isA>(), isA>(), isA>(), ]); expect((states.last as Success).data.name, 'Alice'); }); test('transitions to Failure on error after retries', () async { when(() => api.getUser(1)).thenThrow(Exception('Network error')); final failClient = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions(retryCount: 0), ), ); addTearDown(failClient.dispose); final states = >[]; final sub = failClient .watchQuery(key: ['users', 1], fetcher: () => api.getUser(1)) .listen(states.add); await Future.delayed(Duration.zero); await sub.cancel(); expect(states.last, isA>()); }); } ``` --- ## Best Practices ### Do ```dart [main.dart] // Handle all four states exhaustively switch (state) { case Initial(): return const Placeholder(); case Loading(): return const Spinner(); case Success(): return DataWidget(state.data); case Failure(): return ErrorWidget(state.error); } // Use previousData in Loading and Failure states case Failure(:final error, :final previousData): return previousData != null ? DataWidget(previousData) : ErrorScreen(error); ``` ### Avoid ```dart [main.dart] // Partial state handling if (state is Success) render(state.data); // Failure is silently ignored // Unsafe cast without prior type check final data = (state as Success).data; // Throws on Loading or Failure ``` --- ## Next Steps - [Caching](https://qora.meragix.dev/caching): LRU eviction, GC timers, and cache lifecycle - [Stale-While-Revalidate](https://qora.meragix.dev/stale-while-revalidate): Background revalidation - [QoraState API Reference](https://qora.meragix.dev/en/api-reference/qora-state): Full API documentation # Caching ## How the Cache Works Every query result is stored as a `CacheEntry` in an in-memory `QueryCache` keyed by the normalised query key. The cache drives: - **Instant responses**: fresh data is returned without a network call - **SWR**: stale data is returned immediately while fresh data is fetched in the background - **Reactive updates**: every `CacheEntry` holds a broadcast stream; any state change reaches all active subscribers instantly ```text fetchQuery(['users', 1]) │ ├─ Cache HIT (fresh) → return data immediately ├─ Cache HIT (stale) → return stale data + revalidate in background └─ Cache MISS → fetch → store → return data ``` --- ## Cache Entry Lifecycle A `CacheEntry` goes through the following stages: ```text Created (Initial) │ fetchQuery / watchQuery triggered ▼ Loading → Success / Failure │ staleTime elapsed ▼ Stale (still in cache, accessible as previousData) │ last subscriber unsubscribes ▼ GC timer starts (= gcTime) │ timer fires, no active subscribers ▼ Evicted (removed from cache) ``` A background eviction sweep also runs every minute to remove expired inactive entries. --- ## `staleTime` vs `gcTime` These two durations control independent behaviors: | Property | Default | Controls | | ----------- | --------- | --------------------------------------------- | | `staleTime` | 0 (zero) | When to trigger a background revalidation | | `gcTime` | 5 minutes | When to remove an unused entry from the cache | ```dart [main.dart] QoraOptions( staleTime: Duration(minutes: 5), // data is fresh for 5 minutes gcTime: Duration(minutes: 10), // unused data stays in cache for 10 min ) ``` ::tip `staleTime: Duration.zero` (the default) means data is **always** considered stale. Qora still serves cached data immediately; it also revalidates in the background on every access. :: --- ## `staleTime` in Detail `staleTime` defines the freshness window. During this window, data is returned from cache without any network activity: ```dart [main.dart] // staleTime: 5 minutes // t = 0:00 → fetch triggers, Success stored // t = 0:01 → fetchQuery → Cache HIT (fresh): no network call // t = 5:01 → fetchQuery → Cache HIT (stale): returns cached data + revalidates // t = 5:01 → network request fires in background // t = 5:02 → Success updated, stream subscribers notified ``` Set a long `staleTime` for data that rarely changes: ```dart [main.dart] // Configuration data: valid for 1 hour options: QoraOptions(staleTime: Duration(hours: 1)), // User profile: valid for 10 minutes options: QoraOptions(staleTime: Duration(minutes: 10)), // Live prices: always stale (default) options: QoraOptions(staleTime: Duration.zero), ``` --- ## `gcTime` in Detail `gcTime` determines how long inactive data lingers in cache after all subscribers leave. It preserves data for fast re-navigation: ```dart [main.dart] // User navigates away → last subscriber cancels → GC timer starts // User navigates back within gcTime → entry still in cache → instant display // User navigates back after gcTime → entry evicted → full fetch required ``` ```dart [main.dart] // Posts list: keep in cache for 2 minutes after leaving the page options: QoraOptions(gcTime: Duration(minutes: 2)), ``` ::warning `gcTime: Duration.zero` removes the entry as soon as all subscribers leave. The next visit always starts from `Initial` . This is the correct behavior for sensitive data that must not outlive the current session. :: --- ## Structural Sharing Structural sharing preserves referential equality for unchanged nested data across fetches. When a fetch returns data that is deeply equal to the existing cached data, Qora keeps the previous data reference instead of replacing it. This is enabled by default (`QoraOptions.structuralSharing: true`). It directly addresses unnecessary widget rebuilds in deeply nested UIs: ```dart [main.dart] // Without structural sharing: every fetch creates a new data reference, // causing all widgets consuming this query to rebuild. // // With structural sharing (default): if the returned data is structurally // identical to the cached data, the old reference is preserved. Widgets // that check identical(oldData, newData) or == skip re-rendering. final client = QoraClient(); await client.fetchQuery>( key: ['config'], fetcher: () => api.getConfig(), options: QoraOptions(structuralSharing: true), ); ``` ### How It Works The deep equality comparison is recursive: - **Map**: compares key presence and values recursively - **List**: compares element count and elements recursively - **Set**: compares membership - **Other types**: uses `==` (override `==` and `hashCode` on custom model classes for best results) ```dart [main.dart] final oldData = {'user': {'name': 'Alice', 'roles': ['admin']}}; final newData = {'user': {'name': 'Alice', 'roles': ['admin']}}; // structuralShare returns oldData: they are deeply equal. // Any widget holding oldData will see identical(oldData, newData) == true. ``` ### When to Disable Disable structural sharing only when the deep-equality check is a measured bottleneck with very large data structures: ```dart [main.dart] QoraOptions(structuralSharing: false), ``` ### Scopes Structural sharing applies to all cache write paths: - `fetchQuery` after a successful network response - `setQueryData` for manual cache writes - `setInfiniteQueryData` and `updateInfiniteQueryState` for infinite queries - `hydrateQuery` when restoring persisted data into a non-initial entry --- ## Transformation Pipeline Qora lets you decouple data transformation from the fetcher via `QoraOptions.transform` and `QoraOptions.transformError`. This keeps the fetcher simple (raw API call) and the transformation pure (testable in isolation). ### `transform`: success path Called **after** the fetcher succeeds but **before** the data enters the cache (and before structural sharing). The transformed value is what gets cached and returned to callers. ```dart [user_repository.dart] fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), // returns Map options: QoraOptions( transform: (raw) => User.fromJson(raw as Map), ), ); ``` ### `transformError`: error path Called **after** all retries fail but **before** the error enters the cache and before `QoraClientConfig.errorMapper`. Use it to normalise error types at the query level. ```dart [post_repository.dart] fetchQuery( key: ['post', postId], fetcher: () => api.getPost(postId), options: QoraOptions( transformError: (error) => AppError.fromException(error), ), ); ``` --- ## Keep Previous Data Use `QoraOptions.keepPreviousData` to suppress the loading flash during refetches. The entry stays in `Success` state with the previous data while the fetch is in progress, then transitions to `Success` with fresh data when the fetch completes. This is especially useful for **offset-based pagination**, where changing the page parameter triggers a fetch but you want the current page visible until the next one is ready: ```dart [post_list.dart] fetchQuery>( key: ['posts', 'page', page], fetcher: () => api.getPosts(page: page, limit: 20), options: QoraOptions( keepPreviousData: true, staleTime: Duration.zero, ), ); ``` On fetch failure, the entry stays in `Success` with the previous data instead of transitioning to `Failure`. The error is still propagated to imperative callers (`fetchQuery`) so error handling (e.g. toasts, retry buttons) works as usual. --- ## LRU Eviction When `maxCacheSize` is set on `QoraClientConfig`, the cache enforces a limit using an LRU (Least Recently Used) strategy: - The entry that has been **accessed least recently** and has **no active subscribers** is evicted to make room - Active entries (with subscribers) are **never forcibly evicted** ```dart [main.dart] final client = QoraClient( config: QoraClientConfig(maxCacheSize: 100), ); ``` Each call to `fetchQuery`, `watchQuery`, or `getQueryData` updates `lastAccessedAt`, keeping entries alive in the LRU ranking. --- ## Manual Cache Control ### Read the cache ```dart [main.dart] // Get just the data (null if not Success / no previousData) final user = client.getQueryData(['users', userId]); // Get the full state final state = client.getQueryState(['users', userId]); // Inspect all cached keys for (final key in client.cachedKeys) { print(key); } ``` ### Write to the cache ```dart [main.dart] // Inject data directly: all active watchQuery streams are notified client.setQueryData(['users', userId], updatedUser); // Roll back to a previous snapshot final snapshot = client.getQueryData(['users', userId]); client.setQueryData(['users', userId], optimisticUser); try { await api.updateUser(userId, payload); } catch (_) { client.restoreQueryData(['users', userId], snapshot); } ``` ### Invalidate (mark stale) ```dart [main.dart] // Single key: active streams transition to Loading immediately client.invalidate(['users', userId]); // Multiple keys via predicate client.invalidateWhere((key) => key.firstOrNull == 'users'); // Tag-based: refetch all queries providing the 'post' tag client.invalidateTags([QueryTag('post')]); // Specific resource: refetch only queries providing QueryTag('post', '123') client.invalidateTags([QueryTag('post', '123')]); ``` Tag-based invalidation is the most flexible approach. Queries declare tags via `providesTags` in `QoraOptions`. Mutations declare tags via `invalidatesTags` in `MutationOptions`. Tags decouple invalidation from query-key structure: you can invalidate queries across different key patterns with a single tag. ```dart [main.dart] options: QoraOptions( providesTags: [QueryTag('post'), QueryTag('user', userId)], ) ``` See [MutationOptions.invalidatesTags](https://qora.meragix.dev/../api-reference/qora-mutation#invalidatestags) and [QoraOptions.providesTags](https://qora.meragix.dev/../api-reference/qora-options#providestags). ### Remove and clear ```dart [main.dart] // Remove a single entry (GC is immediate) client.removeQuery(['users', userId]); // Wipe the entire cache (e.g. on logout) client.clear(); ``` --- ## `onCacheEvict` Callback React to cache evictions for logging or storage sync: ```dart [main.dart] final client = QoraClient( config: QoraClientConfig( onCacheEvict: (key) { analytics.track('cache_evict', {'key': key.toString()}); }, ), ); ``` --- ## Debug Snapshot ```dart [main.dart] print(client.debugInfo()); // { // total_queries: 12, // active_queries: 3, // inactive_queries: 9, // non_success_queries: 1, // pending_requests: 2, // max_size: 100, // keys: [['users', 1], ['posts'], ...] // } ``` --- ## Global vs Per-query Config `gcTime` and `staleTime` can be set globally via `QoraClientConfig.defaultOptions` and overridden per query: ```dart [main.dart] final client = QoraClient( config: QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), // global default gcTime: Duration(minutes: 10), ), ), ); // This query overrides staleTime only await client.fetchQuery( key: ['config'], fetcher: api.getConfig, options: QoraOptions(staleTime: Duration(hours: 1)), // gcTime still inherits 10 minutes from global ); ``` --- ## Next Steps - [Stale-While-Revalidate](https://qora.meragix.dev/stale-while-revalidate): How background revalidation works - [Deduplication](https://qora.meragix.dev/deduplication): One network request for many callers - [QoraClientConfig API](https://qora.meragix.dev/en/api-reference/qora-client-config): Full configuration reference # Stale-While-Revalidate ## The Problem SWR Solves Traditional data fetching presents a tradeoff: - **No cache**: data is always current, but every navigation triggers a network request and shows a spinner. - **Permanent cache**: responses are instant, but data goes stale and the UI reflects outdated information. SWR (Stale-While-Revalidate) resolves this tradeoff. --- ## How It Works When `fetchQuery` or `watchQuery` is called with stale data in cache: 1. Return the stale data immediately. The UI renders without a spinner. 2. Dispatch a background revalidation. The fetcher runs silently. 3. Update the cache and all active streams when fresh data arrives. ```text t=0:00 Success(user: Alice, updatedAt: 10:00) ← staleTime: 5 min t=5:01 fetchQuery called ├── returns Alice immediately (stale data) └── fires background fetch t=5:02 Success(user: Alice McBride, updatedAt: 10:05) ← updated └── watchQuery streams emit new state ``` --- ## Configuring Stale Time ```dart [user_repository.dart] // Default: Duration.zero: always stale, always revalidates // (still serves cached data instantly on every call) options: QoraOptions(staleTime: Duration.zero), // Fresh for 5 minutes: no network call within the window options: QoraOptions(staleTime: Duration(minutes: 5)), // Configuration data: only revalidates once per hour options: QoraOptions(staleTime: Duration(hours: 1)), ``` ### Choosing `staleTime` by data type | Data type | Recommended `staleTime` | | -------------------------- | ----------------------- | | Live prices, notifications | `Duration.zero` | | User profile, settings | `Duration(minutes: 5)` | | Post / article content | `Duration(minutes: 10)` | | Rarely-changing config | `Duration(hours: 1)` | | Static reference data | `Duration(days: 1)` | --- ## SWR with `fetchQuery` When `fetchQuery` hits a stale cache entry, it returns the stale data synchronously and fires a revalidation in the background. The call resolves immediately: ```dart [user_repository.dart] // This returns immediately with cached data if available final user = await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions(staleTime: Duration(minutes: 5)), ); // If data was stale: // - `user` is the stale value (returned instantly) // - A background fetch is already in progress // - Active watchQuery streams will receive the updated user when the fetch completes ``` --- ## SWR with `watchQuery` `watchQuery` streams are the primary interface for SWR. Subscribers receive the latest state, including the result of background revalidation: ```dart [user_page.dart] StreamBuilder>( stream: client.watchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions(staleTime: Duration(minutes: 5)), ), builder: (context, snapshot) { return switch (snapshot.data) { // Shows stale data instantly while Loading(previousData: user) is emitted Loading(:final previousData) when previousData != null => Stack(children: [ UserCard(previousData), const LinearProgressIndicator(), ]), Success(:final data) => UserCard(data), Failure(:final error, :final previousData) => Column(children: [ if (previousData != null) UserCard(previousData), ErrorBanner('$error'), ]), _ => const CircularProgressIndicator(), }; }, ) ``` ### State sequence on mount with stale cache ```text Subscription starts ↓ emits immediately Loading(previousData: Alice) ← stale data visible, subtle loading indicator ↓ fetch completes Success(data: Alice McBride) ← fresh data, UI updates seamlessly ``` --- ## Refetch on Mount By default, `watchQuery` triggers a fetch on the first subscription when data is stale. This behavior is controlled by `refetchOnMount`: ```dart [main.dart] // Always refetch on mount, even if cached data is still fresh (default when // QoraClientConfig.refetchOnMount is true) options: QoraOptions(refetchOnMount: true), // Only fetch if data is missing or stale; skip refetch when data is fresh options: QoraOptions(refetchOnMount: false), // Inherit the global QoraClientConfig.refetchOnMount setting (default) options: QoraOptions(refetchOnMount: null), ``` --- ## Polling (Active Revalidation) For live data, combine SWR with `refetchInterval` to continuously refresh the cache: ```dart [dashboard_page.dart] client.watchQuery( key: ['dashboard', 'stats'], fetcher: api.getDashboardStats, options: QoraOptions( staleTime: Duration(seconds: 10), refetchInterval: Duration(seconds: 30), ), ) ``` The interval timer starts when the first subscriber attaches, fires every `refetchInterval`, and stops when the last subscriber cancels. Polling does not occur for off-screen queries. --- ## Force Refresh To bypass the stale check and force an immediate network request, invalidate the key first: ```dart [main.dart] // Force a fresh fetch on next access client.invalidate(['users', userId]); // Or immediately prefetch fresh data await client.prefetch( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions(staleTime: Duration.zero), ); ``` --- ## Next Steps - [Deduplication](https://qora.meragix.dev/deduplication): How concurrent callers share a single request - [Caching](https://qora.meragix.dev/caching): LRU, GC, and `gcTime` vs `staleTime` - [QoraOptions API](https://qora.meragix.dev/en/api-reference/qora-options): Full options reference # Deduplication ## The Problem Multiple widgets or services can independently request the same data at the same moment: on page load, during a fast navigation, or when a feature flag is evaluated. Without deduplication, each call fires its own network request: ```text Without deduplication: Widget A → GET /users/1 ←─┐ Widget B → GET /users/1 ←─┤ 3 identical network calls Widget C → GET /users/1 ←─┘ ``` This wastes bandwidth, adds server load, and risks race conditions where responses arrive out of order. --- ## How Qora Deduplicates Qora tracks every in-flight request in a `_pendingRequests` map keyed by the normalised query key. When a second call arrives while the first is still pending: 1. The in-flight `Future` is reused. No new network call is issued. 2. All callers await the same result. 3. The cache is updated once when the single response arrives. 4. All active `watchQuery` streams receive the state update. ```text With Qora: Widget A ─────────────────┐ Widget B → fetchQuery → dedup → GET /users/1 (1 request) Widget C ─────────────────┘ all three resolve with the same result ``` --- ## Example ```dart [main.dart] // All three calls fire simultaneously: only one HTTP request is made final f1 = client.fetchQuery(key: ['users', 1], fetcher: () => api.getUser(1)); final f2 = client.fetchQuery(key: ['users', 1], fetcher: () => api.getUser(1)); final f3 = client.fetchQuery(key: ['users', 1], fetcher: () => api.getUser(1)); // f1, f2, f3 are the same Future: one network call, three waiters final results = await Future.wait([f1, f2, f3]); // results = [alice, alice, alice] ``` The same applies to `watchQuery` subscribers: ```dart [main.dart] // Mounted simultaneously on the same page final stream1 = client.watchQuery(key: ['users', 1], fetcher: () => api.getUser(1)); final stream2 = client.watchQuery(key: ['users', 1], fetcher: () => api.getUser(1)); // One fetch fires; both streams emit the same Loading → Success sequence ``` --- ## Deduplication Scope Deduplication applies within a single `QoraClient` instance. A single shared client per application (or DI scope) provides full deduplication coverage across all widgets and services. ```dart [main.dart] // Good: One shared client: full deduplication final client = QoraClient(); class UserRepository { UserRepository(this._client); // injected final QoraClient _client; } class PostRepository { PostRepository(this._client); // same instance final QoraClient _client; } ``` ```dart [main.dart] // Bad: Two separate clients: no deduplication between them final clientA = QoraClient(); final clientB = QoraClient(); // different cache, different pending map ``` --- ## Interaction with Retries Deduplication and retry compose correctly. If the in-flight request fails, all waiters receive the same `Failure` state after all retry attempts are exhausted: ```text fetchQuery called by A, B, C (deduplicated to 1 request) ↓ attempt 1 → fails ↓ wait 1 s ↓ attempt 2 → fails ↓ wait 2 s ↓ attempt 3 → fails ↓ Failure stored in cache A, B, C all receive Failure ``` --- ## When Deduplication Does Not Apply Deduplication merges only requests that are in-flight at the same time. Sequential calls are independent and resolved through the cache: ```dart [main.dart] // Sequential: two network calls (each hits SWR or cache independently) final u1 = await client.fetchQuery(key: ['users', 1], fetcher: api.getUser); final u2 = await client.fetchQuery(key: ['users', 1], fetcher: api.getUser); // ↑ served from cache (staleTime check) ``` --- ## Next Steps - [Caching](https://qora.meragix.dev/caching): How the cache stores and ages entries - [Stale-While-Revalidate](https://qora.meragix.dev/stale-while-revalidate): Background revalidation - [Mutations & Invalidation](https://qora.meragix.dev/mutations): Writes and how they update the cache - [QoraClient API](https://qora.meragix.dev/en/api-reference/qora-client): Full client reference # Mutations & Invalidation A query reads server data and keeps it in the cache. A **mutation** changes server data: create, update, delete. Mutations are not cached and not deduplicated; they run when you call them, once per call. The hard part of a write is not sending it. It is what happens to the cached data afterwards: the list that should now contain the new item, the detail screen that shows the old title. This page is about that part. ## The lifecycle A mutation moves through four states: ```text MutationIdle ──mutate()──▶ MutationPending ──▶ MutationSuccess └──▶ MutationFailure ``` Its callbacks run in a fixed order. Knowing it tells you where each piece of logic belongs: ```text mutate(variables) 1. onMutate(variables) → returns a context (usually a snapshot) 2. state: MutationPending 3. mutator(variables) → retried retryCount times (default 0) success: 4. state: MutationSuccess 5. onSuccess(data, variables, context) 6. invalidates, then invalidatesTags 7. onSettled(data, null, variables, context) failure: 4. state: MutationFailure 5. onError(error, variables, context) 6. onSettled(null, error, variables, context) ``` The `context` returned by `onMutate` is passed to every later callback. That is how a rollback in `onError` knows what to restore. If `onMutate` itself throws, the mutation goes straight to `MutationFailure`: the mutator is not called, and neither is `onError`. ## Three ways to update the cache after a write | Strategy | What you do | Use it when | | ---------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Invalidate** | Mark the affected queries stale; they refetch | The default. The server is the source of truth and one extra request is acceptable | | **Write the response** | Put the server's response in the cache with `setQueryData` | The server returns the updated entity, and you want to skip the refetch | | **Optimistic update** | Change the cache in `onMutate`, roll back in `onError` | The result is predictable and the UI must react instantly, or work offline | They combine. The most robust pattern is an optimistic update followed by an invalidation: the UI changes immediately, and the refetch replaces your guess with what the server actually stored. ### Invalidate Declare the keys a mutation affects; Qora invalidates them after `onSuccess`: ```dart QoraMutationBuilder( mutator: (title) => api.createTodo(title), options: const MutationOptions(invalidates: [['todos']]), builder: (context, state, mutate) => AddTodoField( enabled: !state.isPending, onSubmit: mutate, ), ) ``` Invalidating a key marks its data stale. A query that is on screen refetches immediately; meanwhile its state is `Loading` with the old data as `previousData`, so the screen can keep showing it. A query that is not on screen refetches the next time a widget uses it. ::warning `invalidates` matches **exact keys** . `[['todos']]` invalidates `['todos']` , not `['todos', 42]` . To invalidate a whole family, call `client.invalidatePrefix(['todos'])` in `onSuccess` , or use tags ( `providesTags` on the queries, `invalidatesTags` on the mutation). See [Query Keys](https://qora.meragix.dev/query-keys) . :: ### Write the response When the server returns the updated entity, put it in the cache instead of refetching it: ```dart options: MutationOptions( onSuccess: (updated, _, __) async { client.setQueryData(['todos', updated.id], updated); }, ), ``` Every widget showing `['todos', updated.id]` rebuilds with the new value, and no request is made. Lists that contain the item are a separate key: invalidate them, or update them the same way. ### Optimistic update Change the cache before the server answers, keep a snapshot, and restore it on failure: ```dart options: MutationOptions?>( onMutate: (todo) async { final previous = client.getQueryData>(['todos']); client.setQueryData>( ['todos'], [for (final t in previous ?? []) t.id == todo.id ? todo : t], ); return previous; // becomes `context` in the other callbacks }, onError: (_, __, previous) async { client.restoreQueryData>(['todos'], previous); }, invalidates: const [['todos']], // reconcile with the server either way ), ``` `restoreQueryData` puts the snapshot back, or removes the entry if the snapshot is `null`. The [Optimistic Updates](https://qora.meragix.dev/../recipes/optimistic-updates) recipe covers lists, multiple keys and concurrent edits. ## Mutations and the network By default a mutation sends its request immediately. While the device is offline that request fails like any network error, and the failure path above runs. With `offlineQueue: true`, a mutation called while offline is **queued** instead: `onMutate` runs right away, so optimistic updates work offline, and the request is sent when the device reconnects. [Offline & Network](https://qora.meragix.dev/offline) explains the queue, its order and its limits. ## Next Steps - [Offline & Network](https://qora.meragix.dev/offline): paused queries, the mutation queue, reconnect - [Mutations recipe](https://qora.meragix.dev/../recipes/mutations): `QoraMutationBuilder`, retries, tags, standalone controllers - [Optimistic Updates](https://qora.meragix.dev/../recipes/optimistic-updates): patterns for lists and multiple keys - [QoraMutationBuilder](https://qora.meragix.dev/../flutter-integration/qora-mutation-builder): the widget API # Offline & Network Most libraries treat "offline" as one more error. Qora treats it as a state: reads **pause** instead of failing, writes can be **queued**, and both resume on their own when the network returns. This page describes the model; the [Network-Aware recipe](https://qora.meragix.dev/../recipes/network-aware) covers configuration in detail. ## How Qora knows the device is offline The client listens to a `ConnectivityManager`. `QoraScope.auto` attaches `FlutterConnectivityManager`, built on `connectivity_plus`, so a standard Flutter setup needs nothing more. Two consequences worth knowing: - **Without a connectivity manager, the client assumes it is online.** Nothing pauses, and nothing replays on reconnect. A pure Dart app can attach its own with `client.attachConnectivityManager(...)`. - **Connectivity means a network interface, not a reachable server.** On a captive portal or with the API down, the device counts as online: requests run, are retried, and end in `Failure`. ## Two axes: what you have, and what is happening A query has a state and a fetch status, and offline handling lives in the second one: - `QoraState` is **what you have**: `Initial`, `Loading`, `Success`, `Failure`, each carrying data when there is some. - `FetchStatus` is **what the engine is doing**: `fetching`, `paused` (waiting for the network) or `idle`. `QoraBuilder` passes both to your builder. The usual offline combinations: | State | `fetchStatus` | Meaning | | ----------------------------- | ------------- | ------------------------------------------------------------------- | | `Loading`, `previousData` set | `paused` | Offline, cached data available, refetch waiting | | `Loading`, no `previousData` | `paused` | Offline and nothing cached: show an offline screen, not a spinner | | `Success` | `paused` | Offline in `offlineFirst` mode: cached data served, refetch waiting | | `Success` | `idle` | Fresh data, nothing to do | A fetch started while offline does not throw inside widgets. Called imperatively, `fetchQuery` returns the cached data if there is some, and otherwise throws `QoraOfflineException`. ## Reads: `NetworkMode` `networkMode` decides what a query does when it must fetch while offline. Set it per query in `QoraOptions`, or for every query in `defaultOptions`. | Mode | While offline | On reconnect | | ------------------ | ------------------------------------------------------------------------------------- | --------------- | | `online` (default) | The fetch is not sent. State becomes `Loading(previousData: cached)`, status `paused` | The fetch runs | | `offlineFirst` | With cached data: state stays `Success`, status `paused`. Without: same as `online` | The fetch runs | | `always` | The fetch is sent anyway, and is retried and fails like any error | Nothing special | Use `always` for fetchers that do not need the network, such as a local database. Use `offlineFirst` when a screen should never flip to a loading state just because the device went offline. ## Writes: the offline mutation queue A mutation does not pause. Without opt-in, a mutation called while offline sends its request and fails. With `offlineQueue: true` in its `MutationOptions`, it is queued instead: ```text mutate(variables) while offline 1. onMutate runs now → optimistic cache updates work offline 2. the call is added to the queue 3. state: MutationSuccess(isOptimistic: true) if optimisticResponse is set, MutationPending otherwise reconnect 4. the call is sent, once, in the order it was queued success → onSuccess, invalidates, onSettled failure → onError(error, variables, context from step 1), onSettled ``` Three properties matter in practice: - **The queue belongs to the client, not to the widget.** A queued write is sent even if the screen that made it was closed. - **A failed replay is not retried.** `onError` runs with the snapshot from `onMutate`, so the rollback you wrote for the online case also covers this one. Tell the user there. - **Order is preserved, dependencies are not checked.** If a queued "create" is rejected, a queued "update" of the same item is still sent, and fails in turn. `optimisticResponse` only sets the mutation's own state, for example to show a "queued" badge. Changes to cached query data belong in `onMutate`. ::note `QoraMutationBuilder` and `useMutation` both use the queue ( `useMutation` since `qora_hooks` 1.3.1). A raw `MutationController` needs `isOnline` and `offlineQueue` passed explicitly. :: ## Reconnect When the connectivity manager reports the network is back, the client: 1. **Replays paused queries** in batches. `ReconnectStrategy` sets the batch size and a random delay between batches (default: 5 at a time, up to 100 ms), so a hundred paused queries do not hit the server at once. 2. **Then replays the mutation queue**, after the reads, one mutation at a time. Independently, `QoraScope.auto` also refetches stale on-screen queries when the app returns to the foreground. ## Across restarts | What | Survives a restart? | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | | Cached query data | Yes, with `PersistQoraClient` and a `StorageAdapter`. See [Persistence](https://qora.meragix.dev/../recipes/persistence) | | Queued mutations | No. The queue lives in memory; persisting it is planned for 1.4 | An app that must not lose writes made offline before being killed should, for now, also save those writes itself (for example to a local outbox table) and resend them at startup. ## Next Steps - [Network-Aware recipe](https://qora.meragix.dev/../recipes/network-aware): configuration, `isOptimistic`, `ReconnectStrategy` - [Network Status widgets](https://qora.meragix.dev/../flutter-integration/network-status): offline banners - [Offline-First case study](https://qora.meragix.dev/../examples/offline-first): a full app with persistence and a queue - [Mutations & Invalidation](https://qora.meragix.dev/mutations): the write lifecycle this page builds on # Setup ## Installation Add `qora_flutter` to your `pubspec.yaml`: ```yaml [pubspec.yaml] dependencies: qora_flutter: ^ 1.3.1 ``` Then fetch: ```bash flutter pub get ``` ::tip `qora_flutter` automatically includes `qora` and `connectivity_plus` as dependencies. You do not need to add them separately. :: --- ## Create the Client `QoraClient` is the central cache and request orchestrator. Create **one instance per app** (or per DI scope): ```dart [main.dart] import 'package:flutter/foundation.dart'; import 'package:qora_flutter/qora_flutter.dart'; final client = QoraClient( config: QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), retryCount: 3, ), debugMode: kDebugMode, ), ); ``` ::tip Set `debugMode: kDebugMode` to get detailed logs in development without any performance cost in production. :: --- ## Wrap Your App with QoraScope `QoraScope` is a `StatefulWidget` that exposes the client to every descendant via an internal `InheritedWidget`. Place it as close to the root as possible: ```dart [main.dart] import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; void main() { final client = QoraClient( config: const QoraClientConfig(debugMode: kDebugMode), ); runApp( QoraScope( client: client, child: const MyApp(), ), ); } ``` Any descendant widget can now access the client via `context.qora` or `QoraScope.of(context)`. --- ## Optional: Lifecycle and Connectivity Managers For automatic refetch-on-resume and refetch-on-reconnect behavior, pass the built-in managers to `QoraScope`: ```dart [main.dart] void main() { final client = QoraClient(); runApp( QoraScope( client: client, lifecycleManager: FlutterLifecycleManager( qoraClient: client, // Invalidate all queries if the app was backgrounded for more than 30 s minBackgroundDuration: const Duration(seconds: 30), ), connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ); } ``` | Manager | Behaviour | | ---------------------------- | --------------------------------------------------------------------------------------------------- | | `FlutterLifecycleManager` | Invalidates all queries when the app resumes after a pause longer than `minBackgroundDuration`. | | `FlutterConnectivityManager` | Emits `NetworkStatus` events; reconnect logic is delegated to `QoraClient` via `reconnectStrategy`. | `QoraScope` automatically calls `start()` and `dispose()` on both managers: no manual lifecycle wiring required. ### Shorthand: `QoraScope.auto()` For convenience, use `QoraScope.auto()` to wire both managers in a single call: ```dart [main.dart] void main() { runApp( QoraScope.auto( client: QoraClient(), child: const MyApp(), ), ); } ``` This is equivalent to manually passing `FlutterLifecycleManager` and `FlutterConnectivityManager`. Use `enableLifecycle` and `enableConnectivity` to disable specific listeners: ```dart [main.dart] QoraScope.auto( client: QoraClient(), enableLifecycle: false, // disable refetch-on-resume enableConnectivity: false, // disable refetch-on-reconnect minBackgroundDuration: const Duration(seconds: 30), child: const MyApp(), ) ``` | Parameter | Type | Default | Description | | ----------------------- | ---------- | ---------------------- | ------------------------------------------------------------ | | `enableLifecycle` | `bool` | `true` | Enables `FlutterLifecycleManager` (refetch on app resume). | | `enableConnectivity` | `bool` | `true` | Enables `FlutterConnectivityManager` (refetch on reconnect). | | `minBackgroundDuration` | `Duration` | `Duration(seconds: 5)` | Minimum pause before invalidation on resume. | --- ## Next Steps - [QoraScope](https://qora.meragix.dev/qora-scope): Detailed API and nested scope patterns - [QoraBuilder](https://qora.meragix.dev/qora-builder): Fetching data and rendering states in widgets # QoraScope `QoraScope` is the single point of truth for your `QoraClient` in Flutter. Wrap your app (or a sub-tree) with it once, and every descendant gains access to the same cache and request pipeline. --- ## Anatomy ### Default constructor ```dart [main.dart] QoraScope( // Required: the shared cache and request engine client: client, // Optional: invalidates queries on app resume lifecycleManager: FlutterLifecycleManager(qoraClient: client), // Optional: pure signal provider for network connectivity connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ) ``` ### `QoraScope.auto()` A convenience constructor that wires both managers automatically: ```dart [main.dart] QoraScope.auto( client: QoraClient(), child: const MyApp(), ) // Equivalent to passing FlutterLifecycleManager + FlutterConnectivityManager manually. // Disable specific listeners: QoraScope.auto( client: QoraClient(), enableLifecycle: false, enableConnectivity: false, child: const MyApp(), ) ``` `QoraScope` is a `StatefulWidget`. It calls `start()` on each manager in `initState` and calls `dispose()` on both managers **and** on the client itself in `dispose`. You never manage their lifetime manually. --- ## Accessing the Client ### `context.qora` (recommended) The shortest path to the client from any widget: ```dart [user_list_screen.dart] // Invalidate a specific query after a mutation context.qora.invalidate(['posts', postId]); // Invalidate by predicate context.qora.invalidateWhere((key) => key.firstOrNull == 'posts'); // Optimistic update final snapshot = context.qora.getQueryData(['users', userId]); context.qora.setQueryData(['users', userId], updatedUser); ``` ### `QoraScope.of(context)` Equivalent to `context.qora`. Throws a descriptive `FlutterError` if no `QoraScope` ancestor is found in the tree: ```dart [user_repository.dart] final client = QoraScope.of(context); await client.fetchQuery>( key: ['posts'], fetcher: api.getPosts, ); ``` ### `QoraScope.maybeOf(context)` Returns `null` instead of throwing when `QoraScope` is absent. Useful when the scope is optional in part of the tree: ```dart [optional_widget.dart] final client = QoraScope.maybeOf(context); if (client == null) return const SizedBox.shrink(); ``` ### `context.qoraOrNull` Shorthand for `QoraScope.maybeOf(context)`: ```dart [nav_bar.dart] context.qoraOrNull?.invalidate(['notifications']); ``` --- ## Nested Scopes You can nest multiple `QoraScope` widgets to isolate caches for different parts of the app: for example, separate caches for public and authenticated routes: ```dart [app.dart] QoraScope( client: publicClient, child: Navigator( pages: [ MaterialPage(child: const HomeScreen()), MaterialPage( child: QoraScope( // Separate cache for authenticated routes client: authClient, child: const DashboardScreen(), ), ), ], ), ) ``` Each `QoraScope` manages its own client: queries are fully independent between scopes. --- ## Lifecycle and Connectivity Managers ### `FlutterLifecycleManager` Observes `AppLifecycleState` changes via `WidgetsBindingObserver`. When the app resumes after a pause longer than `minBackgroundDuration`, it calls `client.invalidateWhere((_) => true)`: ```dart [main.dart] FlutterLifecycleManager( qoraClient: client, minBackgroundDuration: const Duration(minutes: 1), // default: 5 s ) ``` Active `QoraBuilder` widgets receive the resulting `Loading(previousData: data)` state and immediately dispatch re-fetch requests. Users see the stale data instantly while fresh data loads in the background. ### `FlutterConnectivityManager` A **pure signal provider**: it converts `connectivity_plus` events into `NetworkStatus` values and nothing else. `QoraScope` wires it to the client automatically via `attachConnectivityManager()` after calling `start()`. All reconnect logic (query replay, offline mutation queue replay, thundering-herd batching) runs inside `QoraClient`, not inside the manager: ```dart [main.dart] // No QoraClient reference required: pure signal provider FlutterConnectivityManager() ``` Requires `connectivity_plus` in your `pubspec.yaml`: ```yaml [pubspec.yaml] dependencies: connectivity_plus: ^6.0.0 ``` ::tip The `FlutterConnectivityManager` no longer invalidates queries directly. Reconnect behaviour is controlled by `QoraClientConfig.reconnectStrategy` and per-query `QoraOptions.networkMode` . Implement a custom `ConnectivityManager` if you need to source connectivity events from a different package. :: ### Accessing the `ConnectivityManager` from descendants `QoraScope.connectivityManagerOf(context)` exposes the manager to widgets deeper in the tree (used internally by `NetworkStatusBuilder`): ```dart [lib/ui/offline_badge.dart] final manager = QoraScope.connectivityManagerOf(context); final status = manager?.currentStatus ?? NetworkStatus.unknown; ``` --- ## API Reference | Property / Method | Type | Description | | ------------------------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client` | `QoraClient` | **Required.** The shared client for this scope. | | `lifecycleManager` | `LifecycleManager?` | Optional. Refetch on app resume. | | `connectivityManager` | `ConnectivityManager?` | Optional. Pure signal provider: no `QoraClient` ref needed. | | `child` | `Widget` | **Required.** The widget subtree. | | `QoraScope.auto()` | Constructor | Shorthand that wires `FlutterLifecycleManager` + `FlutterConnectivityManager`. Accepts `enableLifecycle`, `enableConnectivity`, `minBackgroundDuration`. | | `QoraScope.of(context)` | `QoraClient` | Returns the client; throws if none found. | | `QoraScope.maybeOf(context)` | `QoraClient?` | Returns the client, or `null` if none found. | | `QoraScope.connectivityManagerOf(context)` | `ConnectivityManager?` | Returns the active manager; used by `NetworkStatusBuilder`. | | `context.qora` | `QoraClient` | Shorthand for `QoraScope.of(context)`. | | `context.qoraOrNull` | `QoraClient?` | Shorthand for `QoraScope.maybeOf(context)`. | --- ## Next Steps - [QoraBuilder](https://qora.meragix.dev/qora-builder): Fetch data and bind state to Flutter widgets - [Network Status](https://qora.meragix.dev/network-status): `NetworkStatusBuilder` and `NetworkStatusIndicator` widgets - [Network Aware Guide](https://qora.meragix.dev/../recipes/network-aware): Full offline-first setup walkthrough - [Quick Start](https://qora.meragix.dev/../getting-started/quick-start): End-to-end Flutter integration walkthrough # QoraBuilder `QoraBuilder` is the primary way to bind server data to Flutter UI. It handles the full query lifecycle: fetching, caching, reactivity, background revalidation: with a single widget declaration. --- ## Basic Usage ```dart [user_list_screen.dart] QoraBuilder>( queryKey: ['users'], fetcher: () => api.getUsers(), builder: (context, state, fetchStatus) { return switch (state) { Initial() => const SizedBox.shrink(), Loading(:final previousData) => previousData != null ? Stack(children: [UserList(previousData), const LinearProgressIndicator()]) : const CircularProgressIndicator(), Success(:final data) => UserList(data), Failure(:final error, :final previousData) => previousData != null ? Column(children: [UserList(previousData), ErrorBanner(error)]) : ErrorScreen(error), }; }, ) ``` The builder receives three arguments: 1. **`context`**: the `BuildContext` 2. **`state`**: the current `QoraState` (what you have) 3. **`fetchStatus`**: the current `FetchStatus` (what Qora is doing right now) Exhaustive pattern-matching over the sealed `state` is the idiomatic approach. Use `fetchStatus` for secondary indicators (progress bars, offline chips) without changing the primary state view. --- ## How It Works On mount, `QoraBuilder`: 1. **Subscribes** to `client.watchState(queryKey)`: an observe-only stream that emits every state update for this key (including updates triggered by other widgets or background services). 2. **Fetches** via `client.fetchQuery(...)` if `enabled` is `true`. The client handles deduplication, caching, and stale-while-revalidate automatically. When the query is **externally invalidated** (via `client.invalidate()`, a lifecycle resume, or a reconnect event), the state transitions to `Loading(previousData: data)`. `QoraBuilder` detects this pattern in the stream and dispatches a fresh `fetchQuery`. The client's deduplication ensures only one network request fires even if multiple widgets are subscribed to the same key. --- ## Props | Prop | Type | Default | Description | | ------------------ | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `queryKey` | `Object` (`List` or `QoraKey`) | required | Cache key. Changing it triggers re-subscription and a fresh fetch. | | `fetcher` | `Future Function()` | required | Async function that returns the data. Must throw on failure. | | `builder` | `Widget Function(BuildContext, QoraState, FetchStatus)` | required | Builds the widget tree from the current state and fetch status. | | `options` | `QoraOptions?` | `null` | Per-query config merged on top of `QoraClientConfig.defaultOptions`. | | `enabled` | `bool` | `true` | When `false`, no fetch is triggered. The widget still observes the current cached state. | | `keepPreviousData` | `bool` | `false` | Augments `Loading`/`Failure` states with the last known `Success` data from this widget. | | `select` | `Object? Function(T data)?` | `null` | Rebuild filter: the widget rebuilds only when the state kind or this value changes. The builder still receives the full state. | | `client` | `QoraClient?` | `null` | Override the client from `QoraScope`. Useful for testing or nested scopes. | --- ## Patterns ### Handling all four states Use `.when()` for exhaustive matching or `.maybeWhen()` when you only care about specific states: ```dart [user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => api.getUser(userId), builder: (context, state, fetchStatus) { return state.when( onInitial: () => const SizedBox.shrink(), onLoading: (previousData) => previousData != null ? UserCard(user: previousData, isRefreshing: true) : const CircularProgressIndicator(), onSuccess: (user, updatedAt) => UserCard(user: user), onError: (error, stackTrace, previousData) => ErrorScreen( message: error.toString(), onRetry: () => context.qora.invalidate(['users', userId]), ), ); }, ) ``` --- ### Offline-aware UI with `FetchStatus` `FetchStatus` is the second axis alongside `QoraState`. Use it to show a background-refresh indicator or an "offline: waiting" message without changing the primary state view: ```dart [user_list_screen.dart] QoraBuilder>( queryKey: ['users'], fetcher: api.getUsers, builder: (context, state, fetchStatus) { return switch (state) { Initial() || Loading(previousData: null) when fetchStatus == FetchStatus.paused => const Center(child: Text('Offline: connect to load')), Initial() || Loading() => const CircularProgressIndicator(), Success(:final data) => Column( children: [ if (fetchStatus == FetchStatus.fetching) const LinearProgressIndicator(), if (fetchStatus == FetchStatus.paused) const OfflineSyncChip(), UserList(data), ], ), Failure(:final error) => ErrorScreen(error), }; }, ) ``` | `FetchStatus` value | Meaning | | ---------------------- | --------------------------------------------------------- | | `FetchStatus.fetching` | A network request is in-flight. | | `FetchStatus.paused` | Offline: fetch is queued and will replay on reconnect. | | `FetchStatus.idle` | No active fetch (data is fresh or the state is terminal). | ::tip `FetchStatus.paused` only appears when `QoraOptions.networkMode` is `NetworkMode.online` (the default) and the device has no connectivity. If you use `NetworkMode.always` , fetches run regardless of connectivity and this state is never emitted. :: --- ### Conditional queries (`enabled`) Prevent fetching until a dependency is available: ```dart [profile_screen.dart] QoraBuilder( queryKey: ['profiles', userId], fetcher: () => api.getProfile(userId!), enabled: userId != null, // No fetch until userId is resolved builder: (context, state, fetchStatus) { ... }, ) ``` When `enabled` flips from `false` to `true` (e.g. via `setState`), a fetch is triggered automatically. --- ### Performance: derived subscriptions with `select` `select` lets you derive a computed value from the query data. The widget only rebuilds when the selected value changes (compared via `==`). Use it to avoid unnecessary rebuilds when only a subset of the data is needed: ```dart [user_list_screen.dart] // Rebuild only when the user count changes. QoraBuilder>( queryKey: ['users'], fetcher: () => api.getUsers(), select: (users) => users.length, builder: (context, state, fetchStatus) => Text('${state.dataOrNull?.length ?? 0} users'), ) ``` The builder still receives the full, up-to-date `QoraState`: `select` decides *when* to rebuild, not *what* is passed. A change of state kind (`Loading`, `Success`, `Failure`) or of `FetchStatus` always rebuilds, so errors and spinners are never hidden. Without `select`, the widget rebuilds on every state change. --- ### Flicker-free pagination (`keepPreviousData`) When the query key changes (e.g. page number increments), the new key starts in `Initial` state: causing a jarring full-screen spinner. `keepPreviousData` prevents this by carrying the last successful data forward into the new key's loading phase: ```dart [paginated_posts_screen.dart] QoraBuilder>( queryKey: ['posts', currentPage], fetcher: () => api.getPosts(page: currentPage), keepPreviousData: true, builder: (context, state, fetchStatus) { // dataOrNull returns data from Success, Loading.previousData, or Failure.previousData final posts = state.dataOrNull ?? []; return Stack( children: [ PostList(posts: posts), if (state.isLoading) const LinearProgressIndicator(), ], ); }, ) ``` ::tip `keepPreviousData` only fills in the gap: if the client already supplies `previousData` in the state, that value takes priority. The widget's local cache is purely a fallback. :: --- ### Displaying a freshness timestamp `Success` carries `updatedAt`: the moment the data was last fetched: ```dart [user_screen.dart] onSuccess: (user, updatedAt) => Column( children: [ UserCard(user: user), Text( 'Last updated ${_timeAgo(updatedAt)}', style: Theme.of(context).textTheme.labelSmall, ), ], ), ``` --- ### Polling Pass `refetchInterval` via `options` to poll on a fixed schedule: ```dart [notifications_screen.dart] QoraBuilder>( queryKey: ['notifications'], fetcher: api.getNotifications, options: const QoraOptions(refetchInterval: Duration(seconds: 30)), builder: (context, state) { ... }, ) ``` Polling is active only while the `QoraBuilder` is in the widget tree. It stops automatically on dispose. --- ### Seeding initial data (`initialData`) `QoraOptions.initialData` seeds the cache with placeholder data before any fetch completes. Use it for SSR hydration, instant renders from shared preferences, or a previous app session: ```dart [user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions( initialData: cachedUser, // pre-seed the cache initialDataUpdatedAt: DateTime.now(), // mark as fresh staleTime: const Duration(minutes: 5), // skip network if fresh ), builder: (context, state, fetchStatus) { ... }, ) ``` | Parameter | Default | Description | | ---------------------- | ------- | -------------------------------------------------------------------------- | | `initialData` | `null` | Placeholder data of type `T` to pre-populate the cache. | | `initialDataUpdatedAt` | epoch | Timestamp for staleness calc. Set to `DateTime.now()` to skip the network. | | `placeholderData` | `null` | Lazy function that derives initial data from another cache entry. | When `initialData` is provided: - If **fresh** (`updatedAt` within `staleTime`): the widget renders with the placeholder immediately and **skips the network**. - If **stale** (default epoch timestamp): the widget renders with the placeholder and triggers a background SWR refetch. The placeholder is carried via `Loading.previousData`. ::note `initialData` takes precedence over `placeholderData` . A runtime type mismatch is silently ignored (the cache stays `Initial` and fetches normally). :: --- ## Key Changing Behaviour When `queryKey` changes (`didUpdateWidget`), `QoraBuilder`: 1. Cancels the previous stream subscription. 2. Subscribes to the new key's stream (starting from its current cache state: could be `Initial`, `Success`, etc.). 3. Dispatches a fresh `fetchQuery` for the new key (if `enabled`). This is how pagination, tab switching, and filter changes are handled automatically without any extra state management. --- ## `QoraStateBuilder`: Observe Without Fetching `QoraStateBuilder` subscribes to query state without triggering a fetch. Use it when another widget or service owns the fetch lifecycle and you only want to display the current state. ```dart [notification_badge.dart] // NotificationsScreen (a QoraBuilder) owns the fetch. // This badge simply mirrors the cached state of the same key. QoraStateBuilder>( queryKey: ['notifications'], builder: (context, state) { final count = state.dataOrNull?.length ?? 0; return Badge( label: Text('$count'), isLabelVisible: count > 0, child: const Icon(Icons.notifications), ); }, ) ``` ### `QoraStateBuilder` Props | Prop | Type | Description | | ---------- | --------------------------------------------- | ---------------------------------------------- | | `queryKey` | `Object` (`List` or `QoraKey`) | Key to observe. | | `builder` | `Widget Function(BuildContext, QoraState)` | Builds the widget tree from the current state. | | `client` | `QoraClient?` | Override the client from `QoraScope`. | ::tip `QoraStateBuilder` is ideal for badges, counters, or secondary displays that derive their data from a query fetched elsewhere: for example, a cart item count or an unread message badge in an app bar. :: --- ## Next Steps - [Network Aware Guide](https://qora.meragix.dev/../recipes/network-aware): Full offline setup with `FetchStatus` and offline mutation queue - [Network Status](https://qora.meragix.dev/network-status): `NetworkStatusBuilder` and `NetworkStatusIndicator` widgets - [Query States](https://qora.meragix.dev/../core-concepts/query-states): Full state machine reference - [QoraOptions](https://qora.meragix.dev/../api-reference/qora-options): Stale time, retry, polling, and `networkMode` configuration - [Optimistic Updates](https://qora.meragix.dev/../recipes/optimistic-updates): Instant UI with safe rollback # QoraMutationBuilder `QoraMutationBuilder` creates a `MutationController` on mount, disposes it on unmount, and rebuilds whenever the mutation state changes. The `builder` callback receives the current `MutationState` and a `mutate(variables)` callback to trigger the write from the UI. ::tip `QoraMutationBuilder` does not require a `QoraScope` ancestor. It wires itself to the client automatically when one is present, and falls back to standalone mode gracefully when one is not. :: --- ## Basic Usage ```dart [create_post_screen.dart] QoraMutationBuilder( mutator: (title) => api.createPost(title), builder: (context, state, mutate) { return ElevatedButton( onPressed: state.isPending ? null : () => mutate('New Post'), child: state.isPending ? const CircularProgressIndicator() : const Text('Create'), ); }, ) ``` The button is disabled while the mutation is running. Pass `null` to `onPressed`: Flutter disables the button automatically. --- ## State-Driven UI Pattern-match directly on the sealed `MutationState` for exhaustive UI coverage: ```dart [create_post_screen.dart] QoraMutationBuilder( mutator: (title) => api.createPost(title), builder: (context, state, mutate) { return switch (state) { MutationIdle() => SubmitButton(onTap: () => mutate('Post')), MutationPending() => const CircularProgressIndicator(), MutationSuccess(:final data) => SuccessBanner('Created: ${data.title}'), MutationFailure(:final error) => Column(children: [ ErrorBanner(error), TextButton( onPressed: () => mutate('Post'), // Retry child: const Text('Try again'), ), ]), }; }, ) ``` --- ## Props | Prop | Type | Default | Description | | ---------- | ------------------------------------------------- | -------- | ------------------------------------------------------------ | | `mutator` | `Future Function(TVariables)` | required | Async write function. Named `mutator` to mirror `fetcher`. | | `builder` | `Widget Function(context, MutationState, mutate)` | required | Builds the widget tree from the current state. | | `options` | `MutationOptions?` | `null` | Hooks, retry, offline queue, and optimistic response config. | | `metadata` | `Map?` | `null` | Key-value pairs forwarded to every `MutationEvent`. | --- ## The `builder` Callback ```dart Widget Function( BuildContext context, MutationState state, Future Function(TVariables variables) mutate, ) ``` - **`state`**: the current sealed `MutationState`. Pattern-match or use `.isPending`, `.isSuccess`, `.isError` helpers. - **`mutate(variables)`**: triggers the mutation. Returns `TData` on success or `null` on failure. Errors never propagate to the caller; they appear as `MutationFailure` state instead. --- ## Lifecycle Hooks Use `options` to perform side-effects around the mutation: ```dart [settings_screen.dart] QoraMutationBuilder( mutator: (update) => api.updateUser(update), options: MutationOptions( invalidates: [['users', currentUserId]], // Auto-refresh on success onSuccess: (_, update, __) async { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Settings saved')), ); }, onError: (error, _, __) async { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error: $error')), ); }, ), builder: (context, state, mutate) { return SaveButton( isLoading: state.isPending, onTap: () => mutate(formData), ); }, ) ``` ::warning Capturing `context` inside `onSuccess` / `onError` is safe as long as the widget is still mounted. Prefer using `ScaffoldMessenger.of(context)` : it is resilient to widget tree changes. Avoid `Navigator.of(context)` in async callbacks without a mounted check. :: --- ## Offline Mutation Queue When the device is offline, mutations with `offlineQueue: true` are enqueued in FIFO order and replayed automatically when connectivity returns. Provide `optimisticResponse` to give instant feedback even without a server response: ```dart [lib/ui/todo_screen.dart] QoraMutationBuilder( mutator: (todo) => api.createTodo(todo), options: MutationOptions( invalidates: [['todos']], // Auto-refresh on success or offline replay // Opt into the offline queue offlineQueue: true, // Provide an optimistic TData so the UI updates immediately optimisticResponse: (todo) => null, ), builder: (context, state, mutate) { return AddTodoButton( onTap: () => mutate(newTodo), ); }, ) ``` ### `isOptimistic`: styling unconfirmed items When a mutation is queued offline and `optimisticResponse` is provided, the state transitions to `MutationSuccess(isOptimistic: true)`. Use the `isOptimistic` flag to apply a pending visual (greyed out, clock icon, strikethrough) so users know the item is not yet confirmed by the server: ```dart [lib/ui/create_post_screen.dart] QoraMutationBuilder( mutator: api.createPost, options: MutationOptions( offlineQueue: true, optimisticResponse: (title) => Post.optimistic(title), ), builder: (context, state, mutate) { // Extract isOptimistic from the success state final isOptimistic = switch (state) { MutationSuccess(:final isOptimistic) => isOptimistic, _ => false, }; return Column( children: [ if (isOptimistic) const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.schedule, size: 14, color: Colors.grey), SizedBox(width: 4), Text('Queued: syncing when online', style: TextStyle(color: Colors.grey)), ], ), ElevatedButton( onPressed: state.isPending ? null : () => mutate('New Post'), child: const Text('Create'), ), ], ); }, ) ``` `isOptimistic` is also available on `MutationState` directly: ```dart // On the sealed class state.isOptimistic // false for all states except MutationSuccess with isOptimistic: true // Inside when() state.when( onSuccess: (data, variables, isOptimistic) { if (isOptimistic) showPendingIndicator(); else showConfirmedBadge(); }, ... ) ``` ::tip `isOptimistic: true` is emitted only when `offlineQueue: true` **and** `optimisticResponse` is provided **and** the device is offline. A real server success always produces `isOptimistic: false` . :: --- ## Optimistic Updates Combine `onMutate` (optimistic write) and `onError` (rollback) to give instant feedback while the server confirms the change: ```dart [todo_list_screen.dart] QoraMutationBuilder?>( // TContext = List?: the snapshot for rollback mutator: (todo) => api.addTodo(todo), options: MutationOptions( invalidates: [['todos']], // 4️⃣ Pull confirmed data from server onMutate: (todo) async { // 1️⃣ Snapshot current list final prev = context.qora.getQueryData>(['todos']); // 2️⃣ Apply optimistically: UI updates before the network call context.qora.setQueryData>( ['todos'], [...?prev, todo.copyWith(isOptimistic: true)], ); return prev; // Returned as TContext }, onError: (_, __, prev) async { // 3️⃣ Rollback on failure context.qora.restoreQueryData(['todos'], prev); }, ), builder: (context, state, mutate) { return AddTodoButton( isLoading: state.isPending, onTap: () => mutate(newTodo), ); }, ) ``` `context.qora` is the shorthand for `QoraScope.of(context)`: available when the widget is inside a `QoraScope`. --- ## Reset After displaying a success or failure result, call `reset()` to return to `MutationIdle` and allow the user to trigger another mutation. Because `QoraMutationBuilder` owns the controller internally, you can drive reset through external widget state: ```dart [create_post_screen.dart] QoraMutationBuilder( mutator: (title) => api.createPost(title), builder: (context, state, mutate) { if (state is MutationSuccess) { return Column(children: [ Text('Created: ${state.data.title}'), TextButton( // Rebuilds with a fresh idle state on next frame onPressed: () => setState(() {}), child: const Text('Create another'), ), ]); } return SubmitButton( onTap: state.isPending ? null : () => mutate('My Post'), ); }, ) ``` ::tip If you need programmatic `reset()` or `mutate()` access outside the builder, consider using `MutationController` directly and subscribing to its `stream` in a `StatefulWidget` . :: --- ## DevTools Labelling with `metadata` Attach domain context to every `MutationEvent` for easier DevTools filtering: ```dart [auth_screen.dart] QoraMutationBuilder( mutator: authApi.login, metadata: {'category': 'auth', 'screen': 'login'}, builder: (context, state, mutate) { ... }, ) ``` All events emitted by this controller will include `metadata: {'category': 'auth', 'screen': 'login'}`, visible in `client.mutationEvents` and `client.activeMutations`. --- ## Standalone Mode `QoraMutationBuilder` is safe to use anywhere in the widget tree: even without a `QoraScope` ancestor. In that case, `tracker` is `null` and the controller operates in standalone mode with no global DevTools wiring: ```dart [onboarding_screen.dart] // No QoraScope required QoraMutationBuilder( mutator: onboardingApi.submit, builder: (context, state, mutate) { ... }, ) ``` --- ## Controller Lifecycle The internal `MutationController` is: - **Created** on widget mount (`initState`) - **Recreated** when `mutator`, `options`, or `metadata` identity changes (`didUpdateWidget`) - **Disposed** on widget unmount (`dispose`) When the controller is recreated, the mutation state resets to `MutationIdle`. --- ## Next Steps - [Network Aware Guide](https://qora.meragix.dev/../recipes/network-aware): Offline queue, `isOptimistic`, and reconnect replay in full detail - [Mutations Guide](https://qora.meragix.dev/../recipes/mutations): Full conceptual guide with all patterns - [Optimistic Updates](https://qora.meragix.dev/../recipes/optimistic-updates): Deep dive into rollback - [Mutation API Reference](https://qora.meragix.dev/../api-reference/qora-mutation): Full API documentation # InfiniteQueryBuilder `InfiniteQueryBuilder` is the Flutter binding for infinite (paginated) queries. It manages the full pagination lifecycle in a single widget: fetching the first page on mount, subscribing to all state transitions, and exposing an `InfiniteQueryController` to trigger additional page loads from scroll listeners, buttons, or `RefreshIndicator`. --- ## Basic Usage: Forward Scroll ```dart [posts_screen.dart] InfiniteQueryBuilder, int>( queryKey: ['posts'], fetcher: (page) => api.getPosts(page: page), options: InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null, ), builder: (context, state, controller) => switch (state) { InfiniteInitial() || InfiniteLoading() => const Center(child: CircularProgressIndicator()), InfiniteFailure(:final error) => ErrorWidget('$error'), InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage) => ListView.builder( itemCount: data.flatten((p) => p).length + (hasNextPage ? 1 : 0), itemBuilder: (ctx, i) { final items = data.flatten((p) => p); if (i == items.length) { if (!isFetchingNextPage) controller.fetchNextPage(); return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } return PostCard(items[i]); }, ), }, ) ``` The builder receives three arguments: 1. **`context`**: the `BuildContext` 2. **`state`**: the current `InfiniteQueryState` (what you have) 3. **`controller`**: an `InfiniteQueryController` exposing `fetchNextPage`, `fetchPreviousPage`, and `refetch` --- ## Props | Prop | Type | Default | Description | | ---------- | ------------------------------------------------------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------- | | `queryKey` | `Object` (`List` or `QoraKey`) | required | Cache key. Changing it disposes the current observer and starts fresh from page 1. | | `fetcher` | `Future Function(TPageParam pageParam)` | required | Async function that fetches a single page for the given param. Must throw on failure. | | `options` | `InfiniteQueryOptions` | required | Pagination config: `initialPageParam`, `getNextPageParam`, `getPreviousPageParam`, `maxPages`, `baseOptions`. | | `builder` | `Widget Function(BuildContext, InfiniteQueryState, InfiniteQueryController)` | required | Builds the widget tree from the current state and pagination controller. | | `client` | `QoraClient?` | `null` | Override the client from `QoraScope`. Useful for testing or nested scopes. | | `enabled` | `bool` | `true` | When `false`, the first-page fetch is not triggered on mount. The widget still observes the cached state. | --- ## How It Works On mount, `InfiniteQueryBuilder`: 1. Creates an `InfiniteQueryObserver` backed by the `QoraClient` from the nearest `QoraScope` (or the overridden `client`). 2. Subscribes to the observer's stream: emitting every `InfiniteQueryState` transition. 3. Calls `observer.fetch()` to load the first page (unless `enabled` is `false`). When the query is **externally invalidated** (via `client.invalidateInfiniteQuery(key)`), the state resets to `InfiniteInitial`. The builder detects this and automatically re-fetches the first page, so the UI never stays blank waiting for a manual action. When `queryKey` changes, the current observer is disposed and a new one is created, starting fresh from `initialPageParam`. --- ## Patterns ### Pull-to-Refresh Wrap the scroll view in a `RefreshIndicator` and pass `controller.refetch` directly to `onRefresh`: ```dart [posts_screen.dart] InfiniteQueryBuilder, int>( queryKey: ['posts'], fetcher: (page) => api.getPosts(page: page), options: InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null, ), builder: (context, state, controller) { final items = state is InfiniteSuccess, int> ? state.data.flatten((p) => p) : []; return RefreshIndicator( onRefresh: controller.refetch, child: ListView( // Always scrollable so RefreshIndicator can always be triggered. physics: const AlwaysScrollableScrollPhysics(), children: items.map(PostCard.new).toList(), ), ); }, ) ``` `refetch()` re-fetches all currently loaded pages in sequence, preserving the page count. Existing data stays visible while pages reload. --- ### Bi-directional Scroll Chat feeds and timelines often load in both directions. Provide `getPreviousPageParam` and call `controller.fetchPreviousPage()` when the user scrolls to the top: ```dart [chat_screen.dart] InfiniteQueryBuilder, String?>( queryKey: ['chat', roomId], fetcher: (cursor) => api.getMessages(roomId, cursor: cursor), options: InfiniteQueryOptions( initialPageParam: null, // Start from the latest message getNextPageParam: (last, all) => last.olderCursor, getPreviousPageParam: (first, all) => first.newerCursor, ), builder: (context, state, controller) => switch (state) { InfiniteInitial() || InfiniteLoading() => const Center(child: CircularProgressIndicator()), InfiniteFailure(:final error) => ErrorWidget('$error'), InfiniteSuccess( :final data, :final hasPreviousPage, :final hasNextPage, :final isFetchingPreviousPage, :final isFetchingNextPage, ) => NotificationListener( onNotification: (n) { if (n is ScrollStartNotification) { if (n.metrics.pixels <= 0 && hasPreviousPage && !isFetchingPreviousPage) { controller.fetchPreviousPage(); // Newer messages } else if (n.metrics.atEdge && n.metrics.pixels > 0 && hasNextPage && !isFetchingNextPage) { controller.fetchNextPage(); // Older messages } } return false; }, child: ListView.builder( reverse: true, itemCount: data.flatten((p) => p).length, itemBuilder: (ctx, i) => MessageBubble(data.flatten((p) => p)[i]), ), ), }, ) ``` --- ### Windowed Paging Display When `maxPages` is set, `InfiniteSuccess.hasPreviousPage` becomes `true` once the window has slid past the first page. Show a "scroll up to load earlier" indicator: ```dart [feed_screen.dart] InfiniteQueryBuilder, int>( queryKey: ['feed'], fetcher: (page) => api.getFeed(page: page), options: InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null, maxPages: 10, ), builder: (context, state, controller) { if (state is! InfiniteSuccess, int>) { return const Center(child: CircularProgressIndicator()); } final items = state.data.flatten((p) => p); return CustomScrollView( slivers: [ if (state.hasPreviousPage) SliverToBoxAdapter( child: TextButton( onPressed: state.isFetchingPreviousPage ? null : controller.fetchPreviousPage, child: const Text('Load earlier posts'), ), ), SliverList.builder( itemCount: items.length, itemBuilder: (ctx, i) => PostCard(items[i]), ), if (state.hasNextPage) SliverToBoxAdapter( child: state.isFetchingNextPage ? const Center(child: CircularProgressIndicator()) : TextButton( onPressed: controller.fetchNextPage, child: const Text('Load more'), ), ), ], ); }, ) ``` --- ### Conditional Queries (`enabled`) Delay fetching until a dependency is ready: for example, until a user session is available: ```dart [user_feed_screen.dart] InfiniteQueryBuilder, int>( queryKey: ['feed', userId], fetcher: (page) => api.getFeed(userId: userId!, page: page), options: InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null, ), enabled: userId != null, builder: (context, state, controller) { ... }, ) ``` When `enabled` flips from `false` to `true` (via `setState`), the first-page fetch is triggered automatically. --- ### Scroll-triggered Loading Use a `ScrollController` listener to trigger the next page as the user approaches the bottom: ```dart [posts_screen.dart] class _PostsScreenState extends State { final _scrollController = ScrollController(); @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _scrollController.dispose(); super.dispose(); } // Populated via a StateSetter or passed down through the widget tree InfiniteQueryController, int>? _queryController; void _onScroll() { final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 200) { _queryController?.fetchNextPage(); } } @override Widget build(BuildContext context) => InfiniteQueryBuilder, int>( queryKey: ['posts'], fetcher: (page) => api.getPosts(page: page), options: InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null, ), builder: (context, state, controller) { _queryController = controller; final items = state is InfiniteSuccess, int> ? state.data.flatten((p) => p) : []; return ListView.builder( controller: _scrollController, itemCount: items.length, itemBuilder: (ctx, i) => PostCard(items[i]), ); }, ); } ``` --- ## `InfiniteQueryController` A stable handle to the underlying `InfiniteQueryObserver`. The controller reference never changes as long as `queryKey` and `client` stay the same, so it is safe to capture in scroll listeners or button callbacks. ### Methods | Method | Signature | Description | | --------------------- | -------------- | ---------------------------------------------------------------------------------------------------- | | `fetchNextPage()` | `Future` | Fetch and append the next page. No-op if `hasNextPage` is `false` or a fetch is already in progress. | | `fetchPreviousPage()` | `Future` | Fetch and prepend the previous page. Requires `getPreviousPageParam` to be configured. | | `refetch()` | `Future` | Re-fetch all currently loaded pages in sequence. Useful for pull-to-refresh. | --- ## Key Changing Behaviour When `queryKey` changes (`didUpdateWidget`), `InfiniteQueryBuilder`: 1. Cancels the stream subscription. 2. Disposes the current `InfiniteQueryObserver`. 3. Creates a new observer for the new key. 4. Subscribes and fetches from `initialPageParam`. This is how feed-switching (e.g. Home / Following tabs) is handled automatically without any extra state management. --- ## Next Steps - [Infinite Queries Guide](https://qora.meragix.dev/../recipes/infinite-queries): Core concepts, state machine, and pagination strategies - [Infinite Query API Reference](https://qora.meragix.dev/../api-reference/infinite-query): Full reference for all infinite query types and client methods - [QoraBuilder](https://qora.meragix.dev/qora-builder): Single-page (non-paginated) query widget # Hooks (flutter_hooks) If your project uses [flutter\_hooks](https://pub.dev/packages/flutter_hooks){rel=""nofollow""}, `qora_hooks` provides a hooks-based API with composable primitives for complex screens. ## Installation ::code-group ```yaml [pubspec.yaml] # You don't need to add `qora`: it's included transitively. dependencies: flutter_hooks: ^0.21.0 qora_hooks: ^ 1.3.1 ``` :: ## useQuery Fetches data and rebuilds the widget when the state changes. Equivalent to `QoraBuilder` without a nested `builder` callback. ```dart class UserScreen extends HookWidget { final String userId; const UserScreen({super.key, required this.userId}); @override Widget build(BuildContext context) { final state = useQuery( key: ['users', userId], fetcher: () => Api.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); return switch (state) { Initial() => const SizedBox.shrink(), Loading(:final previousData) => previousData != null ? UserCard(previousData) : const CircularProgressIndicator(), Success(:final data) => UserCard(data), Failure(:final error) => ErrorView(error), }; } } ``` ### useQuery parameters | Parameter | Type | Description | | --------- | ---------------------- | ------------------------------------------ | | `key` | `List` | Query identifier. Changes trigger refetch. | | `fetcher` | `Future Function()` | Async function returning the data. | | `options` | `QoraOptions?` | Per-query caching/retry configuration. | ### useQuery return value Returns a `QoraState`: a sealed class with four variants: `Initial`, `Loading`, `Success`, `Failure`. --- ## useMutation Triggers a mutation and tracks its state. ```dart class EditProfileScreen extends HookWidget { @override Widget build(BuildContext context) { final mutation = useMutation( mutator: (input) => Api.updateUser(input), options: MutationOptions( invalidates: [['users', currentUserId]], ), ); return ElevatedButton( onPressed: mutation.isPending ? null : () => mutation.mutate(UpdateUserInput(name: 'Alice')), child: mutation.isPending ? const CircularProgressIndicator() : const Text('Save'), ); } } ``` ### useMutation parameters | Parameter | Type | Description | | --------- | ------------------------------------ | --------------------------------------- | | `mutator` | `Future Function(TVariables)` | Async function performing the mutation. | | `options` | `MutationOptions?` | Lifecycle callbacks and retry settings. | ### useMutation return value Returns a `MutationHandle`: | Property | Type | Description | | ------------- | ------------------------------------- | ---------------------------------------- | | `mutate` | `void Function(TVariables)` | Fire-and-forget; errors go to state. | | `mutateAsync` | `Future Function(TVariables)` | Returns the result or `null` on failure. | | `reset` | `void Function()` | Resets state to `MutationIdle`. | | `isIdle` | `bool` | `true` when no mutation has run yet. | | `isPending` | `bool` | `true` while the mutation is running. | | `isSuccess` | `bool` | `true` after successful completion. | | `isError` | `bool` | `true` after a failure. | | `data` | `TData?` | Available when `isSuccess`. | | `error` | `Object?` | Available when `isError`. | --- ## useQueryClient Returns the nearest `QoraClient` from the widget tree. ```dart final client = useQueryClient(); client.invalidate(['posts']); ``` The widget must be wrapped in a `QoraScope` for this to work. --- ## useIsFetching Returns `true` while at least one query managed by the nearest `QoraClient` has a network request in flight. Useful for a global loading indicator at the top of the screen without coupling it to any individual query. ```dart class AppLoadingBar extends HookWidget { @override Widget build(BuildContext context) { final isFetching = useIsFetching(); return AnimatedOpacity( opacity: isFetching ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: const LinearProgressIndicator(), ); } } ``` ### useIsFetching return value `bool`: `true` when `isFetchingCount > 0`, `false` otherwise. Rebuilds the widget only when the value crosses the zero boundary. --- ## useIsMutating Returns `true` while at least one mutation managed by the nearest `QoraClient` is in `MutationPending` state. Handy for disabling a global submit button while any write operation is in progress. ```dart class SubmitButton extends HookWidget { final VoidCallback onPressed; const SubmitButton({super.key, required this.onPressed}); @override Widget build(BuildContext context) { final isMutating = useIsMutating(); return FilledButton( onPressed: isMutating ? null : onPressed, child: isMutating ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ); } } ``` ### useIsMutating return value `bool`: `true` when `activeMutations.length > 0`, `false` otherwise. --- ## useInfiniteQuery Manages paginated data: accumulates pages and exposes a `fetchNextPage` function. ```dart class PostsScreen extends HookWidget { @override Widget build(BuildContext context) { final query = useInfiniteQuery( key: const ['posts'], fetcher: (cursor) => Api.getPosts(cursor: cursor), getNextPageParam: (page) => page.nextCursor, initialPageParam: null, ); final posts = query.pages.expand((p) => p.posts).toList(); return ListView.builder( itemCount: posts.length + (query.hasNextPage ? 1 : 0), itemBuilder: (context, i) { if (i == posts.length) { if (!query.isFetchingNextPage) query.fetchNextPage(); return const Center(child: CircularProgressIndicator()); } return PostTile(posts[i]); }, ); } } ``` ### useInfiniteQuery parameters | Parameter | Type | Description | | ------------------ | ------------------------------------ | ------------------------------------------ | | `key` | `List` | Query identifier. Changes trigger refetch. | | `fetcher` | `Future Function(TPageParam)` | Called with page param for each page. | | `getNextPageParam` | `TPageParam? Function(TData)` | Returns `null` to signal last page. | | `initialPageParam` | `TPageParam` | First page param passed to `fetcher`. | ### useInfiniteQuery return value Returns an `InfiniteQueryHandle`: | Property | Type | Description | | -------------------- | ------------------------- | ----------------------------------------- | | `pages` | `List` | All fetched pages in order. | | `isLoading` | `bool` | `true` while the first page is loading. | | `isFetchingNextPage` | `bool` | `true` while a next page is loading. | | `hasNextPage` | `bool` | `false` when no more pages are available. | | `fetchNextPage` | `Future Function()` | Loads next page. No-op if busy. | | `error` | `Object?` | Last error, or `null`. | --- ## Combining hooks Hooks compose naturally. The following example fetches a user, updates their profile, and displays an inline error in a single widget without `setState` or `StreamBuilder`. ```dart class ProfileScreen extends HookWidget { final String userId; const ProfileScreen({super.key, required this.userId}); @override Widget build(BuildContext context) { final client = useQueryClient(); final userQuery = useQuery( key: ['users', userId], fetcher: () => Api.getUser(userId), ); final updateMutation = useMutation( mutator: Api.updateUser, options: MutationOptions( invalidates: [['users', userId]], ), ); return switch (userQuery) { Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorView(error), Success(:final data) => ProfileForm( user: data, isSaving: updateMutation.isPending, saveError: updateMutation.error, onSave: (input) => updateMutation.mutate(input), ), _ => const SizedBox.shrink(), }; } } ``` --- ::callout{type="info"} `qora_hooks` requires your widget to extend `HookWidget` (or use `HookConsumerWidget` if you are combining with Riverpod). Standard `StatelessWidget` and `StatefulWidget` are not supported: use `QoraBuilder` and `QoraMutationBuilder` instead. :: # Network Status Widgets `NetworkStatusBuilder` and `NetworkStatusIndicator` are two Flutter widgets that subscribe to the `ConnectivityManager` exposed by the nearest `QoraScope` and rebuild whenever the `NetworkStatus` changes. --- ## Requirements A `QoraScope` with a `ConnectivityManager` must be an ancestor. Without one, both widgets emit `NetworkStatus.unknown` and never update. ```dart [lib/main.dart] QoraScope( client: client, connectivityManager: FlutterConnectivityManager(), // required child: const MyApp(), ) ``` --- ## `NetworkStatusIndicator` The high-level widget. Wraps your app (or a sub-tree) and overlays an offline banner when `NetworkStatus.offline` is detected. ### Default banner ```dart [lib/main.dart] QoraScope( client: client, connectivityManager: FlutterConnectivityManager(), child: NetworkStatusIndicator( child: const MyApp(), ), ) ``` Renders a thin semi-transparent dark bar at the bottom of the screen with a wifi-off icon and "Offline mode" text. No Material dependency: uses only `flutter/widgets.dart`. ### Custom offline banner Override the offline UI with `offlineBanner`: ```dart [lib/main.dart] NetworkStatusIndicator( offlineBanner: (context) => Container( color: Colors.orange.shade800, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.wifi_off, color: Colors.white, size: 16), SizedBox(width: 8), Text('No connection', style: TextStyle(color: Colors.white)), ], ), ), child: const MyApp(), ) ``` `offlineBanner` is ignored when a custom `builder` is provided. ### Full builder control Use `builder` when you need to react to every `NetworkStatus` value: including `online` and `unknown`: ```dart [lib/main.dart] NetworkStatusIndicator( builder: (context, status, child) { return Stack( children: [ child!, if (status == NetworkStatus.offline) const Positioned( bottom: 0, left: 0, right: 0, child: MyOfflineBanner(), ), if (status == NetworkStatus.unknown) const Positioned( bottom: 0, left: 0, right: 0, child: MyCheckingBanner(), ), ], ); }, child: const MyApp(), ) ``` ### Props | Prop | Type | Default | Description | | --------------- | -------------------------------------------------------- | -------- | ----------------------------------------------------------- | | `child` | `Widget` | required | The main content of the app. | | `builder` | `Widget Function(BuildContext, NetworkStatus, Widget?)?` | `null` | Full control builder. When set, `offlineBanner` is ignored. | | `offlineBanner` | `Widget Function(BuildContext)?` | `null` | Custom widget shown at the bottom when offline. | --- ## `NetworkStatusBuilder` The low-level widget. Subscribes to the `ConnectivityManager` stream and calls `builder` on every `NetworkStatus` transition. Use this when you need fine-grained control over how each status is rendered. ### Basic usage ```dart [lib/ui/widgets/offline_banner.dart] NetworkStatusBuilder( builder: (context, status, child) { return status == NetworkStatus.offline ? const MyOfflineBanner() : const SizedBox.shrink(); }, ) ``` ### With child optimisation Pass an expensive sub-tree as `child` to avoid rebuilding it on every status change: ```dart [lib/ui/screens/home_screen.dart] NetworkStatusBuilder( builder: (context, status, child) { return Column( children: [ if (status == NetworkStatus.offline) const OfflineBanner(), Expanded(child: child!), ], ); }, child: const ExpensiveContentTree(), ) ``` The framework reuses `child` across `builder` calls: only the wrapper rebuilds. ### Reading the current status imperatively ```dart [lib/ui/offline_badge.dart] final manager = QoraScope.connectivityManagerOf(context); final status = manager?.currentStatus ?? NetworkStatus.unknown; ``` ### Props | Prop | Type | Default | Description | | --------- | ------------------------------------------------------- | -------- | ----------------------------------------------------- | | `builder` | `Widget Function(BuildContext, NetworkStatus, Widget?)` | required | Builds the widget tree for the given `NetworkStatus`. | | `child` | `Widget?` | `null` | Optional child passed unchanged to `builder`. | --- ## `NetworkStatus` values | Value | Meaning | | ----------------------- | ------------------------------------------------------------------------------ | | `NetworkStatus.online` | Device has at least one active network interface. | | `NetworkStatus.offline` | All network interfaces are down. | | `NetworkStatus.unknown` | No `ConnectivityManager` is registered, or status has not yet been determined. | ::tip `NetworkStatus` reflects **interface availability** , not actual internet reachability. A device connected to a router with no WAN access will report `online` . For reachability validation, add a custom health-check in your `ConnectivityManager` implementation. :: --- ## Recommended placement Place `NetworkStatusIndicator` just inside `QoraScope`, wrapping the entire app. This ensures the banner is always visible regardless of which route is active: ```dart [lib/main.dart] runApp( QoraScope( client: client, connectivityManager: FlutterConnectivityManager(), child: NetworkStatusIndicator( child: MaterialApp( home: const HomeScreen(), ), ), ), ); ``` --- ## Next Steps - [Network Aware Guide](https://qora.meragix.dev/../recipes/network-aware): Full offline-first setup walkthrough - [QoraScope](https://qora.meragix.dev/qora-scope): Wiring `FlutterConnectivityManager` into the widget tree - [QoraBuilder](https://qora.meragix.dev/qora-builder): Using `FetchStatus.paused` for per-widget offline indicators # Best Practices ## Query Keys ### Use arrays, not strings Query keys are arrays. The first element is a **domain namespace**; subsequent elements are parameters. ```dart [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: ```dart [lib/data/query_keys.dart] abstract final class QueryKeys { static List users() => ['users']; static List user(int id) => ['users', id]; static List userPosts(int userId) => ['users', userId, 'posts']; static List posts() => ['posts']; static List post(int id) => ['posts', id]; } ``` ```dart [main.dart] QoraBuilder( 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` | `queryKey` | `fetcher` | `(BuildContext, QoraState, FetchStatus)` | | `QoraStateBuilder` | `queryKey` | : | `(BuildContext, QoraState)` | | `QoraMutationBuilder` | : | `mutator` | `(BuildContext, MutationState, mutate)` | | `InfiniteQueryBuilder` | `queryKey` | `fetcher` | `(BuildContext, InfiniteQueryState, InfiniteQueryController)` | | `QoraPrefetch` | `queryKey` | `fetcher` | : (wraps `child`) | --- ## Fetcher Placement ### Keep fetchers in repository classes, not widgets ```dart [lib/data/user_repository.dart] class UserRepository { final QoraClient _client; final UserApi _api; UserRepository(this._client, this._api); Stream> watchUser(int id) => _client.watchQuery( key: QueryKeys.user(id), fetcher: () => _api.getUser(id), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); Future updateUser(int id, String name) async { final snapshot = _client.getQueryData(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: ```dart [user_screen.dart] QoraBuilder( 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: ```dart [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: ```dart [user_screen.dart] QoraBuilder( 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: ```dart [avatar_widget.dart] QoraStateBuilder( 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: ```dart [user_list_item.dart] MouseRegion( onEnter: (_) => setState(() => _hover = true), child: _hover ? QoraPrefetch( queryKey: QueryKeys.user(user.id), fetcher: () => api.getUser(user.id), child: UserListTile(user: user), ) : UserListTile(user: user), ) ``` --- ## Mutations ### Correct widget: `QoraMutationBuilder` ```dart [update_name_form.dart] QoraMutationBuilder( mutator: (name) => api.updateUser(userId, name), options: MutationOptions( onMutate: (name) async => QoraScope.of(context).getQueryData(['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: ```dart [main.dart] MutationOptions(retryCount: 0) ``` --- ## Performance ### Scope `QoraScope` near the root ```dart [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`: ```dart [test/user_test.dart] late QoraClient client; setUp(() => client = QoraClient()); tearDown(() => client.dispose()); ``` --- ## Persistence ### Register serializers before `hydrate()` ```dart [main.dart] final client = PersistQoraClient(storage: storage); client.registerSerializer( 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: ```dart [main.dart] client.registerSerializer>(serializer, name: 'List'); ``` --- ## Related - [QoraBuilder](https://qora.meragix.dev/qora-builder) - [QoraScope](https://qora.meragix.dev/qora-scope) - [Persistence Guide](https://qora.meragix.dev/../recipes/persistence) - [Error Handling Guide](https://qora.meragix.dev/../recipes/error-handling) # Mutations A **mutation** is any operation that changes server-side state, including create, update, and delete operations. Queries are read-only and cache-driven. Mutations are explicit write operations. Qora manages mutations with `MutationController`, a standalone lifecycle controller with no cache key, no deduplication, and no SWR behavior. --- ## The State Machine ```text MutationIdle │ │ mutate(variables) ▼ MutationPending │ ├── success ──▶ MutationSuccess │ └── failure ──▶ MutationFailure │ └── reset() ──▶ MutationIdle ``` Every state carries `variables`: the value that was passed to `mutate()`. This makes it easy to display "Creating post…" or "Failed to delete comment #42" without extra local state. --- ## Basic Usage ```dart [post_repository.dart] final controller = MutationController( mutator: (title) => api.createPost(title), ); // Trigger the mutation final post = await controller.mutate('Hello, Qora!'); // Returns the Post on success, or null on failure. // Observe state transitions controller.stream.listen((state) { switch (state) { case MutationIdle(): print('Ready'); case MutationPending(:final variables): print('Creating "$variables"…'); case MutationSuccess(:final data): print('Created: ${data.title}'); case MutationFailure(:final error): print('Failed: $error'); } }); // Reset to idle after showing the result controller.reset(); // Always dispose when done controller.dispose(); ``` `mutate()` never throws. Errors are captured in `MutationFailure` state and returned as `null` from the call site. --- ## Lifecycle Callbacks `MutationOptions` provides four hooks that run around the mutation: ```dart [post_repository.dart] final controller = MutationController( mutator: (title) => api.createPost(title), options: MutationOptions( invalidates: [['posts']], // Auto-refresh the posts list on success onMutate: (title) async { print('About to create "$title"'); // Return value becomes TContext: passed to onSuccess, onError, onSettled. }, onSuccess: (post, title, _) async { print('Created: ${post.id}'); }, onError: (error, title, _) async { print('Failed to create "$title": $error'); }, onSettled: (post, error, title, _) async { // Called regardless of outcome. analytics.track('post_create_attempt', {'success': error == null}); }, ), ); ``` | Hook | When | Receives | | -------------------------------------------- | -------------------------- | ---------------------------------------------- | | `onMutate(variables)` | Before the mutator starts | `TVariables` | | `onSuccess(data, variables, context)` | After a successful mutator | `TData`, `TVariables`, `TContext?` | | `onError(error, variables, context)` | After the mutator throws | `Object`, `TVariables`, `TContext?` | | `onSettled(data, error, variables, context)` | After either outcome | `TData?`, `Object?`, `TVariables`, `TContext?` | The value returned by `onMutate` becomes `TContext`: the third type parameter of `MutationController`. This is the mechanism behind optimistic update rollback. --- ## Automatic Invalidation Instead of calling `client.invalidate()` manually inside `onSuccess`, declare the query keys upfront with `invalidates`: ```dart options: MutationOptions( invalidates: [['posts'], ['users', userId]], ) ``` Each key is invalidated automatically after the mutation succeeds, whether it runs immediately or is replayed from the offline queue. Invalidations happen **after** `onSuccess` completes, so the cache is freshened after any manual side-effects. ```dart // Before: manual onSuccess: (_, __, ___) async { client.invalidate(['posts']); } // After: automatic invalidates: [['posts']], ``` ### Tag-based invalidation You can also invalidate queries by tag, completely decoupling from query-key structure: ```dart options: MutationOptions( invalidatesTags: [QueryTag('post'), QueryTag('user', userId)], ) ``` Every query whose `QoraOptions.providesTags` includes a matching tag is automatically refetched. Wildcard matching: `QueryTag('post')` refetches all queries providing any `'post'` tag regardless of id. Specific matching: `QueryTag('post', '123')` refetches only queries providing exactly `'post':'123'`. ```dart // Before: key-based invalidates: [['posts']], // After: tag-based invalidatesTags: [QueryTag('post')], ``` Works with `QoraMutationBuilder`, `useMutation`, and raw `MutationController` as long as a `QoraClient` or `invalidateQuery` callback is wired. Both `invalidates` and `invalidatesTags` can be used together; tag invalidation runs after key-based invalidation. --- ## Optimistic Updates Optimistic updates apply a change to the local cache immediately: before the server confirms it. If the server rejects the change, the previous data is restored. The pattern has three steps: 1. **`onMutate`**: snapshot the current cache data, apply the optimistic change, return the snapshot as `TContext` 2. **`onError`**: use the snapshot (`TContext`) to roll back via `client.restoreQueryData()` 3. **`onSuccess`**: invalidate the query to pull the confirmed server data ```dart [post_repository.dart] final controller = MutationController?>( // TContext = List?: the snapshot we'll use for rollback mutator: (title) => api.createPost(title), options: MutationOptions( invalidates: [['posts']], // 4️⃣ Confirm: replace optimistic data with real server data onMutate: (title) async { // 1️⃣ Snapshot final previous = client.getQueryData>(['posts']); // 2️⃣ Optimistic update: UI reflects change immediately client.setQueryData>( ['posts'], [...?previous, Post.optimistic(title)], ); return previous; // Becomes TContext }, onError: (error, title, previous) async { // 3️⃣ Rollback: restore the snapshot on failure client.restoreQueryData(['posts'], previous); }, ), ); await controller.mutate('New Post'); ``` ::tip `client.restoreQueryData(key, null)` removes the query from the cache entirely: useful when the snapshot was `null` (the list didn't exist yet). :: --- ## Handling `onMutate` Failure If `onMutate` throws (e.g. a pre-flight validation fails), the mutator is **not called**. The controller transitions directly to `MutationFailure`. ```dart [post_repository.dart] options: MutationOptions( onMutate: (title) async { if (title.isEmpty) throw ArgumentError('Title cannot be empty'); // Mutator will never run if this throws return null; }, ), ``` This is useful for client-side validation that should block the network call entirely. --- ## Retry By default mutations do not retry (unlike queries, retrying a write can have side effects). Enable retry explicitly: ```dart [post_repository.dart] options: const MutationOptions( retryCount: 2, // 1 initial + 2 retries = 3 total attempts retryDelay: Duration(seconds: 1), // Exponential backoff: 1s, 2s ), ``` Retry only applies to the mutator. If `onMutate` throws, no retry occurs. --- ## Standalone Mode `MutationController` works without any `QoraClient`. Pass `tracker: null` (the default) to operate in standalone mode with no global observability: ```dart [checkout_service.dart] // No client required: mutations work anywhere final controller = MutationController( mutator: (cart) => paymentApi.checkout(cart), ); ``` --- ## DevTools Observability Wire a controller into `QoraClient` by passing `tracker: client`. The client then exposes all mutation activity via `mutationEvents` and `activeMutations`. ```dart [post_repository.dart] final controller = MutationController( mutator: (title) => api.createPost(title), tracker: client, // Optional: label this controller for DevTools filtering metadata: {'category': 'content', 'resource': 'post'}, ); // Listen to all mutation activity across all tracked controllers client.mutationEvents.listen((event) { if (event.isError) { logger.error('[${event.mutatorId}] Failed', event.error); } }); // Snapshot of currently running mutations (only Pending entries) final running = client.activeMutations; print('${running.length} mutation(s) in flight'); ``` ::tip `activeMutations` only contains `MutationPending` entries: finished mutations (Success or Failure) are auto-purged. This prevents memory accumulation and makes the map a reliable "currently in-flight" indicator. :: When using `QoraMutationBuilder` in Flutter, `tracker` is set to `QoraScope.maybeOf(context)` automatically: no manual wiring needed. --- ## Multi-Query Optimistic Updates A single mutation can touch multiple queries. Each `setQueryData` call triggers a reactive update in any `QoraBuilder` subscribed to that key: ```dart [post_repository.dart] options: MutationOptions( onMutate: (postId) async { // Snapshot both queries final posts = client.getQueryData>(['posts']); final user = client.getQueryData(['users', currentUserId]); // Update both optimistically client.setQueryData>( ['posts'], posts?.where((p) => p.id != postId).toList(), ); client.setQueryData( ['users', currentUserId], user?.copyWith(postCount: (user.postCount ?? 1) - 1), ); return (posts: posts, user: user); // TContext is a record }, onError: (error, postId, snapshots) async { // Roll back both client.restoreQueryData(['posts'], snapshots?.posts); client.restoreQueryData(['users', currentUserId], snapshots?.user); }, ), ``` --- ## Next Steps - [MutationBuilder](https://qora.meragix.dev/../flutter-integration/qora-mutation-builder): Bind mutations to Flutter UI - [Optimistic Updates](https://qora.meragix.dev/optimistic-updates): Deep dive into rollback patterns - [Mutation API Reference](https://qora.meragix.dev/../api-reference/qora-mutation): Full API documentation # Optimistic Updates An **optimistic update** applies a local state change before server confirmation. If the request fails, the previous data is restored. Qora provides three primitives that make this pattern straightforward: | Method | Description | | ------------------------------------------- | -------------------------------------------------------------------------- | | `client.getQueryData(key)` | Snapshot the current data (`T?`), or `null` if not in `Success` state | | `client.setQueryData(key, data)` | Push data directly into the cache: all subscribed widgets update instantly | | `client.restoreQueryData(key, snapshot)` | Restore a previously snapshotted data value; `null` removes the entry | --- ## The Pattern ```dart [update_user.dart] Future updateUserName(BuildContext context, int userId, String newName) async { final client = context.qora; final key = ['users', userId]; // 1. Snapshot the current data for rollback final previousUser = client.getQueryData(key); try { // 2. Apply the optimistic update: UI reflects the change instantly if (previousUser != null) { client.setQueryData(key, previousUser.copyWith(name: newName)); } // 3. Fire the mutation final updatedUser = await UserApi.updateUser(userId, newName); // 4. Confirm with the authoritative server response client.setQueryData(key, updatedUser); // 5. Invalidate related queries (e.g. the list that shows the name) client.invalidateWhere((key) => key.firstOrNull == 'users'); } catch (error) { // 6. Rollback to the pre-mutation data client.restoreQueryData(key, previousUser); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to update: $error')), ); } } } ``` ### Step-by-step | Step | Call | Effect | | ---- | ------------------------------------- | -------------------------------------------------------------- | | 1 | `getQueryData(key)` | Returns the current `T?` data value, or `null` if not in cache | | 2 | `setQueryData(key, optimisticData)` | Cache updated → all active `QoraBuilder` widgets rebuild | | 3 | `UserApi.updateUser(...)` | Network request in flight | | 4 | `setQueryData(key, serverData)` | Cache confirmed with authoritative data from the server | | 5 | `invalidateWhere(...)` | Related queries (e.g. the user list) will refetch | | 6 | `restoreQueryData(key, previousUser)` | Cache reverted → UI snaps back to the pre-mutation state | --- ## Full Example: Edit Profile Screen ```dart [screens/edit_profile_screen.dart] class EditProfileScreen extends StatefulWidget { final int userId; const EditProfileScreen({super.key, required this.userId}); @override State createState() => _EditProfileScreenState(); } class _EditProfileScreenState extends State { late final TextEditingController _nameController; bool _isSaving = false; @override void initState() { super.initState(); final currentUser = context.qora.getQueryData(['users', widget.userId]); _nameController = TextEditingController(text: currentUser?.name ?? ''); } @override void dispose() { _nameController.dispose(); super.dispose(); } Future _save() async { if (_isSaving) return; setState(() => _isSaving = true); final client = context.qora; final key = ['users', widget.userId]; final previousUser = client.getQueryData(key); try { // Optimistic update if (previousUser != null) { client.setQueryData( key, previousUser.copyWith(name: _nameController.text), ); } final updated = await UserApi.updateUser( widget.userId, _nameController.text, ); client.setQueryData(key, updated); client.invalidateWhere((k) => k.firstOrNull == 'users'); if (mounted) Navigator.pop(context); } catch (error) { client.restoreQueryData(key, previousUser); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Save failed: $error')), ); setState(() => _isSaving = false); } } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Edit Profile'), actions: [ TextButton( onPressed: _isSaving ? null : _save, child: _isSaving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ], ), body: Padding( padding: const EdgeInsets.all(16), child: TextField( controller: _nameController, decoration: const InputDecoration(labelText: 'Name'), ), ), ); } } ``` When the user taps **Save**: 1. The `UserDetailScreen` (which uses `QoraBuilder`) immediately reflects the new name: no spinner, no delay. 2. The network request fires in the background. 3. If it succeeds, the server response confirms the data. 4. If it fails, the name reverts to what it was before the tap. --- ## Invalidating Related Queries After a successful mutation you typically want to invalidate related queries so stale list views refresh. Use `invalidateWhere` with a predicate: ```dart [update_user.dart] // Invalidate the specific user AND the users list client.invalidateWhere((key) => key.firstOrNull == 'users'); // More targeted: only the list, not the individual entry client.invalidateWhere((key) => key.length == 1 && key.firstOrNull == 'users'); // Or invalidate the exact key client.invalidate(['users']); ``` Active `QoraBuilder` widgets for any matching key receive `Loading(previousData: data)` and immediately re-fetch. Because the previous data is carried through, there is no flash of empty content. --- ## Why `restoreQueryData` Instead of `setQueryData`? Both methods write data into the cache. The difference is null handling: - `setQueryData(key, data)`: requires a non-null `T` value; always produces a `Success` state. - `restoreQueryData(key, snapshot)`: accepts `T?`; when `snapshot` is `null`, calls `removeQuery(key)` instead of writing. This makes rollback safe when the query had no prior data: no orphaned `Success` state is left behind. ```dart [rollback.dart] // Correct: handles the null case (query had no prior data) client.restoreQueryData(key, previousUser); // null → removeQuery // Note: Equivalent only when previousUser is guaranteed non-null if (previousUser != null) { client.setQueryData(key, previousUser); } ``` --- ## Best Practices ### Do ```dart [update.dart] // Always snapshot before mutating final previousUser = client.getQueryData(key); // Apply optimistic update only when data exists if (previousUser != null) { client.setQueryData(key, optimisticData); } // Always restore on any error path } catch (_) { client.restoreQueryData(key, previousUser); } ``` ### Don't ```dart [update.dart] // Don't skip the snapshot: you won't be able to roll back client.setQueryData(key, optimisticData); await api.update(data); // if this throws, no rollback possible // Don't ignore the error: always inform the user } catch (_) {} // Bad: Silent failure is worse than no optimistic update ``` --- ## Next Steps - [Quick Start](https://qora.meragix.dev/../getting-started/quick-start): List and detail screens without mutations - [QoraClient API](https://qora.meragix.dev/../api-reference/qora-client): `setQueryData`, `getQueryData`, `restoreQueryData` reference - [Query States](https://qora.meragix.dev/../core-concepts/query-states): Understanding the state machine that makes rollback predictable # Persistence **Persistence** stores successful query results in a storage backend (Hive, Isar, SharedPreferences, and others) and restores them on application startup. The in-memory cache is hydrated before the first network request, including offline sessions. This is built on three components in the `qora` package: | Class | Role | | ------------------- | --------------------------------------------------------------- | | `StorageAdapter` | Raw key/value contract for a storage backend | | `QoraSerializer` | Converts `T` ↔ JSON for a specific type | | `PersistQoraClient` | Drop-in `QoraClient` replacement that wires everything together | --- ## Setup ### 1. Replace `QoraClient` with `PersistQoraClient` ```dart [main.dart] import 'package:qora/qora.dart'; final client = PersistQoraClient( storage: InMemoryStorageAdapter(), // swap for a real adapter (see below) persistDuration: const Duration(days: 7), config: const QoraClientConfig( defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)), debugMode: true, // prints [PersistQora] logs ), ); ``` `PersistQoraClient` extends `QoraClient`. Existing APIs such as `fetchQuery`, `watchQuery`, and `invalidate` remain unchanged. --- ### 2. Register serializers Register a `QoraSerializer` for every type you want to persist. Do this **before** calling `hydrate`. ```dart [main.dart] // Single object client.registerSerializer( QoraSerializer( toJson: (user) => user.toJson(), fromJson: User.fromJson, ), name: 'User', // explicit name: see Obfuscation section below ); // Collection client.registerSerializer>( QoraSerializer( toJson: (posts) => posts.map((p) => p.toJson()).toList(), fromJson: (json) => (json as List).map((e) => Post.fromJson(e)).toList(), ), name: 'List', ); // Primitive: no conversion needed client.registerSerializer( QoraSerializer(toJson: (n) => n, fromJson: (json) => json as int), name: 'int', ); ``` Types without a registered serializer are fetched and cached in memory normally. They are not persisted to disk. For projects with many models, see [Organising Serializers at Scale](https://qora.meragix.dev/#organising-serializers-at-scale) below. --- ### 3. Initialise storage, then hydrate Awaiting `hydrate()` before `runApp` guarantees a warm cache on the first frame: ```dart [main.dart] Future main() async { WidgetsFlutterBinding.ensureInitialized(); await storage.init(); // open the database / file await client.hydrate(); // warm cache from disk runApp(QoraScope(client: client, child: const MyApp())); } ``` If `hydrate()` is measurably slow (large number of stored entries: verify with profiling before optimising), show a **splash screen** while the future runs rather than blocking `runApp`: ```dart [main.dart] void main() { WidgetsFlutterBinding.ensureInitialized(); runApp( QoraScope( client: client, child: FutureBuilder( future: storage.init().then((_) => client.hydrate()), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const MaterialApp(home: SplashScreen()); } return const MyApp(); }, ), ), ); } ``` In practice, `hydrate()` reads a handful of JSON strings from local storage. For most apps the `before runApp` pattern is simpler and fast enough. --- ## How It Works ### Auto-persist (write-through) After every successful fetch: whether triggered directly or by SWR background revalidation: the result is written to `StorageAdapter` as a JSON envelope containing: - the serialised data - a type discriminator (`typeName`) - the timestamp (`persistedAt`) - the TTL in milliseconds No extra code needed. As long as the type has a registered serializer, it is persisted automatically. --- ### Lazy typed hydration `hydrate()` deserialises stored entries but does **not** inject them into the cache immediately. Instead, it queues them in an internal map. On the first call to `fetchQuery`, `watchQuery`, `getQueryData`, etc., the queued entry is consumed and injected as a correctly-typed `CacheEntry`. This avoids a Dart runtime cast failure: `CacheEntry as CacheEntry` throws, but `CacheEntry` created by `hydrateQuery` does not. --- ### SWR and freshness The restored `Success` state carries the original `persistedAt` as its `updatedAt` timestamp. This means `staleTime` works exactly as in-memory: - `persistedAt` is recent → entry is **fresh** → no network call on mount - `persistedAt` is old → entry is **stale** → widget shows cached data immediately, then revalidates in background (SWR) ```dart [main.dart] // staleTime: 5 minutes // If the last fetch was 3 minutes ago → still fresh → no spinner on restart // If the last fetch was 8 minutes ago → show cached data + background refetch ``` --- ## TTL (Time To Live) ### Global TTL Set once on `PersistQoraClient`: ```dart [main.dart] final client = PersistQoraClient( storage: adapter, persistDuration: const Duration(days: 7), // default ); ``` By default, entries expire after 7 days (`persistDuration: Duration(days: 7)`). Pass `null` to persist indefinitely (until `removeQuery` or `evictFromStorage` is called explicitly). Pass `Duration.zero` to disable persistence (entries expire immediately). --- ### Per-query TTL override Pass `ttl:` when calling `persistQuery` manually: ```dart [main.dart] // Force-persist the current cached value with a custom TTL await client.persistQuery(['users', userId], ttl: const Duration(hours: 1)); ``` --- ### What happens to expired entries on hydrate? Expired entries are **deleted from storage and skipped**: they are never injected into the cache. The next successful fetch will write a fresh entry. ```mermaid graph TD A[hydrate: read all keys from storage] A --> B{Expired?} B -- yes --> C[Delete from storage] B -- no --> D{Serializer found?} D -- no --> E[Warn + skip] D -- yes --> F{fromJson OK?} F -- no --> G[Delete + warn] F -- yes --> H[Queue in _pendingHydration] H --> I[First fetchQuery / watchQuery call] I --> J[hydrateQuery typed injection into cache] ``` ### Model versioning and migrations If your model schema changes between app releases: a required field added to `User`, a field renamed, a type changed: persisted entries written by the old version may fail to deserialise with the new `fromJson`. `PersistQoraClient` intercepts every `fromJson` exception during `hydrate`, logs a warning (when `debugMode` is `true`), deletes the corrupt entry from storage, and moves on. The app never crashes because of a stale schema on disk. Your responsibility is to write a defensive `fromJson` that handles both old and new shapes: ```dart [models/user.dart] factory User.fromJson(dynamic json) { final map = json as Map; return User( id: map['id'] as int, name: map['name'] as String, // Added in v2: default to empty string for entries persisted by v1 avatarUrl: map['avatarUrl'] as String? ?? '', ); } ``` For **breaking changes** (field removal, type change with no safe default), bump the serializer `name`: e.g. `'User'` → `'User_v2'`. Old entries are silently skipped on hydration (no serializer registered for the old name), the user gets a fresh network fetch on that run, and future writes use the new schema. Old entries are eventually purged by TTL expiry. --- ## Storage Eviction Semantics | Operation | In-memory cache | Storage | | ------------------ | ----------------------------- | ------------- | | `invalidate(key)` | Marks stale, triggers refetch | **Untouched** | | `removeQuery(key)` | Removed | Deleted | | `clearCache()` | Cleared | **Untouched** | | `clearStorage()` | Untouched | Cleared | | `clear()` | Cleared | Cleared | `invalidate` intentionally does **not** delete from storage. The intent is to refetch, not purge. The next successful fetch will overwrite the stored entry with fresh data. --- ## Obfuscation and Flutter Web On Flutter Web or when Dart's `--obfuscate` flag is active, `T.toString()` is replaced by a short minified identifier (`a`, `b`, …). If `registerSerializer` is called without an explicit `name`, the type discriminator written to disk becomes unreadable on the next cold start. **Always pass `name:` explicitly** when targeting these configurations: ```dart [main.dart] // Good: Stable: name is hardcoded, never changes client.registerSerializer(userSerializer, name: 'User'); client.registerSerializer>(postListSerializer, name: 'List'); // Unsafe on Flutter Web / --obfuscate client.registerSerializer(userSerializer); // name defaults to User.toString() ``` The `name` must remain **stable across app versions**. Changing it will cause all previously persisted entries for that type to be silently skipped on the next `hydrate`. --- ## Organising Serializers at Scale For small projects (< 10 models), inline `registerSerializer` calls in `main.dart` are fine. As your app grows, the registrations need structure. --- ### The problem with inline registration With 20+ models, `main.dart` becomes a maintenance burden: ```dart [main.dart] // Bad: Unmanageable at scale client.registerSerializer(...); client.registerSerializer(...); client.registerSerializer(...); client.registerSerializer(...); // 20 more lines... await client.hydrate(); ``` --- ### Recommended: satellite `_serializer.dart` files Keep your model **pure** (no Qora dependency) and place its serializer in a dedicated satellite file next to it: ```text features/ user/ models/ user.dart ← pure data class, no Qora import user_serializer.dart ← QoraSerializer lives here post/ models/ post.dart post_serializer.dart ``` ```dart [features/user/models/user.dart] // No Qora import: model is fully portable class User { final int id; final String name; const User({required this.id, required this.name}); factory User.fromJson(dynamic json) { final map = json as Map; return User(id: map['id'] as int, name: map['name'] as String); } Map toJson() => {'id': id, 'name': name}; } ``` ```dart [features/user/models/user_serializer.dart] import 'package:qora/qora.dart'; import 'user.dart'; final userSerializer = QoraSerializer( toJson: (u) => u.toJson(), fromJson: User.fromJson, ); final userListSerializer = QoraSerializer>( toJson: (list) => list.map((u) => u.toJson()).toList(), fromJson: (json) => (json as List).map(User.fromJson).toList(), ); ``` This pattern has three benefits: - **Model stays portable**: reusable with a Dart backend, other packages, or a different cache engine without dragging a Qora dependency - **Tree-shaking**: if `user_serializer.dart` is never imported, it is not compiled - **Multi-format friendly**: you can add `user_firestore.dart`, `user_supabase.dart` etc. without touching the model --- ### Central registration function Collect all satellite serializers in one file: `main.dart` calls a single function: ```dart [lib/core/persistence/serializers.dart] import 'package:qora/qora.dart'; import '../../features/user/models/user_serializer.dart'; import '../../features/post/models/post_serializer.dart'; import '../../features/product/models/product_serializer.dart'; void registerAllSerializers(PersistQoraClient client) { client.registerSerializer(userSerializer, name: 'User'); client.registerSerializer>(userListSerializer, name: 'List'); client.registerSerializer(postSerializer, name: 'Post'); client.registerSerializer(productSerializer, name: 'Product'); } ``` ```dart [main.dart] registerAllSerializers(client); // ← single call, regardless of model count await client.hydrate(); runApp(...); ``` This file also becomes the authoritative list of every type that gets persisted: useful for auditing and migrations. --- ### Alternative: `static const` on the model class If your model lives entirely within the Flutter app and `qora` is a core dependency with no plans to share the model elsewhere, a `static const` serializer on the class itself is acceptable and offers the best autocompletion: ```dart [features/user/models/user.dart] import 'package:qora/qora.dart'; class User { // ... static const serializer = QoraSerializer( toJson: _encode, fromJson: User.fromJson, ); static Map _encode(User u) => u.toJson(); } ``` ```dart [lib/core/persistence/serializers.dart] client.registerSerializer(User.serializer, name: 'User'); ``` This approach is simpler but couples the model to Qora. Avoid it if the model is shared across packages or with a backend. > **Note on Dart extensions:** `static const` members defined inside a Dart `extension` block are accessed via the *extension name* (`UserQoraExt.serializer`), not via the class (`User.serializer`). If you want `User.serializer` syntax, the static must be on the class itself, not on an extension. --- ### Decision guide | Context | Pattern | | ------------------------------------------- | ----------------------------------------------- | | < 10 models, small app | Inline in `main.dart` | | Model shared with backend or other packages | `_serializer.dart` satellite (mandatory) | | Feature-first architecture, 15+ models | `_serializer.dart` + `registerAllSerializers()` | | Model is Flutter-only, DX is top priority | `static const` on the class | --- ## Storage Adapters The built-in `InMemoryStorageAdapter` is useful for tests. For real apps, use one of the external adapter packages: | Package | Backend | | --------------------------- | ----------------------------------------------------- | | `qora_storage_shared_prefs` | `SharedPreferences`: simple, no extra setup | | `qora_storage_hive` | Hive: fast, no native dependencies | | `qora_storage_isar` | Isar: reactive, efficient binary format | | `qora_storage_drift` | Drift / SQLite: relational, great for complex queries | All adapters implement the same `StorageAdapter` interface: switching backends requires changing one line. --- ## Full Flutter Example ```dart [main.dart] import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; import 'package:qora/qora.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final storage = InMemoryStorageAdapter(); // replace with HiveStorageAdapter() await storage.init(); final client = PersistQoraClient( storage: storage, persistDuration: const Duration(days: 7), config: const QoraClientConfig( defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)), ), ); client.registerSerializer( QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson), name: 'User', ); client.registerSerializer>( QoraSerializer( toJson: (posts) => posts.map((p) => p.toJson()).toList(), fromJson: (json) => (json as List).map((e) => Post.fromJson(e)).toList(), ), name: 'List', ); await client.hydrate(); // warm cache from disk runApp( QoraScope( client: client, lifecycleManager: FlutterLifecycleManager(qoraClient: client), child: const MyApp(), ), ); } ``` ```dart [screens/posts_screen.dart] class PostsScreen extends StatelessWidget { const PostsScreen({super.key}); @override Widget build(BuildContext context) { return QoraBuilder>( queryKey: ['posts'], fetcher: PostApi.fetchAll, // If hydrated data exists: shown instantly, no spinner. // If stale: shown immediately while refetching in background. builder: (context, state, fetchStatus) => switch (state) { Initial() => const SizedBox.shrink(), Loading(previousData: null) => const CircularProgressIndicator(), Loading(:final previousData?) => PostList(previousData), Success(:final data) => PostList(data), Failure(:final error, previousData: null) => ErrorView('$error'), Failure(:final error, :final previousData?) => Column(children: [ PostList(previousData), ErrorBanner('Refresh failed: $error'), ]), }, ); } } ``` On the first launch, `PostsScreen` shows a spinner and fetches from the network. On every subsequent launch, the posts list appears instantly from disk while Qora revalidates in the background if the data is stale. --- ## Logout / Account Switch When the user logs out, you can choose to clear memory only (keeping storage for next session) or clear both: ```dart [auth_service.dart] Future logout() async { await api.logout(); // Wipes cache + storage atomically (complete reset) client.clear(); // Or, wipe memory only but keep persisted queries for next session // client.clearCache(); Navigator.pushReplacementNamed(context, '/login'); } ``` --- ## Next Steps - [PersistQoraClient API](https://qora.meragix.dev/../api-reference/persist-qora-client): full method reference - [Optimistic Updates](https://qora.meragix.dev/optimistic-updates): combine persistence with optimistic UI - [QoraClient API](https://qora.meragix.dev/../api-reference/qora-client): base client reference # Network Aware Qora provides network-aware behavior for queries and mutations. When connectivity is lost, Qora can pause fetches, queue mutations, and replay operations after reconnect. --- ## Overview Network awareness in Qora is built on three orthogonal primitives: | Primitive | What it controls | | ---------------------- | ---------------------------------------------------- | | `NetworkMode` | How a query or mutation behaves when offline | | `FetchStatus` | Whether a fetch is actively running, paused, or idle | | `OfflineMutationQueue` | FIFO queue that replays queued writes on reconnect | They are fully independent from each other. You can use `NetworkMode.online` (pause queries) without enabling the offline mutation queue, or use the queue on mutations while letting queries use `NetworkMode.always`. --- ## NetworkMode `NetworkMode` is a per-query / per-mutation enum that tells Qora what to do when the device has no connectivity: ```dart [lib/core/qora.dart] enum NetworkMode { /// Default. Pause the fetch while offline and replay it on reconnect. /// The state becomes Loading(previousData: ) and /// FetchStatus becomes paused until connectivity returns. online, /// Always execute regardless of connectivity. /// Useful when the fetcher uses a local database or cache. always, /// Serve cached data immediately, then schedule a background revalidation /// when the device is back online. offlineFirst, } ``` ### Setting `networkMode` per query ```dart [lib/data/user_repository.dart] final user = await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: const QoraOptions(networkMode: NetworkMode.online), // default ); ``` ### Setting `networkMode` globally ```dart [lib/core/qora.dart] final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions( networkMode: NetworkMode.online, ), ), ); ``` ### Behaviour by mode | Mode | Offline behaviour | On reconnect | | ------------------ | ------------------------------------------------- | ------------------------------ | | `online` (default) | Fetch is **paused**, `FetchStatus.paused` emitted | Replayed automatically | | `always` | Fetch **runs**: fails if no connectivity | Not retried on reconnect | | `offlineFirst` | Cached data returned immediately | Background revalidation queued | ::tip Use `NetworkMode.always` for queries backed by a local SQLite database or Hive box: they never need connectivity and should never be paused. :: --- ## FetchStatus: the second axis `QoraState` describes **what you have** (initial / loading / success / failure). `FetchStatus` describes **what Qora is currently doing** to get it: ```dart enum FetchStatus { /// A network request is in flight. fetching, /// Queued for fetch but offline: will resume on reconnect. paused, /// No active fetch (data is fresh or no fetch has started yet). idle, } ``` These two values form a two-dimensional grid. The most useful combinations are: | `QoraState` | `FetchStatus` | Meaning | | ------------------------------- | ------------- | -------------------------------------------------------------------- | | `Loading` | `fetching` | First fetch running | | `Loading` (no `previousData`) | `paused` | Offline, nothing cached: the fetch waits for the network | | `Loading` (with `previousData`) | `paused` | Offline, `online` mode: refetch waiting, cached data still available | | `Success` | `fetching` | Background revalidation running | | `Success` | `paused` | Offline, `offlineFirst` mode: cached data served, refetch queued | | `Success` | `idle` | Data is fresh: nothing running | | `Failure` | `idle` | Last fetch failed, not retrying | ### Observing `FetchStatus` in Dart ```dart [lib/data/user_repository.dart] client.watchFetchStatus(['users', userId]).listen((status) { switch (status) { case FetchStatus.fetching: print('Fetching…'); case FetchStatus.paused: print('Offline: will retry when back online'); case FetchStatus.idle: print('Done'); } }); ``` ### Observing `FetchStatus` in Flutter `QoraBuilder` receives `FetchStatus` as its third builder argument: ```dart [lib/ui/screens/user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => api.getUser(userId), builder: (context, state, fetchStatus) { // Show a "paused" chip when offline and waiting to fetch final isPaused = fetchStatus == FetchStatus.paused; return switch (state) { Initial() => const SizedBox.shrink(), Loading() => isPaused ? const OfflineWaitingIndicator() : const CircularProgressIndicator(), Success(:final data) => Column( children: [ UserCard(data), if (isPaused) const RefreshingOfflineChip(), ], ), Failure(:final error) => ErrorBanner(error), }; }, ) ``` --- ## Offline Mutation Queue The `OfflineMutationQueue` holds write operations that could not be sent while offline and replays them in FIFO order when the device reconnects. ### Enabling the queue The queue is automatically created and attached to `QoraClient`. In Flutter, `QoraMutationBuilder` injects it automatically. You only need to opt-in per mutation via `MutationOptions`: ```dart [lib/ui/todo_screen.dart] QoraMutationBuilder( mutator: (todo) => api.createTodo(todo), options: MutationOptions( // Opt this mutation into the offline queue offlineQueue: true, // Provide an optimistic response so the UI updates immediately optimisticResponse: (todo) => todo, ), builder: (context, state, mutate) { return AddTodoButton( onTap: () => mutate(newTodo), ); }, ) ``` ### Offline flow: step by step When `offlineQueue: true` and the device is offline: ```text mutate(variables) │ ├── offlineQueue: true & offline? │ YES │ ├── Enqueue PendingMutation (FIFO) │ ├── optimisticResponse provided? │ │ YES → emit MutationSuccess(isOptimistic: true) │ │ NO → stay MutationPending │ └── ← return to caller │ └── NO (online) → execute normally → MutationSuccess(isOptimistic: false) ``` On reconnect, `QoraClient` replays the queue: ```text reconnect detected │ ├── Replay paused queries (batched, with jitter) └── Replay OfflineMutationQueue (FIFO) │ ├── each mutation runs once, in order ├── success → onSuccess, invalidates, onSettled └── failure → onError (rollback), onSettled, next mutation ``` ### `isOptimistic`: styling unconfirmed mutations When `optimisticResponse` is provided, the mutation emits `MutationSuccess(isOptimistic: true)`. Use this flag in the UI to render a "pending" visual state (greyed out, clock icon, strikethrough) for items that have not yet been confirmed by the server: ```dart [lib/ui/todo_list.dart] QoraMutationBuilder( mutator: api.createTodo, options: MutationOptions( offlineQueue: true, optimisticResponse: (todo) => todo, onSuccess: (_, todo, __) async { // Runs for REAL success (isOptimistic: false) only after server confirms context.qora.invalidate(['todos']); }, ), builder: (context, state, mutate) { // Check isOptimistic on the MutationSuccess state final isOptimistic = switch (state) { MutationSuccess(:final isOptimistic) => isOptimistic, _ => false, }; return Column( children: [ if (isOptimistic) const Row( children: [ Icon(Icons.schedule, size: 14), SizedBox(width: 4), Text('Queued: will sync when online'), ], ), SubmitButton( onTap: state.isPending ? null : () => mutate(newTodo), ), ], ); }, ) ``` ### Accessing the queue directly ```dart [lib/ui/offline_indicator.dart] // How many mutations are pending replay? final count = context.qora.offlineMutationQueue.length; // Inspect the pending mutations for (final pending in context.qora.offlineMutationQueue.snapshot) { print('${pending.mutatorId} enqueued at ${pending.enqueuedAt}'); } // Manually trigger a replay (normally done automatically on reconnect) final result = await context.qora.offlineMutationQueue.replay(); print('Succeeded: ${result.succeeded}, Failed: ${result.failed}'); ``` ### When a replayed mutation fails Each queued mutation is sent once, in order, through the same path as an online call. When the server rejects one, `onError` receives the context returned by `onMutate` at enqueue time, so the rollback you wrote for the online case also covers the offline one. Replay then continues with the next mutation. A failed replay is not re-enqueued. Surface it from `onError` (a snackbar, a retry button) rather than relying on the next reconnect. Keep queued mutations independent where you can. When one depends on another (create a todo, then complete it), the second one fails if the first was rejected, and its own `onError` rolls it back. --- ## ReconnectStrategy: thundering herd prevention When connectivity returns and dozens of queries are paused, replaying them simultaneously floods the server. `ReconnectStrategy` introduces controlled concurrency and jitter: ```dart [lib/core/qora.dart] final client = QoraClient( config: QoraClientConfig( reconnectStrategy: const ReconnectStrategy( maxConcurrent: 5, // Replay 5 queries at a time jitter: Duration(milliseconds: 200), // Random delay between batches ), ), ); ``` ### Named constructors ```dart // Default: 5 concurrent, 100 ms jitter const ReconnectStrategy() // All queries replay simultaneously (useful for testing) ReconnectStrategy.instant() // Conservative: 2 concurrent, 500 ms jitter ReconnectStrategy.conservative() ``` --- ## Flutter Setup ### 1. Install `connectivity_plus` ```yaml [pubspec.yaml] dependencies: connectivity_plus: ^6.0.0 ``` ### 2. Wire up `QoraScope` ```dart [lib/main.dart] import 'package:flutter/foundation.dart'; import 'package:qora_flutter/qora_flutter.dart'; void main() { final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), networkMode: NetworkMode.online, ), reconnectStrategy: ReconnectStrategy( maxConcurrent: 5, jitter: Duration(milliseconds: 100), ), debugMode: kDebugMode, ), ); runApp( QoraScope( client: client, lifecycleManager: FlutterLifecycleManager(qoraClient: client), // No QoraClient reference needed: pure signal provider connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ); } ``` ### 3. Add `NetworkStatusIndicator` Wrap your app (or any sub-tree) with `NetworkStatusIndicator` to show an offline banner automatically: ```dart [lib/main.dart] QoraScope( client: client, connectivityManager: FlutterConnectivityManager(), child: NetworkStatusIndicator( child: const MyApp(), ), ) ``` This renders a thin dark bar at the bottom of the screen saying "Offline mode" whenever `NetworkStatus.offline` is detected. #### Custom offline banner ```dart [lib/main.dart] NetworkStatusIndicator( offlineBanner: (context) => Container( color: Colors.orange, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.wifi_off, color: Colors.white, size: 16), SizedBox(width: 8), Text('No connection', style: TextStyle(color: Colors.white)), ], ), ), child: const MyApp(), ) ``` #### Full builder control Use `builder` when you need to react to `NetworkStatus.online` or `NetworkStatus.unknown` as well: ```dart [lib/main.dart] NetworkStatusIndicator( builder: (context, status, child) { return Stack( children: [ child!, if (status == NetworkStatus.offline) const Positioned( bottom: 0, left: 0, right: 0, child: MyOfflineBanner(), ), if (status == NetworkStatus.unknown) const Positioned( bottom: 0, left: 0, right: 0, child: MyCheckingBanner(), ), ], ); }, child: const MyApp(), ) ``` --- ## Complete Example: Offline-First Todo App ```dart [lib/ui/todo_screen.dart] import 'package:flutter/material.dart'; import 'package:qora_flutter/qora_flutter.dart'; class TodoScreen extends StatelessWidget { const TodoScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: QoraBuilder>( queryKey: const ['todos'], fetcher: api.getTodos, builder: (context, state, fetchStatus) { // Paused → offline, waiting to load for the first time if (fetchStatus == FetchStatus.paused && state.dataOrNull == null) { return const Center(child: Text('Offline: connect to load todos')); } return switch (state) { Initial() => const SizedBox.shrink(), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorScreen(error), Success(:final data) => TodoList( todos: data, isRefreshing: fetchStatus == FetchStatus.fetching, isStale: fetchStatus == FetchStatus.paused, ), }; }, ), floatingActionButton: QoraMutationBuilder( mutator: (title) => api.createTodo(title), options: MutationOptions( offlineQueue: true, optimisticResponse: (title) => null, onSuccess: (_, __, ___) async => context.qora.invalidate(const ['todos']), ), builder: (context, state, mutate) { final isOptimistic = state is MutationSuccess && state.isOptimistic; return FloatingActionButton.extended( onPressed: state.isPending ? null : () => mutate('New Todo'), icon: isOptimistic ? const Icon(Icons.schedule) : const Icon(Icons.add), label: Text( isOptimistic ? 'Queued offline' : 'Add Todo', ), ); }, ), ); } } ``` --- ## Next Steps - [NetworkStatusBuilder / Indicator](https://qora.meragix.dev/../flutter-integration/network-status): Widgets for connectivity UI - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder): Full `FetchStatus` builder API - [QoraMutationBuilder](https://qora.meragix.dev/../flutter-integration/qora-mutation-builder): Offline queue and `isOptimistic` - [QoraOptions](https://qora.meragix.dev/../api-reference/qora-options): `networkMode` field reference - [QoraClientConfig](https://qora.meragix.dev/../api-reference/qora-client-config): `reconnectStrategy` field reference # Infinite Queries An **infinite query** fetches data in pages and accumulates results across requests. Pages are appended or prepended instead of replacing existing data. Qora implements this pattern with `InfiniteQueryObserver`, `InfiniteData`, and `InfiniteQueryState`. --- ## The State Machine ```text InfiniteInitial │ │ observer.fetch() ▼ InfiniteLoading ← initial page load only │ ├── success ──▶ InfiniteSuccess │ │ │ ├── fetchNextPage() ──▶ InfiniteSuccess (isFetchingNextPage: true) │ ├── fetchPreviousPage() ──▶ InfiniteSuccess (isFetchingPreviousPage: true) │ └── fetch failure ──▶ InfiniteFailure (with previousData) │ └── failure ──▶ InfiniteFailure ``` Key design: after the first page is loaded, the state never returns to `InfiniteLoading`. Subsequent page fetches signal their progress via `isFetchingNextPage` / `isFetchingPreviousPage` on `InfiniteSuccess`, so existing pages stay visible while more data loads. --- ## Basic Setup ```dart [posts_screen.dart] final observer = InfiniteQueryObserver, int>( client: client, key: ['posts'], fetcher: (page) => api.getPosts(page: page), options: InfiniteQueryOptions( initialPageParam: 1, // Return null when there are no more pages. getNextPageParam: (lastPage, allPages) => lastPage.hasMore ? allPages.length + 1 : null, ), ); // Fetch the first page await observer.fetch(); // Subscribe to state changes observer.stream.listen((state) { switch (state) { case InfiniteInitial() || InfiniteLoading(): showSpinner(); case InfiniteSuccess(:final data, :final hasNextPage): final posts = data.flatten((page) => page); renderList(posts, hasNextPage: hasNextPage); case InfiniteFailure(:final error): showError(error); } }); // Later: trigger next page (e.g. from a scroll listener) await observer.fetchNextPage(); // Always dispose when done observer.dispose(); ``` --- ## `InfiniteData` `InfiniteData` is an **immutable** container that holds all loaded pages and the parameters used to fetch them: ```dart state.data.pages // List: all pages in order state.data.pageParams // List: params used for each page state.data.pageCount // int state.data.isEmpty // bool ``` ### `flatten`: from pages to items `flatten` converts a list of pages into a flat item list for `ListView.builder`: ```dart [posts_screen.dart] // TData == List final posts = data.flatten((page) => page); // TData is a response object with a nested list final posts = data.flatten((page) => page.items); ``` --- ## `InfiniteQueryOptions` | Option | Type | Required | Description | | ---------------------- | -------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------ | | `initialPageParam` | `TPageParam` | Yes | Parameter for the very first page. Typically `1`, `0`, or an empty cursor. | | `getNextPageParam` | `TPageParam? Function(TData lastPage, List allPages)` | Yes | Return the next page's parameter, or `null` when no more pages exist. | | `getPreviousPageParam` | `TPageParam? Function(TData firstPage, List allPages)?` | : | Return the previous page's parameter. Only needed for bi-directional scroll. | | `maxPages` | `int?` | : | Maximum pages kept in memory (windowed paging). When exceeded, the oldest page is dropped. | | `baseOptions` | `QoraOptions` | : | Standard retry, stale time, and network mode options applied to every page fetch. | --- ## Pagination Strategies ### Integer page numbers ```dart [posts_screen.dart] InfiniteQueryOptions, int>( initialPageParam: 1, getNextPageParam: (lastPage, allPages) => lastPage.hasMore ? allPages.length + 1 : null, ) ``` ### Cursor-based ```dart [feed_screen.dart] InfiniteQueryOptions( initialPageParam: null, // null = "start from beginning" getNextPageParam: (lastPage, allPages) => lastPage.nextCursor, ) ``` ### Offset/limit ```dart [search_screen.dart] const pageSize = 20; InfiniteQueryOptions, int>( initialPageParam: 0, getNextPageParam: (lastPage, allPages) { final nextOffset = allPages.length * pageSize; return lastPage.length == pageSize ? nextOffset : null; }, ) ``` --- ## Bi-directional Scroll Chat feeds, timelines, and comment threads often need to load in both directions. Provide `getPreviousPageParam` to enable backward paging: ```dart [chat_screen.dart] InfiniteQueryOptions, String?>( initialPageParam: null, // Start from the latest message getNextPageParam: (lastPage, allPages) => lastPage.olderCursor, getPreviousPageParam: (firstPage, allPages) => firstPage.newerCursor, ) ``` ```dart [chat_screen.dart] // Scroll up → load older messages await observer.fetchPreviousPage(); // Scroll down → load newer messages await observer.fetchNextPage(); ``` --- ## Windowed Paging (`maxPages`) For long-running scroll sessions (Twitter/X-like feeds), an unbounded page list causes increasing memory usage and UI jank. `maxPages` limits the in-memory window: ```dart [feed_screen.dart] InfiniteQueryOptions, int>( initialPageParam: 1, getNextPageParam: (lastPage, allPages) => lastPage.hasMore ? allPages.length + 1 : null, maxPages: 10, // Keep at most 10 pages in memory ) ``` When the window slides: - **`fetchNextPage`**: drops the oldest page from the front. - **`fetchPreviousPage`**: drops the newest page from the back. The dropped edge becomes scrollable again: when the user reaches it, `hasPreviousPage` (or `hasNextPage`) becomes `true` and Qora re-fetches the dropped page using `getPreviousPageParam` (or `getNextPageParam`). This requires your API to support random-access parameters (stable page numbers or cursors). ::tip For most apps, `maxPages: 5–10` provides a good balance. Each page holds 20–50 items, so 10 pages ≈ 200–500 items in memory: well within Flutter's capabilities. :: --- ## Refetch After Invalidation When data becomes stale (e.g. a mutation changes server state), call `refetch()`: ```dart [posts_screen.dart] // After creating a new post await api.createPost(title); await observer.refetch(); // Refetches all loaded pages in sequence ``` `refetch()` re-fetches every page in the current window using its original `pageParams`, preserving the page count. It signals progress via `isFetchingNextPage: true` on the existing `InfiniteSuccess` state: existing data stays visible. You can also invalidate from anywhere in the app: ```dart [anywhere.dart] client.invalidateInfiniteQuery(['posts']); // The observer detects InfiniteInitial and re-fetches automatically ``` --- ## Error Handling Errors during `fetchNextPage` or `fetchPreviousPage` transition to `InfiniteFailure` while preserving `previousData`: ```dart [posts_screen.dart] case InfiniteFailure(:final error, :final previousData): if (previousData != null) { // Show existing content + an error banner return Column(children: [ PostList(items: previousData.flatten((p) => p)), ErrorBanner(message: 'Failed to load more: $error'), ]); } // No data loaded yet: show full error screen return ErrorScreen(error); ``` Retry is automatic for page fetches (controlled by `baseOptions.retryCount`, default 3). For the Flutter binding, see [`InfiniteQueryBuilder`](https://qora.meragix.dev/../flutter-integration/infinite-query-builder). --- ## Optimistic Updates You can write directly to the infinite cache for immediate UI feedback: ```dart [posts_screen.dart] // Snapshot before mutation final snapshot = client.getInfiniteQueryData, int>(['posts']); // Optimistically prepend the new post to the first page if (snapshot != null) { final optimisticData = InfiniteData, int>( pages: [ [Post.optimistic(title), ...snapshot.pages.first], ...snapshot.pages.skip(1), ], pageParams: snapshot.pageParams, ); client.setInfiniteQueryData(['posts'], optimisticData); } try { await api.createPost(title); client.invalidateInfiniteQuery(['posts']); } catch (_) { // Rollback if (snapshot != null) client.setInfiniteQueryData(['posts'], snapshot); } ``` --- ## Next Steps - [InfiniteQueryBuilder](https://qora.meragix.dev/../flutter-integration/infinite-query-builder): Bind infinite queries to Flutter UI in one widget - [Infinite Query API Reference](https://qora.meragix.dev/../api-reference/infinite-query): Full API documentation - [Mutations Guide](https://qora.meragix.dev/mutations): Combine mutations with cache invalidation # Dependent Queries A **dependent query** fires only after another query has successfully loaded its data. This is the pattern for sequential data-fetching: load a session first, then load the user profile; load a project, then load its members. --- ## `dependsOn` Set `QoraOptions.dependsOn` to the key of the prerequisite query. Qora subscribes internally and triggers the dependent fetch when data becomes available. ```dart [main.dart] // Step 1: fetch the session client.watchQuery( key: ['session'], fetcher: () => api.getSession(), ); // Step 2: fires automatically once ['session'] has data client.watchQuery( key: ['user'], fetcher: () => api.getUser(session!.userId), options: QoraOptions(dependsOn: ['session']), ); ``` --- ## Behaviour per API | API | Behaviour when dependency is unresolved | | ------------ | ------------------------------------------------------------------ | | `watchQuery` | Subscribes reactively; fires when dependency reaches `Success` | | `fetchQuery` | Throws `StateError` immediately. Call after dependency resolution. | | `prefetch` | Silently skips. Safe to call speculatively. | --- ## In Flutter: `QoraBuilder` The cleanest Flutter pattern is to nest a `QoraBuilder` for the dependent query inside the success case of the parent: ```dart [profile_screen.dart] QoraBuilder( queryKey: ['session'], fetcher: () => api.getSession(), builder: (context, sessionState, refetch) => switch (sessionState) { Loading() || Initial() => const CircularProgressIndicator(), Failure(:final error) => ErrorWidget('$error'), Success(:final data) => QoraBuilder( queryKey: ['user', data.userId], fetcher: () => api.getUser(data.userId), builder: (context, userState, _) => switch (userState) { Success(:final data) => ProfileCard(data), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorWidget('$error'), Initial() => const SizedBox.shrink(), }, ), }, ) ``` The inner `QoraBuilder` only mounts when the session is ready, so the dependent fetch never fires prematurely. --- ## Using `dependsOn` with `watchQuery` When both queries need to be active simultaneously (e.g. in a repository layer without Flutter), use `dependsOn`: ```dart [user_repository.dart] class UserRepository { final QoraClient _client; Stream> watchCurrentUser() { // Ensure the session query is running _client.watchQuery( key: ['session'], fetcher: () => api.getSession(), ); // This query waits for ['session'] to have data before fetching return _client.watchQuery( key: ['user'], fetcher: () async { final session = _client.getQueryData(['session'])!; return api.getUser(session.userId); }, options: const QoraOptions(dependsOn: ['session']), ); } } ``` --- ## Chaining Multiple Dependencies A query can only declare a single `dependsOn`. For a chain of three queries, nest the `dependsOn` at each step: ```dart [main.dart] // 1: session client.watchQuery(key: ['session'], fetcher: () => api.getSession()); // 2: user (depends on session) client.watchQuery( key: ['user'], fetcher: () => api.getUser(session!.userId), options: const QoraOptions(dependsOn: ['session']), ); // 3: projects (depends on user) client.watchQuery>( key: ['projects'], fetcher: () => api.getProjects(user!.orgId), options: const QoraOptions(dependsOn: ['user']), ); ``` --- ## `fetchQuery` and Dependencies `fetchQuery` throws immediately if the dependency is not resolved. Use it only after you know the dependency has data: ```dart [main.dart] // Only safe to call once ['session'] is in Success state final user = await client.fetchQuery( key: ['user'], fetcher: () => api.getUser(session!.userId), options: QoraOptions(dependsOn: ['session']), ); ``` For reactive code, prefer `watchQuery`: it handles the timing automatically. --- ## Related - [QoraOptions API](https://qora.meragix.dev/../api-reference/qora-options): `dependsOn`, `enabled` - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder): Widget-level query binding - [Quick Start](https://qora.meragix.dev/../getting-started/quick-start) # Cancellation `CancelToken` is a one-shot cooperative cancellation signal. Pass it to `fetchQuery`, `watchQuery`, or `prefetch` and call `cancel()` to abort the fetch before start or during execution. --- ## Creating a Token ```dart [main.dart] final token = CancelToken(); ``` A token can only be cancelled once. Calling `cancel()` a second time is a no-op. --- ## Cancelling a `fetchQuery` ```dart [user_repository.dart] final token = CancelToken(); // Start the fetch final future = client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), cancelToken: token, ); // Cancel it (e.g. the user navigated away) token.cancel(); try { final user = await future; } on QoraCancelException catch (e) { // e.key is the string-encoded query key print('Fetch cancelled: ${e.key}'); } ``` When cancelled, `fetchQuery` throws `QoraCancelException` to the caller and restores the cache entry to its pre-fetch snapshot (no partial state is left behind). --- ## Cancelling a `watchQuery` stream ```dart [user_page.dart] final token = CancelToken(); final sub = client.watchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), cancelToken: token, ).listen((state) { ... }); // On widget dispose: cancel the in-flight fetch token.cancel(); await sub.cancel(); ``` ::tip Cancelling the stream subscription is separate from cancelling the fetch. `token.cancel()` aborts the HTTP request; `sub.cancel()` removes the state listener. Do both on dispose for a clean teardown. :: --- ## Timeout Pattern Combine `CancelToken` with `Future.delayed` to implement a request timeout: ```dart [user_repository.dart] Future fetchWithTimeout(int userId, Duration timeout) async { final token = CancelToken(); Timer(timeout, token.cancel); try { return await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), cancelToken: token, ); } on QoraCancelException { throw TimeoutException('User fetch timed out after $timeout'); } } ``` --- ## Navigation Cancellation in Flutter In a `StatefulWidget`, tie the token to the widget lifecycle: ```dart [user_screen.dart] class _UserScreenState extends State { CancelToken? _fetchToken; Future _loadUser() async { _fetchToken = CancelToken(); try { await context.qora.fetchQuery( key: ['users', widget.userId], fetcher: () => api.getUser(widget.userId), cancelToken: _fetchToken, ); } on QoraCancelException { // Widget disposed: ignore } } @override void dispose() { _fetchToken?.cancel(); super.dispose(); } } ``` --- ## `CancelToken` API ### Constructor ```dart CancelToken() ``` ### Properties | Property | Type | Description | | ------------- | ------ | ---------------------------------- | | `isCancelled` | `bool` | `true` after `cancel()` is called. | ### Methods | Method | Signature | Description | | ---------- | --------- | ------------------------------------------------------------------------ | | `cancel()` | `void` | Mark the token as cancelled. Idempotent and safe to call multiple times. | --- ## `QoraCancelException` Thrown by `fetchQuery` and `prefetch` when a fetch is cancelled via `CancelToken`. ```dart class QoraCancelException implements Exception { final String key; // JSON-encoded normalised query key } ``` | Property | Type | Description | | -------- | -------- | -------------------------------------------------- | | `key` | `String` | The string-encoded query key, e.g. `'["users",1]'` | --- ## Tracker Hook `QoraTracker.onQueryCancelled(String key)` is called every time a fetch is cancelled via `CancelToken`. Implement it on your custom tracker to log or measure cancellation rates: ```dart [analytics_tracker.dart] class AnalyticsTracker extends NoOpTracker { @override void onQueryCancelled(String key) { analytics.track('query_cancelled', {'key': key}); } } ``` --- ## Related - [QoraClient API](https://qora.meragix.dev/../api-reference/qora-client): `fetchQuery`, `watchQuery`, `prefetch` signatures - [QoraTracker](https://qora.meragix.dev/../api-reference/qora-client): Observability hooks # SSR Hydration `SsrHydrator` reads the server-rendered cache snapshot from `window.__QORA_STATE__` and injects it into `QoraClient` before the first frame renders. `SsrHydrator` is only available on **Flutter Web**. On native and server targets the import resolves to a no-op stub, so the same code compiles everywhere without `#if` guards. --- ## How It Works 1. Your server prefetches data and embeds it as a JSON object in a ` ``` Keys are JSON-encoded normalised query keys: the same format Qora uses internally (e.g. `'["users",1]'` for key `['users', 1]`). `updatedAtMs` is optional. When omitted, Qora treats the data as epoch-time-old (always stale): it is served immediately but a background revalidation fires at the first mount. --- ## Flutter Setup Call `SsrHydrator.hydrate()` before `runApp`: ```dart [main.dart] import 'package:flutter/material.dart'; import 'package:qora/qora.dart'; import 'package:qora_flutter/qora_flutter.dart'; void main() { final client = QoraClient(); // Hydrate from window.__QORA_STATE__ (no-op on non-web) SsrHydrator(client).hydrate( deserializers: { '["users",1]': (json) => User.fromJson(json as Map), '["posts"]': (json) => (json as List).map(Post.fromJson).toList(), }, ); runApp(QoraScope(client: client, child: const MyApp())); } ``` Keys in `window.__QORA_STATE__` that are absent from `deserializers` are silently skipped. If `deserializers` is omitted entirely, raw `dynamic` values from `dartify()` are queued directly: suitable when your data is already a plain `Map`/`List`. --- ## Security `SsrHydrator` is XSS-safe by design: - Uses `dartify()` for JS → Dart conversion: produces plain maps/lists/strings, never HTML. No `innerHTML` or `eval`. - Validates the top-level type (`Map`) and each entry (`Map` with a `'data'` key) before processing. - Wraps every deserializer call in `try/catch`: a corrupt entry cannot crash the app. **Your responsibility:** JSON-encode user-controlled data on the server before embedding it in `__QORA_STATE__`. Raw HTML interpolation bypasses Qora's protections and enables XSS. --- ## Freshness and SWR The `updatedAtMs` timestamp is stored as the `updatedAt` on the injected `Success` state. Qora's stale-while-revalidate logic uses it to decide whether to trigger a background fetch: ```dart [main.dart] // staleTime: 5 minutes // updatedAtMs: 4 minutes ago → fresh, no background fetch // updatedAtMs: 6 minutes ago → stale, SWR fires immediately on mount // updatedAtMs: absent → always stale client.watchQuery( key: ['users', 1], fetcher: () => api.getUser(1), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); ``` --- ## `SsrHydrator` API ### Constructor ```dart const SsrHydrator(QoraClient client) ``` ### `hydrate` ```dart void hydrate({Map? deserializers}) ``` Read `window.__QORA_STATE__` and enqueue hydration entries. | Parameter | Type | Description | | --------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `deserializers` | `Map?` | Map from string-encoded key to a function converting raw JSON to typed Dart. Keys absent from this map are skipped. Pass `null` to queue raw values. | --- ## With `PersistQoraClient` `SsrHydrator` and `PersistQoraClient` both use `queueHydration()` under the hood. They compose without conflict: the last write for a given key wins (first typed access picks it up): ```dart [main.dart] final client = PersistQoraClient(storage: storage); client.registerSerializer(...); await storage.init(); await client.hydrate(); // from disk SsrHydrator(client).hydrate(...); // from server (overwrites disk entry if key matches) runApp(QoraScope(client: client, child: const MyApp())); ``` --- ## Related - [Persistence Guide](https://qora.meragix.dev/persistence): Disk-based cache with `PersistQoraClient` - [QoraClient API](https://qora.meragix.dev/../api-reference/qora-client): `queueHydration`, `hydrateQuery` - [Quick Start](https://qora.meragix.dev/../getting-started/quick-start) # Error Handling Qora distinguishes between transient errors (retry automatically) and terminal errors (surface to the UI). All fetch failures go through the same pipeline: retry with exponential backoff → transition to `Failure` → notify subscribers. --- ## The `Failure` State When all retries are exhausted, `QoraState` transitions to `Failure`: ```dart sealed class QoraState { ... } final class Failure extends QoraState { final Object error; final StackTrace? stackTrace; final T? previousData; // non-null when data was cached before the failure } ``` `previousData` carries the last successfully fetched value. This enables **graceful degradation**: show stale data alongside an error indicator rather than replacing the entire view. ```dart [user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => api.getUser(userId), builder: (context, state, fetchStatus) => switch (state) { Initial() => const SizedBox.shrink(), Loading(:final previousData) => previousData != null ? Stack(children: [UserCard(previousData), const LoadingOverlay()]) : const CircularProgressIndicator(), Success(:final data) => UserCard(data), Failure(:final error, :final previousData) => previousData != null ? Column(children: [ UserCard(previousData), ErrorBanner('Failed to refresh: $error'), ]) : ErrorScreen('$error'), }, ) ``` --- ## Retry Configuration Qora retries failed fetches automatically with exponential backoff. Control this per query via `QoraOptions`: ```dart [main.dart] QoraOptions( retryCount: 3, // Default: 3: set 0 to disable retryDelay: const Duration(seconds: 1), // Base delay; doubles each attempt ) ``` **Default backoff schedule:** | Attempt | Delay | | ------- | ----- | | 1 | 1 s | | 2 | 2 s | | 3 | 4 s | ### Custom Delay ```dart [main.dart] QoraOptions( retryDelayCalculator: (attempt) => Duration( milliseconds: (500 * pow(2, attempt)).toInt(), ), ) ``` ### Disable Retries ```dart [main.dart] // Fail immediately: e.g. for mutations or real-time queries QoraOptions(retryCount: 0) ``` --- ## Selectively Skipping Retries To skip retries for specific error types (e.g. `401 Unauthorized` should never be retried): ```dart [main.dart] QoraOptions( retryCount: 3, retryDelayCalculator: (attempt) { // This won't be called if retryCount is effectively 0, // but you can gate retry logic in the fetcher itself: return Duration(seconds: 1 << attempt); }, ) ``` The recommended pattern is to throw a specific exception and catch it in the fetcher: ```dart [user_api.dart] Future getUser(int id) async { final response = await http.get(Uri.parse('/users/$id')); if (response.statusCode == 401) throw const UnauthorizedException(); if (response.statusCode == 404) throw const NotFoundException(); if (response.statusCode != 200) throw HttpException(response.statusCode); return User.fromJson(jsonDecode(response.body)); } ``` Then in `QoraClientConfig.errorMapper`, normalize errors before they reach the retry engine: ```dart [main.dart] QoraClientConfig( errorMapper: (error, stack) { if (error is UnauthorizedException) return error; // Don't wrap: let it surface return QoraException('$error', originalError: error, stackTrace: stack); }, ) ``` --- ## Global Error Handling Use `QoraClientConfig.errorMapper` to normalise all errors into a single type before they surface in `Failure.error`: ```dart [main.dart] final client = QoraClient( config: QoraClientConfig( errorMapper: (error, stackTrace) { if (error is DioException) { return switch (error.type) { DioExceptionType.connectionTimeout => const NetworkTimeoutError(), DioExceptionType.receiveTimeout => const NetworkTimeoutError(), _ => ApiError(error.response?.statusCode), }; } return error; }, ), ); ``` --- ## Background Fetch Errors When using `watchQuery`, background fetches run in `unawaited` futures. If the fetch fails, the error is stored in the `Failure` state, but the re-thrown exception would crash the current async `Zone`. Use `QoraClientConfig.onBackgroundFetchError` to safely intercept these errors for logging or crash reporting: ```dart [lib/core/qora.dart] final client = QoraClient( config: QoraClientConfig( onBackgroundFetchError: (error, stackTrace, key) { // Error already stored in Failure state; use this for reporting crashReporter.record(error, stackTrace); }, ), ); ``` The callback receives the error, stack trace, and the normalised query key. The error has already been stored in the query's `Failure` state, this callback is for observability only. --- ## Per-Tracker Error Observation Implement `QoraTracker.onQueryFetchFailed` to observe all query errors globally: useful for crash reporting or analytics: ```dart [analytics_tracker.dart] class AnalyticsTracker extends NoOpTracker { @override void onQueryFetchFailed(String key, Object error, StackTrace stackTrace) { Sentry.captureException(error, stackTrace: stackTrace); analytics.track('query_error', {'key': key, 'error': '$error'}); } } ``` --- ## Mutation Errors Mutation errors are surfaced on `MutationFailure`: ```dart [main.dart] QoraMutationBuilder( mutator: (payload) => api.updateUser(payload), options: MutationOptions( onMutate: (payload) async => QoraScope.of(context).getQueryData(['users', userId]), onError: (error, payload, snapshot) async { if (snapshot != null) { QoraScope.of(context).restoreQueryData(['users', userId], snapshot); } }, onSettled: (_, _, _, _) async => QoraScope.of(context).invalidate(['users', userId]), ), builder: (context, state, mutate) => switch (state) { MutationFailure(:final error) => ErrorBanner('$error'), _ => UpdateButton(onTap: () => mutate(payload)), }, ) ``` --- ## Offline Errors When `networkMode` is `NetworkMode.online` (default) and the device is offline: - `fetchQuery` throws `QoraOfflineException` if no cached data exists. - `watchQuery` emits `FetchStatus.paused`: the state stays at `Loading(previousData: ...)` if data was previously cached. - The fetch is replayed automatically when connectivity is restored. ```dart [main.dart] try { final data = await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), ); } on QoraOfflineException { showSnackBar('No internet connection'); } ``` --- ## Related - [QoraOptions](https://qora.meragix.dev/../api-reference/qora-options): `retryCount`, `retryDelay`, `retryDelayCalculator`, `networkMode` - [Network-Aware Queries](https://qora.meragix.dev/network-aware): offline behaviour, `NetworkMode`, `FetchStatus` - [Mutations Guide](https://qora.meragix.dev/mutations): `onError`, rollback # Testing Core principle: replace real I/O with deterministic fakes. Qora has no global state, and each `QoraClient` instance is isolated for deterministic test execution. --- ## Setup No extra packages are required for the core patterns. Add `mocktail` if you want to mock API classes: ```yaml [pubspec.yaml] dev_dependencies: mocktail: ^0.3.0 flutter_test: sdk: flutter ``` --- ## Unit Testing For `packages/qora` tests (no Flutter), use `QoraClient` directly: ```dart [test/queries_test.dart] import 'package:qora/qora.dart'; import 'package:test/test.dart'; void main() { late QoraClient client; setUp(() { client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions( retryCount: 0, // disable retries for fail-fast tests ), ), ); }); tearDown(() => client.dispose()); test('fetchQuery returns data on success', () async { final user = await client.fetchQuery( key: const ['users', 1], fetcher: () async => User(id: 1, name: 'Alice'), ); expect(user.name, 'Alice'); }); test('fetchQuery throws on fetcher error', () { expect( () => client.fetchQuery( key: const ['users', 99], fetcher: () async => throw Exception('not found'), options: const QoraOptions(retryCount: 0), ), throwsException, ); }); test('second fetchQuery returns cached data without calling fetcher', () async { var callCount = 0; await client.fetchQuery( key: const ['users', 1], fetcher: () async { callCount++; return User(id: 1, name: 'Alice'); }, options: const QoraOptions(staleTime: Duration(minutes: 5)), ); await client.fetchQuery( key: const ['users', 1], fetcher: () async { callCount++; return User(id: 1, name: 'Alice'); }, options: const QoraOptions(staleTime: Duration(minutes: 5)), ); expect(callCount, 1); // cache hit on second call }); } ``` --- ## Widget Testing: `QoraScope` Wrapper Wrap `pumpWidget` with `QoraScope`. Pass a custom client so each test is isolated: ```dart [test/user_screen_test.dart] import 'package:flutter_test/flutter_test.dart'; import 'package:qora_flutter/qora_flutter.dart'; import 'package:qora/qora.dart'; Widget buildTestApp({ required QoraClient client, required Widget child, }) { return QoraScope( client: client, child: MaterialApp(home: child), ); } void main() { testWidgets('UserScreen shows name after fetch', (tester) async { final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions(retryCount: 0), ), ); addTearDown(client.dispose); await tester.pumpWidget( buildTestApp( client: client, child: QoraBuilder( queryKey: const ['users', 1], fetcher: () async => User(id: 1, name: 'Alice'), builder: (context, state, _) => switch (state) { Initial() || Loading() => const CircularProgressIndicator(), Success(:final data) => Text(data.name), Failure(:final error) => Text('Error: $error'), }, ), ), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); await tester.pumpAndSettle(); // let the future resolve expect(find.text('Alice'), findsOneWidget); }); } ``` --- ## Pre-Seeding the Cache Use `setQueryData` to pre-populate the cache before mounting the widget. The `QoraBuilder` receives `Success` immediately: no async needed: ```dart [test/user_screen_test.dart] testWidgets('UserScreen renders pre-seeded data without fetching', (tester) async { final client = QoraClient(); addTearDown(client.dispose); // Seed the cache directly: widget gets Success on first frame client.setQueryData(const ['users', 1], User(id: 1, name: 'Bob')); await tester.pumpWidget( buildTestApp( client: client, child: QoraBuilder( queryKey: const ['users', 1], fetcher: () async => throw Exception('should not be called'), options: const QoraOptions(staleTime: Duration(minutes: 5)), builder: (_, state, __) => Text(state.dataOrNull?.name ?? 'loading'), ), ), ); // No pump needed: data was already in cache expect(find.text('Bob'), findsOneWidget); }); ``` --- ## Testing Error States Pass a fetcher that throws and check the `Failure` branch: ```dart [test/user_screen_test.dart] testWidgets('shows error message when fetch fails', (tester) async { final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions(retryCount: 0), // fail immediately ), ); addTearDown(client.dispose); await tester.pumpWidget( buildTestApp( client: client, child: QoraBuilder( queryKey: const ['users', 404], fetcher: () async => throw Exception('not found'), builder: (_, state, __) => switch (state) { Failure(:final error) => Text('Error: $error'), _ => const CircularProgressIndicator(), }, ), ), ); await tester.pumpAndSettle(); expect(find.textContaining('Error:'), findsOneWidget); }); ``` --- ## Testing `PersistQoraClient` Use `InMemoryStorageAdapter`: it satisfies the `StorageAdapter` interface with no file I/O, making persistence tests deterministic and fast: ```dart [test/persistence_test.dart] import 'package:qora/qora.dart'; import 'package:test/test.dart'; void main() { test('data survives a simulated restart', () async { final storage = InMemoryStorageAdapter(); await storage.init(); // ── First "launch" ──────────────────────────────────────────────────── final client1 = PersistQoraClient( storage: storage, persistDuration: const Duration(days: 7), ); client1.registerSerializer( QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson), name: 'User', ); await client1.hydrate(); await client1.fetchQuery( key: const ['user'], fetcher: () async => User(id: 1, name: 'Alice'), ); client1.dispose(); // ── Second "launch": same storage ──────────────────────────────────── final client2 = PersistQoraClient( storage: storage, persistDuration: const Duration(days: 7), ); client2.registerSerializer( QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson), name: 'User', ); await client2.hydrate(); // Data is in cache without any network call final state = client2.getQueryState(const ['user']); expect(state, isA>()); expect((state as Success).data.name, 'Alice'); client2.dispose(); }); } ``` --- ## Testing Optimistic Updates Verify that rollback restores the exact pre-mutation state: ```dart [test/optimistic_test.dart] test('rollback restores previous state on failure', () async { final client = QoraClient(); addTearDown(client.dispose); // Seed initial data client.setQueryData(const ['user'], User(id: 1, name: 'Alice')); final snapshot = client.getQueryData(const ['user']); // Apply optimistic update client.setQueryData(const ['user'], User(id: 1, name: 'Bob')); expect(client.getQueryState(const ['user']), isA>()); expect( (client.getQueryState(const ['user']) as Success).data.name, 'Bob', ); // Simulate failure: rollback client.restoreQueryData(const ['user'], snapshot); expect( (client.getQueryState(const ['user']) as Success).data.name, 'Alice', ); }); ``` --- ## Quick Reference | What to test | How | | -------------------------------------------- | --------------------------------------------------------------------------- | | Fetcher is called once (cache deduplication) | Count calls in a closure | | Data is cached across two calls | `staleTime > 0`, call twice, check `callCount == 1` | | Error state reaches the widget | Fetcher throws, `retryCount: 0`, `pumpAndSettle` | | Widget shows pre-seeded data | `setQueryData` before `pumpWidget` | | Persistence survives restart | Two `PersistQoraClient` instances sharing an `InMemoryStorageAdapter` | | Optimistic rollback | `getQueryData` → `setQueryData` → `restoreQueryData` → check state | | `refetchOnWindowFocus` | Don't wire `FlutterLifecycleManager` in tests: avoids unwanted side effects | --- ## Related - [Persistence Guide](https://qora.meragix.dev/persistence): `PersistQoraClient`, `InMemoryStorageAdapter`, serializers - [Optimistic Updates Guide](https://qora.meragix.dev/optimistic-updates): `setQueryData`, `getQueryData`, `restoreQueryData` - [QoraClient API](https://qora.meragix.dev/../api-reference/qora-client): full method reference # Route-Level Prefetching Route-level prefetching ensures data is already cached by the time a user navigates to a new screen. This guide covers GoRouter redirect-based prefetching, AutoRoute resolver-based prefetching, error handling strategies, and cache-first vs network-first trade-offs. --- ## Core API Qora provides three prefetch primitives, listed from most declarative to most imperative: | Primitive | When to use | | ----------------------------------- | ----------------------------------------------------------------------------------- | | `QoraPrefetch` (widget) | Wrapping a child that is already in the tree (e.g. hover, focus, scroll-into-view). | | `context.prefetch()` (extension) | Imperative trigger from a gesture callback or route event. | | `client.prefetch()` (core) | Any Dart context without a widget tree (e.g. route guards, resolvers). | All three are no-ops when fresh data already exists for the given `queryKey`. Prefetch runs once per key; repeat calls are safe and cheap. ```dart [lib/features/user_detail_screen.dart] // Widget-based: wrap a child that implies "likely to navigate" MouseRegion( onEnter: (_) => setState(() => _prefetch = true), child: _prefetch ? QoraPrefetch( queryKey: ['user', userId], fetcher: () => api.getUser(userId), child: UserListTile(userId: userId), ) : UserListTile(userId: userId), ) // Imperative: call from any callback GestureDetector( onLongPress: () => context.prefetch( key: ['user', userId], fetcher: () => api.getUser(userId), ), child: UserTile(userId), ) ``` --- ## GoRouter: Redirect-Based Prefetching GoRouter supports `redirect` callbacks that run before a route is activated. Use them to prefetch data synchronously (fire-and-forget), then let the route guard proceed immediately. By the time the screen widget mounts, its `QoraBuilder` reads from cache with zero network wait. ::tip The `redirect` callback runs synchronously. Prefetch is fire-and-forget: do **not** `await` the prefetch, or you will block the navigation transition. :: ### Implementation ```dart [lib/app_router.dart] import 'package:go_router/go_router.dart'; import 'package:qora_flutter/qora_flutter.dart'; final _client = QoraClient(); final appRouter = GoRouter( routes: [ GoRoute( path: '/users', builder: (_, __) => const UserListScreen(), routes: [ GoRoute( path: ':id', redirect: (context, state) { // Prefetch user detail while the list is still visible. // The redirect runs synchronously; navigation proceeds immediately. final userId = state.pathParameters['id']!; _client.prefetch( key: ['user', userId], fetcher: () => api.getUser(userId), ).ignore(); // fire-and-forget return null; // proceed to the route }, builder: (_, state) { final userId = state.pathParameters['id']!; return UserDetailScreen(userId: userId); }, ), ], ), ], ); ``` ### With error handling Prefetch failures should never block navigation. Wrap the fetcher in a try/catch and log errors for observability: ```dart [lib/app_router.dart] redirect: (context, state) { final userId = state.pathParameters['id']!; _client.prefetch( key: ['user', userId], fetcher: () async { try { return await api.getUser(userId); } catch (e, s) { log('Prefetch failed for user $userId', error: e, stackTrace: s); rethrow; // still captured by Qora's Failure state } }, ).ignore(); return null; }, ``` --- ## AutoRoute: Resolver-Based Prefetching AutoRoute uses `RouteGuard` or an `onNavigation` callback inside the router delegate. A resolver-based approach fires prefetch before the resolver returns the route. The route renders only after the prefetch is dispatched: ```dart [lib/app_router.dart] import 'package:auto_route/auto_route.dart'; import 'package:qora_flutter/qora_flutter.dart'; final _client = QoraClient(); @AutoRouterConfig() class AppRouter extends RootStackRouter { @override List get routes => [ AutoRoute(page: UserListRoute.page, path: '/users', children: [ AutoRoute( page: UserDetailRoute.page, path: ':id', guards: [PrefetchGuard()], ), ]), ]; } class PrefetchGuard extends AutoRouteGuard { @override Future onNavigation( NavigationResolver resolver, StackRouter router, ) async { final userId = resolver.route.pathParams.getString('id'); if (userId != null) { _client .prefetch( key: ['user', userId], fetcher: () => api.getUser(userId), ) .ignore(); } // Never block: resolve immediately, data loads in background. resolver.next(); } } ``` ### With a timeout fallback If you want to wait a short grace period (e.g. 200 ms) to give the prefetch a head start before resolving, but never block longer than that: ```dart [lib/guards/prefetch_guard.dart] class PrefetchGuard extends AutoRouteGuard { @override Future onNavigation( NavigationResolver resolver, StackRouter router, ) async { final userId = resolver.route.pathParams.getString('id'); if (userId != null) { final prefetch = _client.prefetch( key: ['user', userId], fetcher: () => api.getUser(userId), ); // Wait at most 200 ms for the prefetch to settle. try { await prefetch.timeout(const Duration(milliseconds: 200)); } catch (_) { // Timeout: resolver still proceeds, data loads on-screen. } } resolver.next(); } } ``` --- ## Strategy: Cache-First vs Network-First | Strategy | Behaviour | Use when | | ------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | **Cache-first** (default) | Prefetch is a no-op if fresh data exists. First navigation after stale expiry triggers a fetch. | Data freshness window is known and acceptable. | | **Network-first** | Always fetch on navigation, bypassing the cache staleness check. | Data must be guaranteed fresh on every visit (e.g. financial dashboards). | ### Cache-first (default) The `client.prefetch()` method already implements cache-first: it checks the cache and returns immediately if fresh data exists. No additional configuration needed. ### Network-first Force a fetch by calling `fetchQuery` directly or by invalidating the key before prefetching: ```dart [lib/app_router.dart] redirect: (context, state) { final userId = state.pathParameters['id']!; // Always fetch, even if fresh data exists. _client.invalidate(['user', userId]); _client.fetchQuery( key: ['user', userId], fetcher: () => api.getUser(userId), ).ignore(); return null; }, ``` For a reusable pattern, set `staleTime` to `Duration.zero` in the query options so every `QoraBuilder` mount triggers a fresh fetch: ```dart [lib/features/user_detail_screen.dart] QoraBuilder( queryKey: ['user', userId], fetcher: () => api.getUser(userId), options: const QoraOptions(staleTime: Duration.zero), builder: (context, state, _) { ... }, ) ``` --- ## Using `QoraPrefetch` Inside a Route If you cannot (or prefer not to) modify your routing layer, embed `QoraPrefetch` at the widget level. This is the simplest approach when the navigable child is already in the tree: ```dart [lib/features/user_list_screen.dart] ListView.builder( itemCount: users.length, itemBuilder: (context, index) { final user = users[index]; return QoraPrefetch( queryKey: ['user', user.id], fetcher: () => api.getUser(user.id), child: ListTile( title: Text(user.name), onTap: () => context.push('/users/${user.id}'), ), ); }, ) ``` Prefetch fires on mount (when the list item appears) and re-fires if `queryKey` changes. By the time the user taps, data is cached. --- ## Testing Prefetch Logic Use `QoraClient` directly in tests without any routing framework: ```dart [test/user_prefetch_test.dart] import 'package:qora/qora.dart'; import 'package:test/test.dart'; void main() { test('prefetch populates cache before screen mounts', () async { final client = QoraClient(); await client.prefetch( key: ['user', 1], fetcher: () async => User(id: 1, name: 'Alice'), ); // Screen widget now mounts and reads from cache. final state = client.getQueryState(['user', 1]); expect(state, isA>()); expect((state as Success).data.name, 'Alice'); }); } ``` --- ## Next Steps - [QoraClient.prefetch](https://qora.meragix.dev/../api-reference/qora-client): API reference for the core prefetch method - [Best Practices](https://qora.meragix.dev/../flutter-integration/best-practices): Additional prefetch patterns (hover, focus, scroll-into-view) - [Dependent Queries](https://qora.meragix.dev/dependent-queries): Chain queries that depend on prefetched data - [Cancellation](https://qora.meragix.dev/cancel-token): Cancel in-flight prefetches on rapid navigation - [Testing](https://qora.meragix.dev/testing): Write deterministic tests for prefetch logic # QoraClient ## Constructor ```dart [main.dart] QoraClient({QoraClientConfig? config}) ``` Creates a new client instance. All queries share the same cache and deduplication map. ```dart [main.dart] // Minimal (all defaults) final client = QoraClient(); // With full configuration final client = QoraClient( config: QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), retryCount: 2, ), debugMode: kDebugMode, maxCacheSize: 200, ), ); ``` --- ## Properties ### `config` ```dart final QoraClientConfig config ``` The resolved configuration for this client instance. Read-only. ### `cachedKeys` ```dart Iterable> get cachedKeys ``` All currently cached query keys (normalised). Useful for debugging or bulk invalidation. ```dart [main.dart] for (final key in client.cachedKeys) { print(key); // [users, 1], [posts], ... } ``` --- ## Fetching ### `fetchQuery` ```dart Future fetchQuery({ required Object key, required Future Function() fetcher, QoraOptions? options, CancelToken? cancelToken, }) ``` Fetch data once and return the result. **Cache behaviour:** | Cache state | Action | | ----------------- | ------------------------------------------------------- | | Fresh (not stale) | Return cached data: no network call | | Stale | Return stale data immediately; revalidate in background | | Missing | Fetch, await, and return result | Concurrent calls with the same key are **deduplicated**: all share a single in-flight request. Failed fetches are retried with exponential backoff up to `retryCount` times. ```dart [user_repository.dart] final user = await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions(staleTime: Duration(minutes: 5)), ); ``` **Throws:** - `StateError` if `QoraOptions.enabled` is `false` - `StateError` if `QoraOptions.dependsOn` is set and the dependency has no data yet - `QoraCancelException` if `cancelToken` is cancelled before or during the fetch - The fetcher's exception (after all retries) on failure --- ### `watchQuery` ```dart Stream> watchQuery({ required Object key, required Future Function() fetcher, QoraOptions? options, CancelToken? cancelToken, }) ``` Create a reactive `Stream>` for a query. **On subscription:** 1. Emits the current cached state immediately 2. Triggers a fetch if data is missing or stale (per `refetchOnMount`) 3. Sets up a polling timer if `refetchInterval` is set 4. Pushes all future state changes: from any source **On cancel (last subscriber):** - Cancels the polling timer - Schedules a GC timer for the cache entry ```dart [user_page.dart] StreamBuilder>( stream: client.watchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions(refetchInterval: Duration(seconds: 30)), ), 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) => ErrorWidget('$error'), }; }, ) ``` --- ### `prefetch` ```dart Future prefetch({ required Object key, required Future Function() fetcher, QoraOptions? options, CancelToken? cancelToken, }) ``` Pre-warm the cache without creating a stream. No-op if data is already fresh. ```dart [user_list_page.dart] onEnter: (_) => client.prefetch( key: ['users', userId], fetcher: () => api.getUser(userId), ), ``` --- ## Cache Reads ### `getQueryData` ```dart T? getQueryData(Object key) ``` Returns the best available data for `key`, or `null`. Returns `Success.data`, or `previousData` from `Loading`/`Failure`. ```dart [main.dart] final user = client.getQueryData(['users', userId]); ``` --- ### `getQueryState` ```dart QoraState getQueryState(Object key) ``` Returns the full `QoraState`, or `Initial()` if not cached. ```dart [main.dart] final state = client.getQueryState(['users', userId]); if (state is Success) { print('Age: ${state.age}'); } ``` --- ## Cache Writes ### `setQueryData` ```dart void setQueryData(Object key, T data) ``` Inject data directly into the cache. Instantly pushes `Success` to all active `watchQuery` streams. ```dart [main.dart] client.setQueryData(['users', userId], updatedUser); ``` --- ### `restoreQueryData` ```dart void restoreQueryData(Object key, T? snapshot) ``` Roll back to a previous snapshot. If `snapshot` is `null`, the entry is removed from cache. ```dart [main.dart] final snapshot = client.getQueryData(['users', userId]); client.setQueryData(['users', userId], optimisticUser); try { await api.updateUser(userId, payload); } catch (_) { client.restoreQueryData(['users', userId], snapshot); } ``` --- ## Invalidation ### `invalidate` ```dart void invalidate(Object key) ``` Mark a query as stale. Active `watchQuery` streams receive `Loading(previousData: ...)` immediately. The next access triggers a fresh fetch. ```dart [main.dart] await api.createPost(payload); client.invalidate(['posts']); ``` --- ### `invalidateWhere` ```dart void invalidateWhere(bool Function(List key) predicate) ``` Invalidate all queries whose key satisfies `predicate`. ```dart [main.dart] // All user queries client.invalidateWhere((key) => key.firstOrNull == 'users'); // Specific user + their sub-resources client.invalidateWhere( (key) => key.length >= 2 && key[0] == 'users' && key[1] == userId, ); ``` --- ### `invalidatePrefix` ```dart void invalidatePrefix(Object prefix) ``` Invalidate every query whose key starts with `prefix`. Segments are compared with the same deep equality as query keys (maps and nested lists match structurally). An empty prefix matches every query. Active watchers refetch. ```dart [main.dart] // ['posts'], ['posts', 1], ['posts', 1, 'comments'], ... client.invalidatePrefix(['posts']); // Every query under user 42 client.invalidatePrefix(['users', 42]); ``` ::note `invalidate(key)` is exact. Use `invalidatePrefix` when you want TanStack-style partial matching. :: --- ### `invalidateQueries` ```dart void invalidateQueries({required QueryFilter filter}) ``` Bulk invalidation with a `QueryFilter`: a richer predicate than `invalidateWhere` that also receives the current state and the options from the last successful fetch. ```dart typedef QueryFilter = bool Function( String key, QoraState state, QoraOptions? lastOptions, ); ``` ```dart [main.dart] // Invalidate every online-mode query not yet in Success client.invalidateQueries( filter: (key, state, opts) => opts?.networkMode == NetworkMode.online && state is! Success, ); // Invalidate all user queries whose data is older than 10 minutes client.invalidateQueries( filter: (key, state, opts) { if (state is! Success) return false; return key.contains('users') && DateTime.now().difference(state.updatedAt) > const Duration(minutes: 10); }, ); ``` --- ### `invalidateTags` ```dart void invalidateTags(List tags) ``` Invalidate all cached queries that provide at least one of the given tags. Queries register tags via `QoraOptions.providesTags`. Matching follows two rules: - **Wildcard**: `QueryTag('post')` matches every query that provides any tag with `type: 'post'`, regardless of `id`. - **Specific**: `QueryTag('post', '123')` matches only queries that provide a tag with `type: 'post'` **and** `id: '123'`. ```dart [main.dart] // Refetch all post queries client.invalidateTags([QueryTag('post')]); // Refetch a specific post + its author client.invalidateTags([QueryTag('post', postId), QueryTag('user', authorId)]); ``` See [QoraOptions.providesTags](https://qora.meragix.dev/qora-options#providestags) and [MutationOptions.invalidatesTags](https://qora.meragix.dev/qora-mutation#invalidatestags). --- ## Cache Removal ### `removeQuery` ```dart void removeQuery(Object key) ``` Remove an entry from cache and cancel any pending request. The next access starts from `Initial`. ```dart [main.dart] await api.deletePost(postId); client.removeQuery(['posts', postId]); ``` --- ### `clearCache` ```dart void clearCache() ``` Clear the in-memory cache only. Persisted storage is untouched. Use `clear()` to wipe both (if using persistence), or `clearStorage()` to wipe storage only. --- ### `clear` ```dart void clear() ``` Remove all cached queries and cancel all pending requests. For `QoraClient`, this wipes the in-memory cache (identical to `clearCache`). For `PersistQoraClient`, this wipes BOTH in-memory cache and storage. ```dart [main.dart] // On logout client.clear(); ``` --- ## Hydration ### `queueHydration` ```dart void queueHydration(Object key, dynamic data, {DateTime? updatedAt}) ``` Enqueue a pre-deserialized value for lazy injection into the typed cache. The value is injected on the first typed API call (`fetchQuery`, `watchQuery`, etc.) for that key, deferring the Dart cast until the type `T` is known. This is the shared mechanism used by `PersistQoraClient.hydrate()` and `SsrHydrator.hydrate()`. Direct use is for advanced scenarios (e.g. custom hydration sources). ```dart [main.dart] // Manually pre-populate the cache before runApp client.queueHydration(['users', 1], User(id: 1, name: 'Ada')); // With a custom timestamp so staleTime evaluates correctly client.queueHydration( ['config'], appConfig, updatedAt: DateTime.now().subtract(const Duration(minutes: 2)), ); ``` --- ### `hydrateQuery` ```dart void hydrateQuery(Object key, T data, {DateTime? updatedAt}) ``` Inject a typed `Success` directly into an `Initial` cache entry. No-op if the entry already has data. Used internally; prefer `queueHydration` when the type is not known at call time. --- ## Debugging ### `debugInfo` ```dart Map debugInfo() ``` Returns a debug snapshot of the current cache state. ```dart [main.dart] print(client.debugInfo()); // { // total_queries: 12, // active_queries: 3, // inactive_queries: 9, // pending_requests: 1, // max_size: 200, // keys: [['users', 1], ['posts'], ...] // } ``` --- ## Lifecycle ### `attachLifecycleManager` ```dart void attachLifecycleManager(LifecycleManager manager) ``` Registers a `LifecycleManager` with the client. When the app transitions to `LifecycleState.resumed`, the client revalidates all stale entries that have active subscribers. `QoraScope` calls this automatically when a `FlutterLifecycleManager` is provided. Direct use is only necessary in custom embedding scenarios. --- ### `dispose` ```dart void dispose() ``` Release all resources. Stops the eviction timer, disposes all cache entries, cancels pending requests. The client **must not** be used after this call. ```dart [main.dart] // In a DI teardown or test tearDown client.dispose(); ``` --- ## Recommended Setup ```dart [lib/core/qora.dart] import 'package:flutter/foundation.dart'; import 'package:qora/qora.dart'; /// Singleton: create once, inject everywhere via DI or QoraScope. final qoraClient = QoraClient( config: QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), retryCount: 3, ), debugMode: kDebugMode, maxCacheSize: 200, refetchOnMount: true, errorMapper: (error, stack) => QoraException( '$error', originalError: error, stackTrace: stack, ), ), ); ``` # QoraKey ## Overview Every Qora query is identified by a key. Qora accepts two equivalent formats: ```dart [main.dart] // Raw list: frictionless, no import needed key: ['users', userId] // QoraKey wrapper: explicit, typed key: QoraKey(['users', userId]) key: QoraKey.withId('users', userId) ``` Both are normalised to the same internal representation and compared with **deep structural equality**. They are fully interchangeable: you can mix them across calls to the same logical query. --- ## Constructors ### `QoraKey(List parts)` Direct constructor from a list of parts. ```dart [main.dart] QoraKey(['users', userId]) QoraKey(['posts', {'status': 'published', 'page': 1}]) ``` --- ### `QoraKey.single(String key)` Shorthand for a single-segment key. ```dart [main.dart] QoraKey.single('users') // equivalent to QoraKey(['users']) ``` --- ### `QoraKey.withId(String entity, dynamic id)` Shorthand for an entity + ID key. ```dart [main.dart] QoraKey.withId('users', userId) // ['users', userId] QoraKey.withId('posts', postId) // ['posts', postId] ``` --- ### `QoraKey.withFilter(String entity, Map filter)` Shorthand for an entity + filter map key. ```dart [main.dart] QoraKey.withFilter('posts', {'status': 'published', 'page': page}) // ['posts', {'status': 'published', 'page': page}] ``` --- ### `QoraKey.from(List parts)` Factory constructor that converts a raw list to a `QoraKey`. ```dart [main.dart] final key = QoraKey.from(['users', userId]); ``` --- ## Properties ### `parts` ```dart final List parts ``` The raw key segments as an unmodifiable list. ```dart [main.dart] final key = QoraKey.withId('users', 42); print(key.parts); // [users, 42] ``` --- ## Equality `QoraKey` uses **deep structural equality**: two keys are equal if their parts are recursively equal: ```dart [main.dart] QoraKey(['users', 1]) == QoraKey(['users', 1]) // true QoraKey(['users', 1]) == QoraKey(['users', 2]) // false // Raw list and QoraKey are normalised identically ['users', 1] == QoraKey(['users', 1]) // normalised to same cache entry ``` Supported types in key parts: | Type | Support | | ----------------------- | -------------------------------------------- | | `int`, `double`, `bool` | Full structural equality | | `String` | Full structural equality | | `null` | Supported as a key part "../5.api-reference" | | `List` | Recursively compared | | `Map` | Order-independent recursive comparison | | Custom objects | Must override `operator ==` and `hashCode` | ::warning Using custom objects (e.g. `UserFilter` ) in key parts without overriding `operator ==` and `hashCode` causes a new cache entry on every call. Use primitive-serialisable values or implement equality explicitly. :: --- ## `normalizeKey` ```dart List normalizeKey(Object key) ``` Top-level function that normalises any valid key input to an unmodifiable `List`: - Accepts `QoraKey` or `List` - Validates that parts are serialisable - Returns a deep-copied, unmodifiable list This is called internally by every `QoraClient` method. You rarely need to call it directly. ```dart [main.dart] final parts = normalizeKey(['users', 1]); // [users, 1] (unmodifiable) final parts = normalizeKey(QoraKey.withId('users', 1)); // same result ``` **Throws** `ArgumentError` if the key is not a `List` or `QoraKey`. --- ## Key Factories Pattern Centralise your key definitions to avoid typos and simplify invalidation: ```dart [lib/query_keys.dart] abstract class Keys { // Users static List users() => ['users']; static List user(int id) => ['users', id]; static List userPosts(int userId) => ['users', userId, 'posts']; // Posts static List posts(Map filters) => ['posts', filters]; static List post(int id) => ['posts', id]; } // Usage await client.fetchQuery( key: Keys.user(userId), fetcher: () => api.getUser(userId), ); client.invalidateWhere((key) => equalsKey(key, Keys.users())); ``` --- ## Best Practices ### Do ```dart [main.dart] // Consistent structure: entity first, then ID/filters ['users', userId] ['users', userId, 'posts'] ['posts', {'status': 'published'}] // Use primitives: always comparable ['users', userId.toString()] ['posts', {'page': page, 'limit': 20}] ``` ### Don't ```dart [main.dart] // Inconsistent ordering ['users', userId] [userId, 'users'] // Bad: Different cache entry // Custom objects without equality ['users', UserFilter(status: 'active')] // Bad: Identity equality: always a miss // Computed values that vary on each call ['posts', DateTime.now().toIso8601String()] // Bad: Never hits cache ``` # QoraState ## Class Hierarchy ```dart [lib/src/state/qora_state.dart] sealed class QoraState { ... } final class Initial extends QoraState { ... } final class Loading extends QoraState { ... } final class Success extends QoraState { ... } final class Failure extends QoraState { ... } ``` --- ## `QoraState`: Base ### Properties (available on all states) | Property | Type | Description | | ------------- | --------- | ------------------------------------------------------ | | `hasData` | `bool` | `true` for Success, or Loading/Failure with prior data | | `dataOrNull` | `T?` | Best available data across all states, or `null` | | `isInitial` | `bool` | `true` if `Initial` | | `isLoading` | `bool` | `true` if `Loading` | | `isSuccess` | `bool` | `true` if `Success` | | `isError` | `bool` | `true` if `Failure` | | `errorOrNull` | `Object?` | Error from `Failure`, or `null` | ### `map(R Function(T) transform)` Transform the data type while preserving state structure. `previousData` in `Loading`/`Failure` is also transformed. ```dart [main.dart] final nameState = userState.map((user) => user.name); // Success → Success ``` ### `when({onInitial, onLoading, onSuccess, onError})` Execute callbacks for the current state. All four callbacks are optional. ```dart [main.dart] state.when( onSuccess: (data, updatedAt) => print('Loaded: ${data.name}'), onError: (error, stackTrace, previousData) => showSnackBar('$error'), ); ``` ### `maybeWhen({required orElse, onInitial, onLoading, onSuccess, onError})` Map the state to a value of type `R`. Unhandled states fall back to `orElse`. ```dart [main.dart] final widget = state.maybeWhen( onSuccess: (data, _) => UserCard(data), orElse: () => const CircularProgressIndicator(), ); ``` --- ## `Initial` The query has not been fetched yet. Default state for any key that has never been loaded or has been removed from cache. ```dart [main.dart] const Initial() state.isInitial // true state.hasData // false state.dataOrNull // null ``` --- ## `Loading` A fetch is actively in progress. ### Fields | Field | Type | Description | | -------------- | ---- | ------------------------------------------ | | `previousData` | `T?` | Last successful data (null on first fetch) | ```dart [main.dart] Loading() // first fetch Loading(previousData: cachedUser) // background reload ``` ### Properties (extensions) | Property | Type | Description | | -------------- | ------ | ---------------------------------- | | `isFirstLoad` | `bool` | `true` when `previousData == null` | | `isRefreshing` | `bool` | `true` when `previousData != null` | | `hasData` | `bool` | `true` when `previousData != null` | ```dart [user_page.dart] case Loading(:final previousData): return previousData != null ? Stack(children: [UserCard(previousData), const LinearProgressIndicator()]) : const CircularProgressIndicator(); ``` --- ## `Success` Data was fetched successfully. ### Success: Fields | Field | Type | Description | | ----------- | ---------- | -------------------------------------- | | `data` | `T` | The fetched data | | `updatedAt` | `DateTime` | Timestamp of the last successful fetch | ### Properties and methods | Member | Type | Description | | ----------------------------- | ---------- | ------------------------------------- | | `age` | `Duration` | Time elapsed since `updatedAt` | | `isStale(Duration threshold)` | `bool` | `true` if `age > threshold` | | `successDataOrNull` (ext.) | `T?` | Returns `data` (null on other states) | ```dart [user_page.dart] case Success(:final data, :final updatedAt): return Column(children: [ UserCard(data), Text('Updated ${_timeAgo(updatedAt)}'), ]); ``` ### Factory ```dart [main.dart] Success.now(data) // equivalent to Success(data: data, updatedAt: DateTime.now()) ``` --- ## `Failure` The fetch failed after all retry attempts. ### Failure: Fields | Field | Type | Description | | -------------- | ------------- | --------------------------------------------------- | | `error` | `Object` | The thrown error | | `stackTrace` | `StackTrace?` | Optional stack trace | | `previousData` | `T?` | Last successful data (enables graceful degradation) | ```dart [user_page.dart] case Failure(:final error, :final previousData): return Column(children: [ if (previousData != null) ...[ UserCard(previousData), // show stale data const SizedBox(height: 8), ], ErrorBanner('$error'), ]); ``` --- ## Extensions on `QoraState` Exported from `package:qora/qora.dart` via `state_extensions.dart`. ### Data access ```dart [main.dart] state.requireData() // T: throws StateError if no data state.successDataOrNull // T?: data only from Success state.updatedAt // DateTime?: updatedAt from Success, null otherwise state.status // QoraStatus enum (initial/loading/success/error) ``` ### Mapping ```dart [main.dart] state.mapData((data) => data.name) // same as map: Success data and previousData state.fold( onInitial: () => 'not loaded', onLoading: (prev) => 'loading...', onSuccess: (data, _) => data.name, onError: (error, _, __) => 'error', ) ``` ### Combining ```dart [main.dart] // Two states → one combined state (Success only when both are Success) userState.combine(postsState, (user, posts) => UserWithPosts(user, posts)) // Lists and tuples QoraStateUtils.combineList([s1, s2, s3]) QoraStateUtils.combine2(userState, settingsState) QoraStateUtils.combine3(userState, postsState, tagsState) ``` --- ## Extensions on `Stream>` ```dart [main.dart] stream.whereHasData() // only states with data stream.whereSuccess() // only Success states stream.whereError() // only Failure states stream.dataOrNull() // Stream: null for no-data states stream.data() // Stream: skips no-data states stream.mapData((data) => transform(data)) // Stream> stream.delayLoading(Duration(milliseconds: 300)) ``` --- ## Serialization: `QoraStateCodec` ```dart [cache_storage.dart] final codec = QoraStateCodec( encode: (user) => user.toJson(), decode: (json) => User.fromJson(json as Map), ); // Encode final json = codec.encodeState(state); // Map // Decode final restored = codec.decodeState(json); // QoraState ``` --- ## `QoraStatus` Enum A simplified status enum for cases where the full sealed class is not needed: ```dart [main.dart] state.status // QoraStatus.initial | .loading | .success | .error state.status.isLoading // bool state.status.isSuccess // bool state.status.isError // bool ``` # QoraOptions ## Overview `QoraOptions` configures the behaviour of an individual query. Any field left at its default inherits from `QoraClientConfig.defaultOptions`. Fields are merged with per-query values taking priority. ```dart [user_repository.dart] await client.fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions( staleTime: Duration(minutes: 5), retryCount: 2, ), ); ``` --- ## Constructor ```dart const QoraOptions({ Duration staleTime = Duration.zero, Duration gcTime = const Duration(minutes: 5), bool enabled = true, Object? dependsOn, int retryCount = 3, Duration retryDelay = const Duration(seconds: 1), Duration Function(int attemptIndex)? retryDelayCalculator, bool Function(Object error, int attemptIndex)? retryCondition, bool refetchOnWindowFocus = true, bool refetchOnReconnect = true, Duration? refetchInterval, bool? refetchOnMount, NetworkMode networkMode = NetworkMode.online, Object? Function(Object? data)? toJson, }) ``` --- ## Fields ### `staleTime` ```dart final Duration staleTime ``` **Default:** `Duration.zero` How long data is considered **fresh** after a successful fetch. During the freshness window, `fetchQuery` and `watchQuery` skip the network entirely and return cached data. Once `staleTime` elapses the data is considered **stale**: Qora returns it immediately but also triggers a background revalidation. ```dart [main.dart] // Always stale (default): serve cache, always revalidate staleTime: Duration.zero, // Fresh for 5 minutes: no network in the window staleTime: Duration(minutes: 5), // Effectively never stale staleTime: Duration(days: 365), ``` | Data type | Recommended value | | ---------------------- | ----------------------- | | Live prices, feeds | `Duration.zero` | | User profile | `Duration(minutes: 5)` | | Article / post content | `Duration(minutes: 10)` | | App configuration | `Duration(hours: 1)` | | Static reference data | `Duration(days: 1)` | --- ### `gcTime` ```dart final Duration gcTime ``` **Default:** `Duration(minutes: 5)` How long an **inactive** entry stays in cache after all `watchQuery` subscribers cancel. When the GC timer fires the entry is removed; the next access starts from `Initial`. ```dart [main.dart] // Keep in cache for 10 minutes after leaving the screen gcTime: Duration(minutes: 10), // Remove immediately on unsubscribe (e.g. sensitive session data) gcTime: Duration.zero, ``` ::tip `staleTime` and `gcTime` are independent. A common pattern is `staleTime < gcTime` : data goes stale (triggering a revalidation) but stays in cache so navigation back is instant. :: --- ### `enabled` ```dart final bool enabled ``` **Default:** `true` When `false`: - `fetchQuery` throws a `StateError` immediately - `watchQuery` emits the current state without triggering any fetch Useful for **conditional queries** that should only run when a prerequisite is satisfied: ```dart [main.dart] // Only fetch when userId is non-null options: QoraOptions(enabled: userId != null), ``` --- ### `dependsOn` ```dart final Object? dependsOn ``` **Default:** `null` The key of another query that must have `Success` data before this query fires. Provides a reactive dependency without manual coordination: | API | Behaviour when dependency unresolved | | ------------ | -------------------------------------------------------------------------- | | `watchQuery` | Subscribes to the dependency stream; fires automatically when data arrives | | `fetchQuery` | Throws `StateError` immediately | | `prefetch` | Silently skips | ```dart [main.dart] // Step 1: fetch the session (no dependency) client.watchQuery( key: ['session'], fetcher: () => api.getSession(), ); // Step 2: fetch the user only once session has data client.watchQuery( key: ['user'], fetcher: () => api.getUser(session!.userId), options: QoraOptions(dependsOn: ['session']), ); ``` See the [Dependent Queries guide](https://qora.meragix.dev/../recipes/dependent-queries) for full examples. --- ### `retryCount` ```dart final int retryCount ``` **Default:** `3` Number of automatic retry attempts after a fetch failure. Set to `0` to disable retries. ```dart [main.dart] retryCount: 0, // fail immediately on first error retryCount: 5, // retry up to 5 times ``` --- ### `retryDelay` ```dart final Duration retryDelay ``` **Default:** `Duration(seconds: 1)` Base delay between retry attempts. By default Qora uses **exponential backoff**: - Attempt 0 → `1 s` - Attempt 1 → `2 s` - Attempt 2 → `4 s` - Attempt 3 → `8 s` --- ### `retryDelayCalculator` ```dart final Duration Function(int attemptIndex)? retryDelayCalculator ``` **Default:** `null` (uses exponential backoff) Custom delay calculator. Receives the zero-based attempt index and returns the delay before that attempt. ```dart [main.dart] // Fixed 500 ms delay retryDelayCalculator: (_) => const Duration(milliseconds: 500), // Jittered exponential backoff retryDelayCalculator: (i) => Duration( milliseconds: (500 * pow(2, i) + Random().nextInt(200)).toInt(), ), ``` --- ### `retryCondition` ```dart final bool Function(Object error, int attemptIndex)? retryCondition ``` **Default:** `null` (retry all failures up to `retryCount`) Predicate that decides whether a failed fetch should be retried. Receives the error and the zero-based attempt index that just failed. Return `false` to skip further retries. Useful to avoid retrying non-recoverable errors like 4xx HTTP responses: ```dart [main.dart] // Only retry network errors, not application errors retryCondition: (error, attempt) => error is SocketException, // Never retry 4xx responses (assuming you map them to a typed exception) retryCondition: (error, attempt) => error is! HttpException, // Retry only the first attempt, fail fast on subsequent failures retryCondition: (error, attempt) => attempt == 0, ``` When `retryCondition` returns `false`, all remaining retries for that error are skipped and the error is thrown immediately. --- ### `refetchOnWindowFocus` ```dart final bool refetchOnWindowFocus ``` **Default:** `true` Whether to refetch this query when the app regains window focus. Requires a `LifecycleManager` to be registered on the client. --- ### `refetchOnReconnect` ```dart final bool refetchOnReconnect ``` **Default:** `true` Whether to refetch this query when the network connection is restored. Requires a `ConnectivityManager` to be registered on the client. --- ### `refetchInterval` ```dart final Duration? refetchInterval ``` **Default:** `null` (disabled) When set, `watchQuery` automatically refetches at this cadence while at least one subscriber is active. The timer stops when the last subscriber cancels. ```dart [dashboard_page.dart] // Poll every 30 seconds while the widget is mounted options: QoraOptions(refetchInterval: Duration(seconds: 30)), ``` --- ### `refetchOnMount` ```dart final bool? refetchOnMount ``` **Default:** `null` (falls back to `QoraClientConfig.refetchOnMount`) Controls whether `watchQuery` triggers a fetch on the first subscription: | Value | Behaviour | | ------- | ------------------------------------------------------- | | `true` | Always refetch on mount, even if data is fresh | | `false` | Only fetch if data is missing; skip if cached | | `null` | Use `QoraClientConfig.refetchOnMount` (default: `true`) | --- ### `networkMode` ```dart final NetworkMode networkMode ``` **Default:** `NetworkMode.online` Controls how this query behaves when the device has no connectivity: | Value | Behaviour | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `NetworkMode.online` | Fetch is **paused** while offline (`FetchStatus.paused`). Replayed automatically on reconnect. | | `NetworkMode.always` | Fetch **always runs** regardless of connectivity. Use for local-database or in-memory fetchers. | | `NetworkMode.offlineFirst` | Cached data is returned immediately. A background revalidation is queued for when online. | ```dart [lib/data/user_repository.dart] // Default: pause while offline, replay on reconnect options: const QoraOptions(networkMode: NetworkMode.online), // Local DB query: always execute, never pause options: const QoraOptions(networkMode: NetworkMode.always), // Serve cache immediately, refresh in background when online options: const QoraOptions(networkMode: NetworkMode.offlineFirst), ``` ::tip Set `NetworkMode.always` for queries backed by SQLite, Hive, or any local store: they do not need connectivity and should never be paused. :: --- ### `structuralSharing` ```dart final bool structuralSharing ``` **Default:** `true` Preserves referential equality for unchanged nested data across fetches. When enabled, every successful fetch compares the new data against the existing cache using deep equality. If the data is structurally unchanged, the previous data reference is retained. This prevents unnecessary widget rebuilds: widgets that compare with `identical()` or `==` can skip re-rendering when the data hasn't actually changed. The comparison is recursive for `Map`, `List`, and `Set`. Custom model classes should override `==` and `hashCode` for reliable detection. Disable this only if you have very large data structures and the deep-equality check becomes a measured bottleneck: ```dart [main.dart] // Enabled (default): preserve references when data has not changed QoraOptions(structuralSharing: true), // Disabled: always create new references on every fetch QoraOptions(structuralSharing: false), ``` --- ### `keepPreviousData` ```dart final bool keepPreviousData ``` **Default:** `false` When `true`, the previous data stays visible while the next fetch is in progress, suppressing the `Success → Loading → Success` transition. The entry stays in `Success` state during the fetch and on fetch failure, so the UI never flashes a loading spinner. Useful for **offset-based pagination**: keep the current page visible while fetching the next one. ```dart [post_list.dart] // Page changes without a loading flash fetchQuery>( key: ['posts', 'page', page], fetcher: () => api.getPosts(page: page, limit: 20), options: QoraOptions( keepPreviousData: true, staleTime: Duration.zero, ), ); ``` Has no effect on the initial fetch (no previous data to keep). --- ### `transform` ```dart final Object Function(Object? data)? transform ``` **Default:** `null` Transform function applied to successful fetch data **before** it enters the cache. Use this to decouple data transformation from the fetcher: the fetcher returns raw data (e.g. JSON-decoded maps) and `transform` converts it into the final model. The transform runs before structural sharing, so changes to the transformed data are correctly detected against the cached entry. ```dart [user_repository.dart] // Fetcher returns raw JSON; transform converts it to a model class. fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), // returns Map options: QoraOptions( transform: (raw) => User.fromJson(raw as Map), ), ); ``` Both the fetcher and the transform are independently testable: the fetcher returns raw data, the transform is a pure function. --- ### `transformError` ```dart final Object Function(Object error)? transformError ``` **Default:** `null` Transform function applied to fetch errors before they enter the cache. Receives the raw error thrown by the fetcher (after all retries). Use it to normalise errors across your API layer or to enrich them with domain context. Runs **before** [QoraClientConfig.errorMapper](https://qora.meragix.dev/../api-reference/qora-client#config), so the per-query transform takes precedence for specific queries before the global mapper. ```dart [post_repository.dart] fetchQuery( key: ['post', postId], fetcher: () => api.getPost(postId), options: QoraOptions( transformError: (error) => switch (error) { DioException(e: final err) => AppError.fromDio(err), _ => AppError.unknown(error.toString()), }, ), ); ``` --- ### `providesTags` ```dart final List? providesTags ``` **Default:** `null` Tags that this query provides for tag-based cache invalidation. When a mutation invalidates a matching tag (via `MutationOptions.invalidatesTags`), all queries that provide that tag are automatically refetched, regardless of their query key. This decouples invalidation logic from query-key structure, making it easier to invalidate related queries across different key patterns: ```dart [post_repository.dart] // Query provides tags watchQuery( key: ['post', postId], fetcher: () => api.getPost(postId), options: QoraOptions( providesTags: [ QueryTag('post', postId.toString()), QueryTag('author', authorId.toString()), ], ), ); ``` #### Wildcard matching Invalidating `QueryTag('post')` (no id) refetches ALL queries that provide any tag of type `'post'` regardless of their individual ids. Invalidating `QueryTag('post', '123')` only refetches queries that provide the specific `'post:123'` tag. ```dart // Refetch ALL queries tagged with 'post' (any id). client.invalidateTags([QueryTag('post')]); // Refetch only queries tagged with 'post:123'. client.invalidateTags([QueryTag('post', '123')]); ``` --- ### `toJson` ```dart final Object? Function(Object? data)? toJson ``` **Default:** `null` Optional serializer that controls how query data is displayed in the DevTools overlay. When omitted, Qora applies the following resolution order automatically: 1. Data that is already `Map`, `List`, `String`, `num`, `bool`, or `null`: passed through as-is. 2. Any object with a `toJson()` method (`json_serializable`, `freezed`, manual): called dynamically. 3. Everything else: `.toString()` fallback. Provide `toJson` explicitly only when the automatic detection is insufficient, for example when your `toJson()` method has a non-standard signature or returns a non-encodable type. ```dart [user_repository.dart] // Explicit serializer (overrides auto-detection) fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions( toJson: (data) => (data as User).toJson(), ), ); ``` → See [Displaying Query Data in DevTools](https://qora.meragix.dev/en/devtools/data-display) for the full guide. --- ## `getRetryDelay(int attemptIndex)` ```dart Duration getRetryDelay(int attemptIndex) ``` Computes the retry delay for the given zero-based attempt index. Delegates to `retryDelayCalculator` if set, otherwise applies exponential backoff: `retryDelay × 2^attemptIndex`. --- ## `merge(QoraOptions? other)` ```dart QoraOptions merge(QoraOptions? other) ``` Returns a new `QoraOptions` merging `this` with `other`. Fields from `other` take priority. Nullable fields (`retryDelayCalculator`, `refetchInterval`, `refetchOnMount`) fall back to `this` when not set in `other`. Used internally by `QoraClient` to combine global defaults with per-query options. --- ## Common Recipes ```dart [main.dart] // Live data: always fresh, poll every 10 s QoraOptions( staleTime: Duration.zero, refetchInterval: Duration(seconds: 10), ) // Config data: cache aggressively QoraOptions( staleTime: Duration(hours: 1), gcTime: Duration(hours: 2), retryCount: 5, ) // Conditional query QoraOptions(enabled: currentUser != null) // Disable retries for mutations QoraOptions(retryCount: 0) ``` # QoraClientConfig ## Overview `QoraClientConfig` is passed to the `QoraClient` constructor and sets defaults for every query in the client's lifetime. Individual queries can override any field via `QoraOptions`. ```dart [lib/core/qora.dart] final client = QoraClient( config: QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), retryCount: 3, ), debugMode: kDebugMode, maxCacheSize: 200, refetchOnMount: true, errorMapper: (error, stack) => QoraException( '$error', originalError: error, stackTrace: stack, ), ), ); ``` --- ## Constructor ```dart const QoraClientConfig({ QoraOptions defaultOptions = const QoraOptions(), QoraException Function(Object error, StackTrace? stackTrace)? errorMapper, bool debugMode = false, int? maxCacheSize, void Function(List key)? onCacheEvict, void Function(Object error, StackTrace? stackTrace, List queryKey)? onBackgroundFetchError, bool refetchOnMount = true, ReconnectStrategy reconnectStrategy = const ReconnectStrategy(), }) ``` --- ## Fields ### `defaultOptions` ```dart final QoraOptions defaultOptions ``` **Default:** `QoraOptions()` (all defaults) The baseline `QoraOptions` applied to every query. Per-query options are merged on top using `QoraOptions.merge()`. ```dart [main.dart] defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), retryCount: 3, retryDelay: Duration(seconds: 1), ), ``` → See [QoraOptions](https://qora.meragix.dev/en/api-reference/qora-options) for all available fields. --- ### `errorMapper` ```dart final QoraException Function(Object error, StackTrace? stackTrace)? errorMapper ``` **Default:** `null` (errors are stored as-is) Transforms raw errors into `QoraException` before they are stored in `Failure` states. Use this to normalise API-specific error types into a consistent shape across your app: ```dart [main.dart] errorMapper: (error, stackTrace) { if (error is DioException) { return QoraException( error.message ?? 'Network error', originalError: error, stackTrace: stackTrace, ); } return QoraException('$error', originalError: error, stackTrace: stackTrace); }, ``` When set, all `Failure.error` values will be `QoraException` instances, giving you a single type to check in your error handlers. --- ### `debugMode` ```dart final bool debugMode ``` **Default:** `false` Enables verbose `print` logging to the console. Logs: - Cache hits (fresh and stale) - Cache misses - Fetch attempts and retry delays - Deduplication events - Lazy eviction and GC removals - `setQueryData` and `invalidate` calls - Client disposal ```dart [main.dart] // Enable only in debug builds debugMode: kDebugMode, ``` --- ### `maxCacheSize` ```dart final int? maxCacheSize ``` **Default:** `null` (unbounded) Maximum number of query entries in the cache. When the limit is reached, the **least-recently-used inactive** entry is evicted to make room. Active entries (those with `watchQuery` subscribers) are never forcibly evicted. ```dart [main.dart] maxCacheSize: 100, // cap memory usage in list-heavy apps ``` ::tip A good starting value for most apps is `100–200` . Monitor `client.debugInfo()['total_queries']` to tune this for your usage patterns. :: --- ### `onCacheEvict` ```dart final void Function(List key)? onCacheEvict ``` **Default:** `null` Callback invoked whenever a cache entry is evicted: via LRU pressure, GC timer, `removeQuery`, or `clear`. Receives the normalised key of the evicted entry. ```dart [main.dart] onCacheEvict: (key) { // Log or sync with persistent storage print('[Cache] evicted: $key'); persistentStore.delete(key.toString()); }, ``` --- ### `onBackgroundFetchError` ```dart final void Function(Object error, StackTrace? stackTrace, List queryKey)? onBackgroundFetchError ``` **Default:** `null` Callback invoked when a background fetch triggered by `watchQuery` fails. Background fetches run inside `unawaited` futures and without this callback the re-thrown error would crash the current async `Zone`. The error has **already been stored** in the query's `Failure` state by the time this callback fires. Use this callback for logging, telemetry, or crash reporting, not for error recovery. ```dart [lib/core/qora.dart] onBackgroundFetchError: (error, stackTrace, key) { crashReporter.record(error, stackTrace); }, ``` --- ### `refetchOnMount` ```dart final bool refetchOnMount ``` **Default:** `true` Global default for whether `watchQuery` should trigger a fetch on the first subscription, even when cached data already exists. - `true`: always refetch on mount (recommended for most apps) - `false`: only fetch if no data exists; skip if any cached state is present Can be overridden per query via `QoraOptions.refetchOnMount`. ```dart [main.dart] // Global: don't refetch if any data exists refetchOnMount: false, // Per-query override: always refetch this specific query options: QoraOptions(refetchOnMount: true), ``` --- ### `reconnectStrategy` ```dart final ReconnectStrategy reconnectStrategy ``` **Default:** `const ReconnectStrategy()` (5 concurrent, 100 ms jitter) Controls how `QoraClient` replays paused queries when connectivity is restored. Without batching, all paused queries would fire simultaneously: the "thundering herd" problem. `ReconnectStrategy` introduces concurrency limits and random jitter between batches: ```dart [lib/core/qora.dart] reconnectStrategy: const ReconnectStrategy( maxConcurrent: 5, // Replay 5 queries at a time jitter: Duration(milliseconds: 200), // Random wait between batches ), ``` Named constructors for common scenarios: ```dart [lib/core/qora.dart] // Default: balanced for most apps const ReconnectStrategy() // All queries replay at once (useful in tests) ReconnectStrategy.instant() // Conservative: 2 queries at a time, 500 ms jitter ReconnectStrategy.conservative() ``` | Named constructor | `maxConcurrent` | `jitter` | | ---------------------------------- | --------------- | -------- | | `ReconnectStrategy()` | 5 | 100 ms | | `ReconnectStrategy.instant()` | 999 | 0 ms | | `ReconnectStrategy.conservative()` | 2 | 500 ms | ::tip For apps that have many background queries (dashboards, feeds), use `ReconnectStrategy.conservative()` or lower `maxConcurrent` to avoid spiking your backend after a connectivity gap. :: --- ## Recommended Setup ```dart [lib/core/qora.dart] import 'package:flutter/foundation.dart'; import 'package:qora/qora.dart'; final qoraClient = QoraClient( config: QoraClientConfig( defaultOptions: const QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), retryCount: 3, retryDelay: Duration(seconds: 1), networkMode: NetworkMode.online, ), debugMode: kDebugMode, maxCacheSize: 200, refetchOnMount: true, onBackgroundFetchError: (error, stackTrace, key) => crashReporter.record(error, stackTrace), reconnectStrategy: const ReconnectStrategy( maxConcurrent: 5, jitter: Duration(milliseconds: 100), ), ), ); ``` For Flutter apps, pass this client to `QoraScope` at the root of your widget tree so all widgets can access it via `context.qora`. → See [Network Aware Guide](https://qora.meragix.dev/en/recipes/network-aware) for the full offline setup walkthrough. # Mutation API ## `MutationController` The core lifecycle controller for a single mutation. | Type parameter | Role | | -------------- | ------------------------------------------------------- | | `TData` | The value returned by the mutator on success | | `TVariables` | The value passed to `mutate()` | | `TContext` | The snapshot returned by `onMutate` (used for rollback) | ### Constructor ```dart MutationController({ required MutatorFunction mutator, MutationOptions? options, MutationTracker? tracker, Map? metadata, }) ``` ```dart [post_repository.dart] final controller = MutationController?>( mutator: (title) => api.createPost(title), options: MutationOptions( onMutate: (title) async => client.getQueryData(['posts']), onError: (err, _, prev) async => client.restoreQueryData(['posts'], prev), onSuccess: (_, __, ___) async => client.invalidate(['posts']), ), tracker: client, metadata: {'category': 'content', 'resource': 'post'}, ); ``` ### Properties #### `id` ```dart final String id ``` Unique identifier for this controller instance. Format: `mutation_N` (monotonically increasing). Used as the key in `QoraClient.activeMutations` and `MutationUpdate.mutatorId`. #### `mutator` ```dart final MutatorFunction mutator ``` The async function that performs the write. Named `mutator` to mirror `fetcher` in `fetchQuery`. #### `options` ```dart final MutationOptions? options ``` Lifecycle callbacks and retry configuration. See `MutationOptions` below. #### `tracker` ```dart final MutationTracker? tracker ``` Optional tracker (typically `QoraClient`) that receives state-change notifications for DevTools observability. When `null`, the controller operates in standalone mode. #### `metadata` ```dart final Map? metadata ``` Arbitrary key-value pairs forwarded verbatim to every `MutationUpdate` emitted by this controller. Use for DevTools labelling without modifying the core schema. #### `state` ```dart MutationState get state ``` The current synchronous state snapshot. #### `stream` ```dart Stream> get stream ``` A per-subscriber stream that **immediately** emits the current state on listen (synchronous capture), then forwards every subsequent transition. Supports multiple concurrent subscribers. Each subscription gets its own stream backed by the shared broadcast controller: no events are lost between `listen()` and the first microtask. ### Methods #### `mutate` ```dart Future mutate(TVariables variables) ``` Execute the mutation. State sequence: 1. Calls `onMutate(variables)` → result becomes `TContext` 2. → `MutationPending` 3. Calls `mutator(variables)` (with optional retry) 4. → `MutationSuccess` + `onSuccess` + `onSettled`, or → `MutationFailure` + `onError` + `onSettled` Returns `TData` on success, `null` on failure. Errors are **never** re-thrown to the caller: they are captured in `MutationFailure` state. **Throws** `StateError` if the controller has been disposed. #### `reset` ```dart void reset() ``` Transition back to `MutationIdle`. Also removes this controller from `tracker.activeMutations`. **Throws** `StateError` if the controller has been disposed. #### `dispose` ```dart void dispose() ``` Release all resources and close the internal stream. Removes the controller from the tracker silently (no event emitted). Must be called when the controller is no longer needed. After `dispose`, any call to `mutate` or `reset` throws `StateError`. --- ## `MutationOptions` Per-mutation lifecycle callbacks and retry configuration. ### Constructor ```dart const MutationOptions({ Future Function(TVariables variables)? onMutate, Future Function(TData data, TVariables variables, TContext? context)? onSuccess, Future Function(Object error, TVariables variables, TContext? context)? onError, Future Function(TData? data, Object? error, TVariables variables, TContext? context)? onSettled, int retryCount = 0, Duration retryDelay = const Duration(seconds: 1), List? invalidates, }) ``` ### Properties #### `onMutate` ```dart Future Function(TVariables variables)? onMutate ``` Called before the mutator. Returns `TContext`: the optimistic snapshot passed to `onError`/`onSettled` for rollback. If this callback throws, the mutator is **not called** and the controller transitions directly to `MutationFailure`. #### `onSuccess` ```dart Future Function(TData data, TVariables variables, TContext? context)? onSuccess ``` Called after a successful mutator, before `onSettled`. #### `onError` ```dart Future Function(Object error, TVariables variables, TContext? context)? onError ``` Called after the mutator throws (after all retries), before `onSettled`. Use `context` (the snapshot from `onMutate`) to roll back via `client.restoreQueryData`. #### `onSettled` ```dart Future Function(TData? data, Object? error, TVariables variables, TContext? context)? onSettled ``` Called after every outcome: success or failure. `data` is non-null on success; `error` is non-null on failure. #### `retryCount` ```dart final int retryCount ``` Number of retry attempts after the initial failure. Default: `0` (no retry). Each retry uses exponential backoff: `retryDelay * 2^attempt`. #### `retryDelay` ```dart final Duration retryDelay ``` Base delay for the first retry. Default: `Duration(seconds: 1)`. Subsequent retries double: `1s, 2s, 4s, …` #### `invalidates` ```dart final List? invalidates ``` List of query keys to automatically invalidate after a successful mutation. Each key is passed to `QoraClient.invalidate` after `onSuccess` completes, triggering a refetch of any active queries whose key matches. Replaces the common manual pattern: ```dart // Before: manual invalidation inside onSuccess onSuccess: (data, vars, _) async { client.invalidate(['posts']); } // After: automatic via options invalidates: [['posts']], ``` Works with offline queue replay too: queries are invalidated on successful replay, not on the initial optimistic emit. Requires a `QoraClient` or an `invalidateQuery` callback wired via `MutationController`. Both `QoraMutationBuilder` and `useMutation` provide this automatically when a `QoraScope` ancestor is present. #### `invalidatesTags` ```dart final List? invalidatesTags ``` **Default:** `null` Tags to invalidate after a successful mutation. Every query that provides a matching tag is automatically refetched. Works alongside `invalidates` (which targets query keys directly) and is processed after it. Wildcard matching applies: invalidating `QueryTag('post')` refetches ALL queries that provide any tag of type `'post'`, regardless of id. ```dart // Refetch ALL queries providing a 'post' tag (any id). MutationOptions( invalidatesTags: [QueryTag('post')], ) // Refetch only queries providing the specific 'post:123' tag. MutationOptions( invalidatesTags: [QueryTag('post', '123')], ) ``` Requires a `QoraClient` or an `invalidateTags` callback wired via `MutationController`. Both `QoraMutationBuilder` and `useMutation` provide this automatically when a `QoraScope` ancestor is present. --- ## `MutationState` Sealed class hierarchy representing all possible states of a mutation. ```text sealed MutationState ├── MutationIdle ├── MutationPending { variables } ├── MutationSuccess { data, variables } └── MutationFailure { error, stackTrace?, variables } ``` ### Common Getters Available on every variant: | Getter | Type | Description | | ----------------- | ------------- | ---------------------------------- | | `isIdle` | `bool` | `true` in `MutationIdle` | | `isPending` | `bool` | `true` in `MutationPending` | | `isSuccess` | `bool` | `true` in `MutationSuccess` | | `isError` | `bool` | `true` in `MutationFailure` | | `dataOrNull` | `TData?` | Non-null only in `MutationSuccess` | | `errorOrNull` | `Object?` | Non-null only in `MutationFailure` | | `variablesOrNull` | `TVariables?` | `null` only in `MutationIdle` | ### `when` ```dart void when({ void Function()? onIdle, void Function(TVariables variables)? onPending, void Function(TData data, TVariables variables)? onSuccess, void Function(Object error, StackTrace? stackTrace, TVariables variables)? onError, }) ``` Call a callback for the matching variant. Unhandled variants are silently ignored. ### `maybeWhen` ```dart R maybeWhen({ required R Function() orElse, R Function()? onIdle, R Function(TVariables)? onPending, R Function(TData, TVariables)? onSuccess, R Function(Object, StackTrace?, TVariables)? onError, }) ``` Returns the value from the matched callback, or `orElse()` if none matched. ### `fold` (extension) ```dart R fold({ required R Function() onIdle, required R Function(TVariables) onPending, required R Function(TData, TVariables) onSuccess, required R Function(Object, StackTrace?, TVariables) onError, }) ``` Exhaustive mapper: the compiler enforces that every variant is handled. ```dart [create_post_screen.dart] final label = state.fold( onIdle: () => 'Create', onPending: (_) => 'Creating…', onSuccess: (post, _) => 'Created: ${post.title}', onError: (err, _, __) => 'Error: $err', ); ``` ### `status` (extension) ```dart MutationStatus get status ``` Returns the coarse-grained `MutationStatus` enum value: `idle | pending | success | error`. --- ## `MutationStatus` ```dart enum MutationStatus { idle, pending, success, error } ``` Coarse-grained status used by `MutationUpdate` to avoid generic type erasure issues at the event bus level. --- ## `MutationUpdate` Type-erased snapshot of a mutation state transition. Emitted by `QoraClient.mutationEvents` on every `MutationController` state change. ### Properties | Property | Type | Description | | ----------- | ----------------------- | ------------------------------------------------- | | `mutatorId` | `String` | Unique controller identifier (`mutation_N`) | | `status` | `MutationStatus` | Coarse-grained status at emission time | | `data` | `Object?` | Success data (type-erased). Non-null on success. | | `error` | `Object?` | Error. Non-null on failure. | | `variables` | `Object?` | Variables passed to `mutate()`. `null` when idle. | | `timestamp` | `DateTime` | When this event was emitted | | `metadata` | `Map?` | Forwarded from `MutationController.metadata` | ### Getters | Getter | Type | Description | | ------------ | ------ | ---------------------------------------------------------------------------- | | `isIdle` | `bool` | `status == MutationStatus.idle` | | `isPending` | `bool` | `status == MutationStatus.pending` | | `isSuccess` | `bool` | `status == MutationStatus.success` | | `isError` | `bool` | `status == MutationStatus.error` | | `isFinished` | `bool` | `isSuccess || isError`: used by `QoraClient` to auto-purge `activeMutations` | --- ## `MutationTracker` Abstract interface implemented by `QoraClient`. Decouples `MutationController` from the client to avoid circular imports. ```dart abstract interface class MutationTracker { void trackMutation( String id, MutationState state, { Map? metadata, }); void untrackMutation(String id); } ``` Implement `MutationTracker` to build custom DevTools, logging middleware, or test spies: ```dart [mutation_logger.dart] class MutationLogger implements MutationTracker { @override void trackMutation( String id, MutationState state, { Map? metadata, }) { print('[$id] → ${state.runtimeType} ${metadata ?? ''}'); } @override void untrackMutation(String id) => print('[$id] disposed'); } ``` --- ## `QoraClient`: Mutation Observability `QoraClient` implements `MutationTracker` and exposes two surfaces for observing tracked mutations. ### `mutationEvents` ```dart Stream get mutationEvents ``` Real-time broadcast stream of every `MutationUpdate` from all tracked controllers. Emits on every state transition: including `MutationIdle` (reset) and `MutationFailure`. ```dart [app_error_handler.dart] client.mutationEvents.listen((event) { if (event.isError) { logger.error( '[${event.mutatorId}] Mutation failed', event.error, event.metadata, ); } }); ``` ::tip `mutationEvents` is a broadcast stream: events fired before your subscription are lost. To catch mutations that started before you subscribed, read `activeMutations` first. :: ### `activeMutations` ```dart Map get activeMutations ``` Unmodifiable snapshot of all **currently running** (pending) mutations, keyed by `MutationController.id`. - Entry **added** when a controller transitions to `MutationPending` - Entry **auto-purged** when the mutation finishes (`MutationSuccess` or `MutationFailure`) - Entry **removed** when the controller resets to `MutationIdle` or is disposed ```dart [devtools_panel.dart] // DevTools late-connect pattern: // 1. Read snapshot to see what is currently in-flight final pending = client.activeMutations; // 2. Subscribe to stream for real-time updates (including completions) client.mutationEvents.listen((event) { devtools.push(event); }); ``` `activeMutations` never accumulates "ghost" entries for completed mutations: it is always a reliable "currently running" indicator. ### `debugInfo` `QoraClient.debugInfo()` includes mutation state in its output: ```dart [main.dart] print(client.debugInfo()); // { // total_queries: 5, // active_queries: 2, // inactive_queries: 3, // pending_requests: 0, // max_size: 100, // active_mutations: 1, // ← currently pending mutations // keys: [...] // } ``` --- ## `MutatorFunction` ```dart typedef MutatorFunction = Future Function(TVariables variables); ``` Type alias for the mutation function. Mirrors `QueryFunction` for consistency with the `fetcher`/`mutator` naming convention throughout the API. --- ## Next Steps - [Mutations Guide](https://qora.meragix.dev/../recipes/mutations): Conceptual guide with all patterns - [QoraMutationBuilder](https://qora.meragix.dev/../flutter-integration/qora-mutation-builder): Flutter widget reference - [QoraClient](https://qora.meragix.dev/qora-client): Full client API reference # PersistQoraClient `PersistQoraClient` extends `QoraClient`. Every method from the base client works unchanged. This page documents the additional persistence API. See the [Persistence guide](https://qora.meragix.dev/../recipes/persistence) for a walkthrough with full examples. --- ## PersistQoraClient ### Constructor ```dart PersistQoraClient({ required StorageAdapter storage, Duration persistDuration = const Duration(days: 7), QoraClientConfig? config, QoraTracker? tracker, }) ``` | Parameter | Description | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `storage` | The storage backend. Must be initialised via `StorageAdapter.init()` before calling `hydrate()`. | | `persistDuration` | Default TTL for persisted entries. If `null`, entries are persisted indefinitely. Pass `Duration.zero` to disable persistence (entries expire immediately). | | `config` | Forwarded to `QoraClient`. | | `tracker` | Forwarded to `QoraClient`. | ```dart [main.dart] final client = PersistQoraClient( storage: InMemoryStorageAdapter(), persistDuration: const Duration(days: 7), config: const QoraClientConfig( defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)), debugMode: true, ), ); ``` --- ### `registerSerializer` ```dart void registerSerializer( QoraSerializer serializer, { String? name, }) ``` Register a `QoraSerializer` for type `T`. | Parameter | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `serializer` | Provides `toJson` / `fromJson` converters for `T`. | | `name` | The stable string identifier stored as a type discriminator on disk. Defaults to `T.toString()`. **Pass an explicit value on Flutter Web or when `--obfuscate` is active.** | - Calling `registerSerializer` twice for the same `T` or the same `name` replaces the previous entry. - Must be called **before** `hydrate()`. ```dart [main.dart] client.registerSerializer( QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson), name: 'User', ); client.registerSerializer>( QoraSerializer( toJson: (posts) => posts.map((p) => p.toJson()).toList(), fromJson: (json) => (json as List).map((e) => Post.fromJson(e)).toList(), ), name: 'List', ); ``` --- ### `hydrate` ```dart Future hydrate() ``` Load all valid persisted entries from `StorageAdapter` into a pending hydration queue. Entries are injected into the typed cache **lazily**: on the first call to `fetchQuery`, `watchQuery`, `getQueryData`, etc., for that key. The restored `Success` state carries the original `persistedAt` as `updatedAt`, so `staleTime` evaluates freshness correctly. **Per-entry behaviour:** | Condition | Action | | ------------------------------------- | ------------------------------ | | JSON decode failure | Delete from storage, skip | | TTL expired | Delete from storage, skip | | No serializer registered for the type | Log (if `debugMode`), skip | | `fromJson` throws | Delete from storage, log, skip | | Valid | Queue in pending hydration map | Call once at startup, after all serializers are registered and before `runApp`: ```dart [main.dart] await storage.init(); client.registerSerializer(...); await client.hydrate(); // ← before runApp runApp(...); ``` --- ### `persistQuery` ```dart Future persistQuery(Object key, {Duration? ttl}) ``` Force-persist the current cached value for `key`. No-op if the query is not in `Success` state or `T` has no registered serializer. `ttl` overrides `persistDuration` for this specific entry. ```dart [main.dart] // Persist with a short TTL (e.g. for sensitive data) await client.persistQuery(['users', userId], ttl: const Duration(hours: 1)); ``` --- ### `evictFromStorage` ```dart Future evictFromStorage(Object key) ``` Delete the persisted entry for `key` from `StorageAdapter`. The in-memory cache is **not** affected. Use `removeQuery` to remove from both cache and storage simultaneously. ```dart [main.dart] await client.evictFromStorage(['users', userId]); ``` --- ### `clearStorage` ```dart Future clearStorage() ``` Delete every entry Qora wrote to the `StorageAdapter`. Keys Qora did not write (app settings in a shared Hive box, for example) are left untouched; call `clear()` on the adapter itself to wipe everything. The in-memory cache is **not** affected. Use `clear()` to wipe both in-memory cache and storage simultaneously. --- ### `removeQuery` (override) Calls `super.removeQuery(key)` **and** deletes the entry from `StorageAdapter`. ```dart [main.dart] // On delete: removes from cache and storage await api.deleteUser(userId); client.removeQuery(['users', userId]); ``` --- ### `clear` (override) Calls `super.clear()` **and** calls `clearStorage()`. ```dart [main.dart] // On logout: wipes everything client.clear(); ``` --- ### `hydrateQuery` (from `QoraClient`) ```dart void hydrateQuery(Object key, T data, {DateTime? updatedAt}) ``` Pre-populate the cache with a value. Inserts a `Success` state with `updatedAt` (defaults to `DateTime.now()`). No-op if the entry is already in a non-`Initial` state. This is used internally by `PersistQoraClient` but is also available for advanced use cases (e.g. pre-loading from a custom source). ```dart [main.dart] client.hydrateQuery( ['users', userId], cachedUser, updatedAt: DateTime.now().subtract(const Duration(minutes: 3)), ); ``` --- ## QoraSerializer\ ```dart class QoraSerializer { const QoraSerializer({ required dynamic Function(T value) toJson, required T Function(dynamic json) fromJson, }); } ``` A pair of conversion functions for type `T`. | Property | Type | Description | | ---------- | --------------------- | ------------------------------------------------------------------------------------------- | | `toJson` | `dynamic Function(T)` | Converts `T` to a JSON-encodable value (`Map`, `List`, `String`, `num`, `bool`, or `null`). | | `fromJson` | `T Function(dynamic)` | Restores `T` from the value previously produced by `toJson`. | Both functions use `dynamic` (not `Map`) so that primitives, lists, and maps are all supported. ```dart [main.dart] // Object final userSerializer = QoraSerializer( toJson: (u) => u.toJson(), // returns Map fromJson: User.fromJson, // accepts Map ); // Primitive final intSerializer = QoraSerializer( toJson: (n) => n, // returns int directly fromJson: (json) => json as int, ); ``` --- ## StorageAdapter ```dart abstract interface class StorageAdapter { Future init(); Future read(String key); Future write(String key, String value); Future delete(String key); Future> keys(); Future clear(); Future dispose(); } ``` A minimal key/value contract. `PersistQoraClient` manages all serialisation; adapters only handle opaque JSON strings. | Method | Description | | ------------------- | --------------------------------------------------------------------- | | `init()` | Open the backend. Must be called and awaited before any other method. | | `read(key)` | Return the value for `key`, or `null` if absent. | | `write(key, value)` | Persist `value` under `key`, overwriting any existing value. | | `delete(key)` | Remove the entry. No-op if absent. | | `keys()` | Return all stored keys. Order is unspecified. | | `clear()` | Remove all entries. | | `dispose()` | Release resources (file handles, DB connections, etc.). | --- ## InMemoryStorageAdapter ```dart class InMemoryStorageAdapter implements StorageAdapter ``` A `Map`-backed adapter. All operations complete synchronously (wrapped in resolved `Future`s). **Data is lost when the process terminates.** Use for unit tests, integration tests, and CI environments where no real storage is needed. ```dart [test/persistence_test.dart] final adapter = InMemoryStorageAdapter(); await adapter.init(); final client = PersistQoraClient(storage: adapter); client.registerSerializer(userSerializer, name: 'User'); await client.hydrate(); // no-op on first run // ... run queries ... // Verify persistence final keys = await adapter.keys(); expect(keys, contains('["users",1]')); ``` --- ## Implementing a Custom Adapter ```dart [my_adapter.dart] import 'package:qora/qora.dart'; class MyAdapter implements StorageAdapter { @override Future init() async { // Open database / file } @override Future read(String key) async { // Return stored value or null } @override Future write(String key, String value) async { // Persist value under key } @override Future delete(String key) async { // Remove entry, no-op if absent } @override Future> keys() async { // Return all stored keys } @override Future clear() async { // Remove all entries } @override Future dispose() async { // Release resources } } ``` --- ## Storage Eviction Reference | Operation | In-memory cache | `StorageAdapter` | | ----------------------- | --------------- | ---------------- | | `invalidate(key)` | Marks stale | **Untouched** | | `removeQuery(key)` | Removed | Deleted | | `clearCache()` | Cleared | **Untouched** | | `clear()` | Cleared | Cleared | | `evictFromStorage(key)` | Untouched | Deleted | | `clearStorage()` | Untouched | Cleared | | `persistQuery(key)` | Untouched | Written | --- ## Related - [Persistence guide](https://qora.meragix.dev/../recipes/persistence): step-by-step walkthrough - [QoraClient API](https://qora.meragix.dev/qora-client): base client reference - [QoraOptions](https://qora.meragix.dev/qora-options): `staleTime`, `gcTime`, and other per-query settings # Infinite Query ## `InfiniteData` Immutable container holding all fetched pages and their corresponding page parameters. `TData` is the type of a single page (e.g. `List`). `TPageParam` is the type of the page parameter (e.g. `int`, `String?`). Both `pages` and `pageParams` are always the same length: `pages[i]` was fetched using `pageParams[i]`. ### Constructor ```dart const InfiniteData({ required List pages, required List pageParams, }) ``` Asserts that `pages.length == pageParams.length`. ### Properties ::collapsible | Property | Type | Description | | ------------ | ------------------ | ------------------------------------------------------------------ | | `pages` | `List` | All fetched pages in order: first page at index 0. | | `pageParams` | `List` | Parameters used to fetch each page (`pageParams[i]` → `pages[i]`). | | `pageCount` | `int` | Total number of pages currently loaded. | | `isEmpty` | `bool` | `true` when no pages have been loaded. | | `isNotEmpty` | `bool` | `true` when at least one page has been loaded. | :: ### Methods #### `flatten` ```dart List flatten(Iterable Function(TData page) selector) ``` Flattens all pages into a single list: the canonical way to convert paginated data for `ListView.builder`. ```dart // TData == List final posts = data.flatten((page) => page); // TData is a response object with a nested list final posts = data.flatten((page) => page.items); ``` #### `append` ```dart InfiniteData append(TData page, TPageParam param) ``` Returns a new `InfiniteData` with `page` and `param` added at the end. Used internally by `fetchNextPage`. #### `prepend` ```dart InfiniteData prepend(TData page, TPageParam param) ``` Returns a new `InfiniteData` with `page` and `param` added at the front. Used internally by `fetchPreviousPage`. #### `dropFirst` ```dart InfiniteData dropFirst() ``` Returns a new `InfiniteData` without its first page. Called during windowed paging when `maxPages` is exceeded after a `fetchNextPage`. #### `dropLast` ```dart InfiniteData dropLast() ``` Returns a new `InfiniteData` without its last page. Called during windowed paging when `maxPages` is exceeded after a `fetchPreviousPage`. --- ## `InfiniteQueryState` Sealed class state machine for an infinite query. Pattern-match exhaustively with `switch`: ```dart switch (state) { case InfiniteInitial() || InfiniteLoading(): return const CircularProgressIndicator(); case InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage): return PostFeed( items: data.flatten((page) => page), hasMore: hasNextPage, loadingMore: isFetchingNextPage, ); case InfiniteFailure(:final error, :final previousData): if (previousData != null) { return Column(children: [ PostFeed(items: previousData.flatten((p) => p)), ErrorBanner(error), ]); } return ErrorScreen(error); } ``` ### `InfiniteInitial` ```dart final class InfiniteInitial extends InfiniteQueryState ``` The query has not been executed yet. This is the default state when no fetch has been triggered. --- ### `InfiniteLoading` ```dart final class InfiniteLoading extends InfiniteQueryState ``` The **initial** page fetch is in progress. No pages have been loaded yet. After the first page is loaded, the state never returns to `InfiniteLoading`. Subsequent page fetches signal progress via `isFetchingNextPage` / `isFetchingPreviousPage` on `InfiniteSuccess`, keeping existing pages visible. --- ### `InfiniteSuccess` ```dart final class InfiniteSuccess extends InfiniteQueryState ``` All currently loaded pages, with pagination status flags. #### Properties | Property | Type | Description | | ------------------------ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `data` | `InfiniteData` | All loaded pages and their page parameters. | | `hasNextPage` | `bool` | `true` when `getNextPageParam` returns non-null for the last page. | | `hasPreviousPage` | `bool` | `true` when `getPreviousPageParam` returns non-null for the first page. Always `false` if no `getPreviousPageParam`. | | `updatedAt` | `DateTime` | When this state was last successfully updated. | | `isFetchingNextPage` | `bool` | `true` while a next-page fetch is in progress. Show a bottom loading indicator; `data` is still valid. | | `isFetchingPreviousPage` | `bool` | `true` while a previous-page fetch is in progress. Show a top loading indicator; `data` is still valid. | #### `copyWith` ```dart InfiniteSuccess copyWith({ InfiniteData? data, bool? isFetchingNextPage, bool? isFetchingPreviousPage, bool? hasNextPage, bool? hasPreviousPage, DateTime? updatedAt, }) ``` Returns a copy with the specified fields overridden. Unspecified fields retain their current values. --- ### `InfiniteFailure` ```dart final class InfiniteFailure extends InfiniteQueryState ``` A fetch failure, with optional `previousData` for graceful degradation. #### Properties | Property | Type | Description | | -------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `error` | `Object` | The error thrown by the fetch function (after all retries). | | `stackTrace` | `StackTrace?` | Optional stack trace. | | `previousData` | `InfiniteData?` | Last successfully loaded pages, if any. Non-null when failure occurs after pages have already been loaded. | When `previousData` is non-null, show existing content alongside an error indicator rather than replacing the entire view. --- ## `InfiniteQueryOptions` Configuration for an infinite query. Combines standard `QoraOptions` with pagination-specific parameters. ### Constructor ```dart const InfiniteQueryOptions({ required TPageParam initialPageParam, required TPageParam? Function(TData lastPage, List allPages) getNextPageParam, TPageParam? Function(TData firstPage, List allPages)? getPreviousPageParam, int? maxPages, QoraOptions baseOptions = const QoraOptions(), }) ``` Asserts `maxPages == null || maxPages >= 1`. ### Properties | Property | Type | Required | Description | | ---------------------- | -------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `initialPageParam` | `TPageParam` | Yes | Parameter for the very first page (e.g. `1`, `0`, `null`, or `''`). | | `getNextPageParam` | `TPageParam? Function(TData lastPage, List allPages)` | Yes | Returns the next page's parameter, or `null` when no forward pages remain (sets `hasNextPage` to `false`). | | `getPreviousPageParam` | `TPageParam? Function(TData firstPage, List allPages)?` | : | Returns the previous page's parameter. Only needed for bi-directional scroll. When `null`, `hasPreviousPage` is always `false`. | | `maxPages` | `int?` | : | Maximum pages kept in memory. When exceeded, the oldest page is dropped on `fetchNextPage` (or newest on `fetchPreviousPage`). Must be `>= 1`. | | `baseOptions` | `QoraOptions` | : | Standard retry, stale time, cache time, and network mode options applied to every page fetch. Merged on top of `QoraClientConfig.defaultOptions`. | --- ## `InfiniteQueryObserver` The central pagination engine. Manages the full lifecycle of a single infinite query: fetching pages, emitting state transitions, and respecting concurrency guards. Typically created inside a Flutter widget's `State.initState` and disposed in `State.dispose`. For a ready-made Flutter binding, use `InfiniteQueryBuilder` instead. Multiple observers sharing the same `key` on the same `client` share the same underlying cache entry, so all listeners receive the same state transitions in sync. ### Constructor ```dart InfiniteQueryObserver({ required QoraClient client, required Object key, required InfiniteQueryFunction fetcher, required InfiniteQueryOptions options, }) ``` ### Properties | Property | Type | Description | | -------- | ----------------------------------------------- | ----------------------------------------------------------------- | | `stream` | `Stream>` | Emits the current state immediately, then all future transitions. | | `state` | `InfiniteQueryState` | Synchronous snapshot of the current state. | ### Methods #### `fetch` ```dart Future fetch() ``` Fetch the first page. No-op when: - Data is already loaded (state is not `InfiniteInitial`). - An initial fetch is already in progress. Transitions: `InfiniteInitial` → `InfiniteLoading` → `InfiniteSuccess` | `InfiniteFailure`. --- #### `fetchNextPage` ```dart Future fetchNextPage() ``` Fetch the next page and append it. No-op when: - No data has been loaded yet (call `fetch` first). - `hasNextPage` is `false`. - A next-page or previous-page fetch is already in progress. While fetching, sets `isFetchingNextPage: true` on the current `InfiniteSuccess`: existing pages stay visible. On failure, transitions to `InfiniteFailure` with `previousData` populated. --- #### `fetchPreviousPage` ```dart Future fetchPreviousPage() ``` Fetch the previous page and prepend it. No-op when: - `getPreviousPageParam` is not configured. - `hasPreviousPage` is `false`. - A fetch is already in progress. While fetching, sets `isFetchingPreviousPage: true`. On failure, transitions to `InfiniteFailure` with `previousData`. --- #### `refetch` ```dart Future refetch() ``` Re-fetch all currently loaded pages in sequence, preserving the page count. Uses the original `pageParams` stored in `InfiniteData`. Useful after `client.invalidateInfiniteQuery(key)` or pull-to-refresh. - If no data is loaded, delegates to `fetch()`. - Signals progress via `isFetchingNextPage: true` on the current `InfiniteSuccess`. - On failure, transitions to `InfiniteFailure` with `previousData`. --- #### `dispose` ```dart void dispose() ``` Releases this observer. After disposal, all further method calls are silently ignored. The underlying cache entry is **not removed**: other observers sharing the same key continue operating normally. --- ### Concurrency Guarantees All guards (`_isFetchingInitial`, `_isFetchingNext`, `_isFetchingPrevious`, `_isRefetching`) are set as the **first synchronous statement** in their method: before any `await`. This prevents race conditions from rapid scroll events or UI interactions even before the first network round-trip begins. --- ## `InfiniteQueryBuilder` Flutter widget that binds an infinite query to the widget tree. See the [InfiniteQueryBuilder guide](https://qora.meragix.dev/../flutter-integration/infinite-query-builder) for usage examples. ### Constructor ```dart const InfiniteQueryBuilder({ Key? key, required Object queryKey, required InfiniteQueryFunction fetcher, required InfiniteQueryOptions options, required Widget Function( BuildContext context, InfiniteQueryState state, InfiniteQueryController controller, ) builder, QoraClient? client, bool enabled = true, }) ``` ### Props | Prop | Type | Default | Description | | ---------- | ---------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | | `queryKey` | `Object` (`List` or `QoraKey`) | required | Cache key. Changing it disposes the current observer and starts fresh from `initialPageParam`. | | `fetcher` | `InfiniteQueryFunction` | required | `Future Function(TPageParam pageParam)`: fetches a single page. | | `options` | `InfiniteQueryOptions` | required | Pagination configuration. | | `builder` | `Widget Function(BuildContext, InfiniteQueryState, InfiniteQueryController)` | required | Builds the widget tree from state and controller. | | `client` | `QoraClient?` | `null` | Override the `QoraClient` from `QoraScope`. Useful for testing. | | `enabled` | `bool` | `true` | When `false`, the first-page fetch is suppressed. The widget still observes the current cached state. | --- ## `InfiniteQueryController` A stable interface exposing pagination actions to the `InfiniteQueryBuilder.builder` callback. The controller reference is stable as long as `queryKey` and `client` remain unchanged. ### Methods | Method | Signature | Description | | --------------------- | -------------- | ---------------------------------------------------------------------------------------------------- | | `fetchNextPage()` | `Future` | Fetch and append the next page. No-op if `hasNextPage` is `false` or a fetch is already in progress. | | `fetchPreviousPage()` | `Future` | Fetch and prepend the previous page. Requires `getPreviousPageParam` to be configured. | | `refetch()` | `Future` | Re-fetch all loaded pages in sequence. Useful for pull-to-refresh (`onRefresh: controller.refetch`). | --- ## `InfiniteQueryFunction` ```dart typedef InfiniteQueryFunction = Future Function( TPageParam pageParam, ); ``` The type of the `fetcher` parameter on `InfiniteQueryObserver` and `InfiniteQueryBuilder`. Receives the page parameter computed by `getNextPageParam` / `getPreviousPageParam` and returns a `Future` resolving to a single page of data. --- ## `QoraClient`: Infinite Query Methods ### `watchInfiniteState` ```dart Stream> watchInfiniteState(Object key) ``` Returns a broadcast stream that immediately emits the current `InfiniteQueryState` for `key`, then emits all future transitions. Subscribing prevents the cache entry from being garbage-collected. --- ### `getInfiniteQueryState` ```dart InfiniteQueryState getInfiniteQueryState(Object key) ``` Synchronous snapshot of the current state. Returns `InfiniteInitial()` if no entry exists in cache. --- ### `getInfiniteQueryData` ```dart InfiniteData? getInfiniteQueryData(Object key) ``` Returns the `InfiniteData` from the current `InfiniteSuccess` state, or `null` if the state is not `InfiniteSuccess`. ```dart [posts_screen.dart] final snapshot = client.getInfiniteQueryData, int>(['posts']); ``` --- ### `setInfiniteQueryData` ```dart void setInfiniteQueryData( Object key, InfiniteData data, { bool? hasNextPage, bool? hasPreviousPage, }) ``` Inject `InfiniteData` directly into the cache, pushing an `InfiniteSuccess` state to all active subscribers. Use this for optimistic updates. ```dart [posts_screen.dart] // Optimistically prepend a new post to page 1 final snapshot = client.getInfiniteQueryData, int>(['posts']); if (snapshot != null) { client.setInfiniteQueryData( ['posts'], InfiniteData( pages: [ [Post.optimistic(title), ...snapshot.pages.first], ...snapshot.pages.skip(1), ], pageParams: snapshot.pageParams, ), ); } try { await api.createPost(title); client.invalidateInfiniteQuery(['posts']); } catch (_) { if (snapshot != null) client.setInfiniteQueryData(['posts'], snapshot); } ``` --- ### `updateInfiniteQueryState` ```dart void updateInfiniteQueryState( Object key, InfiniteQueryState state, ) ``` The raw write path for any `InfiniteQueryState`. Used internally by `InfiniteQueryObserver`. Prefer `setInfiniteQueryData` for data-only updates. --- ### `invalidateInfiniteQuery` ```dart void invalidateInfiniteQuery(Object key) ``` Reset the cache entry for `key` to `InfiniteInitial`. Active `InfiniteQueryBuilder` instances detect the reset and automatically re-fetch the first page, so the UI never stays blank. ```dart [anywhere.dart] await api.createPost(title); client.invalidateInfiniteQuery(['posts']); ``` --- ## Related - [Infinite Queries Guide](https://qora.meragix.dev/../recipes/infinite-queries): State machine, pagination strategies, bi-directional scroll, windowed paging - [InfiniteQueryBuilder](https://qora.meragix.dev/../flutter-integration/infinite-query-builder): Flutter widget reference with patterns and examples - [QoraClient API](https://qora.meragix.dev/qora-client): Full client reference for standard (non-paginated) queries - [QoraOptions](https://qora.meragix.dev/qora-options): Retry, stale time, cache time, and network mode # Overview Qora's debugging tool is the **in-app overlay**: a panel rendered inside your running app, in debug builds only. It shows what the cache holds, what each query is doing, and every mutation with its variables and rollback context, as it happens. ::card --- icon: i-lucide-layers title: In-App Overlay to: https://qora.meragix.dev/en/devtools/overlay --- Install it, wrap the app with `QoraInspector` , and tap the **Q** button. Works on simulators, emulators and physical devices. :: ## What you can see and do | Area | Contents | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Queries** | Every cached key with its status, searchable. The inspector shows the cached data and the state machine, with actions: Refetch, Invalidate, Mark Stale, Remove, Simulate Error | | **Mutations** | Pending and settled mutations. The inspector shows variables, result or error, and the rollback context returned by `onMutate`, with Retry and Cancel | | **Timeline** | Fetches, mutations, optimistic updates and cache clears, in order | | **Data Diff** | For a mutation: the variables sent next to the server's result, and the cache snapshot taken for an optimistic update | Actions require passing the client to `QoraInspector`; without it the overlay is read-only. ## How it works `QoraClient` reports what it does to a `QoraTracker`. The overlay provides one, `OverlayTracker`, which keeps the recent events in memory (200 per channel) for the panel to display. ```text QoraClient │ ├── NoOpTracker default, and in release: no events, no cost └── OverlayTracker debug builds: events kept in memory for the panel ``` In release builds you pass `null` as the tracker, so the client uses `NoOpTracker`, and `QoraInspector` builds nothing. See [Production safety](https://qora.meragix.dev/overlay#production-safety). To send the same events to more than one tracker (the overlay and your own logging, for example), combine them with `MultiTracker` from `qora`. ## IDE extension A Flutter DevTools extension is in development and is not published. The overlay is the supported tool; this page will link to the extension when it ships. ## Next Steps - [In-App Overlay](https://qora.meragix.dev/overlay): installation and setup - [Data Serialization](https://qora.meragix.dev/data-display): make your models readable in the inspector # In-App Overlay The overlay is a panel rendered inside the running app. It provides real-time visibility into queries, mutations, and optimistic updates. ::tabs{.w-full} :::tabs-item{icon="i-lucide-smartphone" label="Mobile view"} ::::div{.grid.grid-cols-3.gap-2} ![Queries tab](https://qora.meragix.dev/devtools/overlay-panel-1.png) ![Mutations tab](https://qora.meragix.dev/devtools/overlay-panel-2.png) ![Timeline tab](https://qora.meragix.dev/devtools/overlay-panel-3.png) :::: ::: :::tabs-item{icon="i-lucide-monitor" label="Tablette/Desktop view"} ![Qora DevTools Overlay](https://qora.meragix.dev/devtools/overlay-panel-tablet.png) ::: :: ::note The overlay is **absent from release builds** . The package produces no production bundle impact. :: --- ## Installation ```bash [Terminal] flutter pub add qora_devtools_overlay ``` Add it to `dependencies`, not `dev_dependencies`: your `main.dart` imports it, and importing a dev dependency from `lib/` is flagged by the `depend_on_referenced_packages` lint. It does not cost anything in release, as explained in [Production safety](https://qora.meragix.dev/#production-safety). --- ## Setup Create an `OverlayTracker` and pass it to both `QoraClient` and `QoraInspector`. Pass the client reference to `QoraInspector` to enable the inspector action buttons. ```dart [main.dart] import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:qora_devtools_overlay/qora_devtools_overlay.dart'; import 'package:qora_flutter/qora_flutter.dart'; void main() { final tracker = OverlayTracker(); final client = QoraClient( // null in release mode: QoraClient uses NoOpTracker, no events emitted. tracker: kDebugMode ? tracker : null, ); runApp( QoraInspector( tracker: tracker, // Required for action buttons (Refetch, Invalidate, Remove, etc.) // to operate on the live cache. Omitting this disables the ACTIONS section. client: kDebugMode ? client : null, child: QoraScope.auto(client: client, child: const MyApp()), ), ); } ``` In debug mode, a **Q** button is rendered in the bottom-right corner of the screen. Tapping it opens the panel. ::callout{type="warning"} The `client` parameter is optional. `QoraInspector` operates as a read-only observer when `client` is not provided. The **ACTIONS** section is not rendered in that case. :: ::callout{type="info"} `QoraInspector` renders nothing in release builds. The guard is applied at widget level: the overlay subtree is not constructed when `kDebugMode` is false. :: --- ## Responsive behavior The overlay layout adapts to the available screen width. - **600 px and above** (tablet, desktop): three-column layout with all panels visible simultaneously. - **Below 600 px** (phone): single-column navigation with forward navigation into inspector and timeline views. --- ## Production safety Two independent mechanisms keep the overlay out of production: ### 1. Compile-time guard ```dart @override Widget build(BuildContext context) { if (!kDebugMode) return child; // nothing is built in release return _buildOverlay(); } ``` `kDebugMode` is a compile-time constant. In a release build the overlay branch is unreachable, and tree shaking removes the panel's code. ### 2. NoOpTracker in release `QoraClient` is constructed with `tracker: kDebugMode ? tracker : null`. In release mode, `null` activates `NoOpTracker`. Its methods are no-ops and no tracking events are emitted. Result: no runtime impact from the overlay tracker path in production. # Data Serialization The overlay shows cached query data as an expandable JSON tree. How readable that tree is depends on the type `T` returned by the fetcher: most models need no configuration, and the rest need one option. --- ## Serialization resolution order `QoraClient` has no knowledge of domain types. When a fetch settles, the result is passed to the active tracker using the following resolution order: | Priority | Condition | Result | | -------- | ---------------------------------- | ----------------------------------------------------- | | 1 | `QoraOptions.toJson` is set | Calls the provided function and uses its return value | | 2 | `String`, `num`, `bool`, or `null` | Passed through unchanged | | 3 | `List` | Each element is recursively serialized | | 4 | `Map` | Each value is recursively serialized | | 5 | Object with a `toJson()` method | Called via dynamic dispatch, result used | | 6 | Everything else | `.toString()` called, displayed as a plain string | The walk is recursive. A `List>` is handled correctly without configuration, provided `User` implements `toJson()`. --- ## Common scenarios ### `json_serializable` and `freezed` Both code generators produce a `toJson()` method. The serializer detects and calls it automatically. No additional configuration is required. ```dart [user_repository.dart] // @JsonSerializable() or @freezed: no extra config needed fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), ); ``` The overlay renders the full object graph as an expandable tree. --- ### Raw `Map` responses When the fetcher returns `Map` directly (for example, after manually decoding the HTTP response body), no configuration is required. ```dart [user_repository.dart] fetchQuery>( key: ['users', userId], fetcher: () async { final response = await http.get(Uri.parse('/users/$userId')); return jsonDecode(response.body) as Map; }, ); ``` This approach requires no serialization configuration. The deserialization step must be handled in the fetcher or repository layer rather than in a consuming widget. --- ### Manual models without `toJson` When a model has no `toJson()` method, the serializer falls back to `.toString()` and displays a single string node. To produce structured output, supply an explicit serializer via `QoraOptions.toJson`. ```dart [user_repository.dart] fetchQuery( key: ['users', userId], fetcher: () => api.getUser(userId), options: QoraOptions( toJson: (data) => (data as User).toJson(), ), ); ``` --- ### Lists Lists of typed objects follow the same resolution rules. When the item type implements `toJson()`, each element is serialized automatically. ```dart [posts_repository.dart] fetchQuery>( key: ['posts'], fetcher: () => api.getPosts(), // Post implements toJson(): auto-detected, no extra config. ); ``` When individual items lack `toJson()`, provide a serializer: ```dart [posts_repository.dart] fetchQuery>( key: ['posts'], fetcher: () => api.getPosts(), options: QoraOptions( toJson: (data) => (data as List).map((p) => p.toJson()).toList(), ), ); ``` --- ## The `toJson` option `QoraOptions.toJson` accepts any function with the signature: ```dart Object? Function(Object? data) ``` The return value must be JSON-safe: `Map`, `List`, `String`, `num`, `bool`, or `null`. Returning any other type will cause a runtime error when the DevTools renderer encodes the value. ```dart [user_repository.dart] // The cast is safe: T is User and data is the fetcher result. options: QoraOptions( toJson: (data) => (data as User).toJson(), ), ``` `toJson` follows the same merge semantics as all other `QoraOptions` fields. A value on `QoraClientConfig.defaultOptions` applies globally and can be overridden per query. --- ## When to use each approach | Situation | Recommendation | | ----------------------------------------------------------- | ------------------------------------------ | | `json_serializable` or `freezed` models | No configuration required. Auto-detected. | | Raw `Map` or `List` from `jsonDecode` | No configuration required. Passed through. | | Manual model with a `toJson()` method | No configuration required. Auto-detected. | | Model without `toJson()` | Provide `QoraOptions(toJson: ...)`. | | Custom serialization with a non-standard `toJson` signature | Provide `QoraOptions(toJson: ...)`. | --- ## Production overhead The serialization path (`_serializeForTracker`) is only executed when `QoraTracker.needsSerialization` returns `true`. In production, `QoraClient` defaults to `NoOpTracker`, which returns `false`. No serialization runs and no overhead is introduced. ```dart [main.dart] final client = QoraClient( tracker: kDebugMode ? tracker : null, // null activates NoOpTracker ); ``` # With Provider Provider and `ChangeNotifier` are effective for UI state and dependency injection. Server data management introduces repetitive boilerplate in each notifier: `isLoading`, `error`, manual cache guards, and inconsistent retry handling. Qora takes over that specific responsibility. Provider keeps everything else. --- ## The Problem with ChangeNotifier for Server Data A typical data-fetching notifier: ```dart [lib/providers/user_provider.dart] class UserNotifier extends ChangeNotifier { User? data; bool isLoading = false; Object? error; int? _loadedId; Future fetchUser(int id) async { if (_loadedId == id && data != null) return; // manual cache, no TTL, no SWR isLoading = true; error = null; notifyListeners(); try { data = await UserApi.getUser(id); _loadedId = id; } catch (e) { error = e; // no retry, no backoff } finally { isLoading = false; notifyListeners(); } } } ``` ```dart [lib/user_screen.dart] Consumer( builder: (context, notifier, _) { if (notifier.isLoading) return const CircularProgressIndicator(); if (notifier.error != null) return ErrorWidget('${notifier.error}'); if (notifier.data == null) { notifier.fetchUser(userId); // side effect in build, fragile lifecycle return const SizedBox.shrink(); } return UserCard(notifier.data!); }, ) ``` This pattern is written identically for every API call. It also misses: stale-while-revalidate, background refetch on focus/reconnect, request deduplication, and offline support. --- ## After: Let Qora Own Server Data ```dart [lib/main.dart] final qoraClient = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)), ), ); runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => ThemeNotifier()), ChangeNotifierProvider(create: (_) => AuthNotifier()), ], child: QoraScope( client: qoraClient, lifecycleManager: FlutterLifecycleManager(qoraClient: qoraClient), connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ), ); ``` ```dart [lib/user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => UserApi.getUser(userId), builder: (context, state, fetchStatus) => switch (state) { Success(:final data) => UserCard(data), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorWidget('$error'), Initial() => const SizedBox.shrink(), }, ) ``` The Qora version removes manual loading flags, notifier notifications, and custom cache guards. Retry, request deduplication, background revalidation, and SWR behavior are built in. The third builder argument `fetchStatus` distinguishes `FetchStatus.fetching` from `FetchStatus.paused` in offline conditions. Use this state to render a waiting-for-connection indicator instead of a loading spinner. --- ## Division of Responsibilities | Responsibility | Keep in Provider | Move to Qora | | ------------------------------------------------- | ------------------------------------- | -------------------------- | | Data fetched from an API (users, products, feeds) | | `QoraBuilder` + `fetcher:` | | Mutations (create, update, delete) | | `QoraMutationBuilder` | | UI state (selected tab, modal open, form values) | `ChangeNotifier` | | | Authentication session (token in memory) | `ChangeNotifier` | | | Dependency injection (services, repositories) | `Provider` / `ChangeNotifierProvider` | | --- ## Key API Mapping | Provider pattern | Qora equivalent | | -------------------------------------------------- | ------------------------------- | | `ChangeNotifier` + `fetch()` method | `QoraBuilder` with `fetcher:` | | `Consumer` + ternary on `isLoading / error / data` | `switch (state)` on `QoraState` | | Manual `if (data != null) return` | `staleTime` in `QoraOptions` | | `ChangeNotifierProvider` (data layer) | `QoraScope` | | `notifyListeners()` after write | `client.invalidate(key)` | --- ## Checklist - Add `qora_flutter` to `pubspec.yaml` and run `flutter pub get` - Nest `QoraScope` inside existing `MultiProvider` root (not a replacement) - Identify `ChangeNotifier` classes that only fetch and emit loading/error/data and replace them with `QoraBuilder` (`fetcher:`, 3-arg builder) - Replace `Consumer` loading/error/data ternaries with `switch (state)` - Replace manual cache guards with `staleTime` - Replace `notifyListeners()` post-write with `client.invalidate(key)` - Keep `ChangeNotifierProvider` entries for UI state, auth, and DI --- ## Related - [Setup](https://qora.meragix.dev/../flutter-integration/setup) - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder) - [Quick Start](https://qora.meragix.dev/../getting-started/quick-start) # With BLoC BLoC is effective for complex UI state machines such as multi-step checkout, authentication flows, and form orchestration. It is less efficient for repetitive server-state operations such as fetch, cache, retry, and background revalidation. In a combined architecture, BLoC remains responsible for UI transitions and Qora manages server-state lifecycles. --- ## Division of Labour | Responsibility | BLoC | Qora | | ---------------------------------------------- | ----------------------- | -------------------------------------- | | Fetch user profile | | `QoraBuilder` | | Fetch product list / infinite feed | | `QoraBuilder` / `InfiniteQueryBuilder` | | Create, update, delete (mutations) | | `QoraMutationBuilder` | | Multi-step checkout flow | `CheckoutBloc` | | | Form validation state | BLoC or `ValueNotifier` | | | Authentication flow | `AuthBloc` | | | Optimistic UI + cache invalidation after write | | `setQueryData` + `invalidate` | The two coexist cleanly: `QoraScope` nests inside the `BlocProvider` tree (or vice versa). --- ## The Anti-Pattern: Data-Fetching BLoC A BLoC that only wraps an API call provides no structural benefit over a function call, yet carries the full overhead of events, states, and wiring: ```dart [lib/blocs/user_bloc.dart] abstract class UserEvent {} class LoadUser extends UserEvent { final int id; LoadUser(this.id); } abstract class UserState {} class UserInitial extends UserState {} class UserLoading extends UserState {} class UserLoaded extends UserState { final User user; UserLoaded(this.user); } class UserError extends UserState { final String message; UserError(this.message); } class UserBloc extends Bloc { final UserApi _api; UserBloc(this._api) : super(UserInitial()) { on((event, emit) async { emit(UserLoading()); try { emit(UserLoaded(await _api.getUser(event.id))); } catch (e) { emit(UserError('$e')); // no retry, no deduplication, no SWR } }); } } ``` ```dart [lib/user_screen.dart] BlocBuilder( builder: (context, state) => switch (state) { UserLoading() => const CircularProgressIndicator(), UserLoaded(:final user) => UserCard(user), UserError(:final message) => ErrorWidget(message), _ => const SizedBox.shrink(), }, ) ``` --- ## After: QoraBuilder for Server Data ```dart [lib/user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => UserApi.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), builder: (context, state, fetchStatus) => switch (state) { Success(:final data) => UserCard(data), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorWidget('$error'), Initial() => const SizedBox.shrink(), }, ) ``` For data-only reads, Qora removes event/state boilerplate and manual transition code. Retry with backoff, request deduplication, and background revalidation on focus or reconnect are built in. The third builder argument `fetchStatus` distinguishes active requests (`FetchStatus.fetching`) from paused offline requests (`FetchStatus.paused`). --- ## BLoC + Qora on the Same Screen Complex screens often need both: a UI state machine (BLoC) driving a view that also displays server data (Qora). They compose without friction: ```dart [lib/checkout_screen.dart] BlocBuilder( builder: (context, checkoutState) { // CheckoutBloc drives the step machine if (checkoutState is SelectingAddress) { return QoraBuilder>( queryKey: ['users', userId, 'addresses'], fetcher: () => api.getAddresses(userId), builder: (context, state, fetchStatus) => switch (state) { Success(:final data) => AddressSelector( addresses: data, onSelect: (address) => context.read().add(AddressSelected(address)), ), Loading() => const CircularProgressIndicator(), Failure(:final error)=> ErrorWidget('$error'), Initial() => const SizedBox.shrink(), }, ); } if (checkoutState is ReviewingOrder) { return OrderReviewWidget(order: checkoutState.order); } return const SizedBox.shrink(); }, ) ``` BLoC controls step selection. Qora provides server data inside each step. --- ## Invalidating Qora from a BLoC When a BLoC performs a write (a form submission, a completed checkout step), it can invalidate Qora's cache so the next read gets fresh data: ```dart [lib/blocs/update_user_bloc.dart] class UpdateUserBloc extends Bloc { final QoraClient _client; final UserApi _api; UpdateUserBloc(this._client, this._api) : super(UpdateUserInitial()) { on((event, emit) async { emit(UpdateUserLoading()); try { await _api.updateUser(event.id, event.name); _client.invalidate(['users', event.id]); // Qora refetches on next read emit(UpdateUserSuccess()); } catch (e) { emit(UpdateUserError('$e')); } }); } } ``` Pass `QoraClient` into BLoCs that need to coordinate with the server-state cache. Do not pass `BuildContext`, the client is a shared cache object, not a widget concern. --- ## Setup ```dart [lib/main.dart] final qoraClient = QoraClient( config: const QoraClientConfig(debugMode: kDebugMode), ); runApp( MultiBlocProvider( providers: [ BlocProvider(create: (_) => AuthBloc()), BlocProvider(create: (ctx) => CheckoutBloc(ctx.read())), ], child: QoraScope( client: qoraClient, lifecycleManager: FlutterLifecycleManager(qoraClient: qoraClient), connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ), ); ``` `QoraScope` can be nested inside or outside `MultiBlocProvider`. Select the layout that yields the clearest composition in the app root. --- ## Checklist - Add `qora_flutter` to `pubspec.yaml` - Add `QoraScope` to the widget tree alongside `MultiBlocProvider` - Identify BLoCs that only do `emit(Loading) -> fetch -> emit(Loaded/Error)` and replace them with `QoraBuilder` - Keep BLoCs that model multi-event workflows, complex transitions, or non-fetch domain logic - Remove `BlocProvider` for data-only blocs - Replace `BlocBuilder` for data-only blocs with `QoraBuilder` - Inject `QoraClient` into BLoCs that perform writes and need to invalidate the cache --- ## Related - [Setup](https://qora.meragix.dev/../flutter-integration/setup) - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder) - [Mutations Guide](https://qora.meragix.dev/../recipes/mutations) - [Comparison](https://qora.meragix.dev/../getting-started/comparison) # With Riverpod Riverpod `Provider` and `NotifierProvider` are effective for dependency injection and client state. `FutureProvider` handles async reads but does not provide SWR, retry/backoff, or offline pause and replay semantics by default. Qora adds these server-state capabilities without replacing Riverpod. Riverpod remains focused on dependency graphs and client state. --- ## What FutureProvider Misses ```dart [lib/providers/user_provider.dart] final userProvider = FutureProvider.family((ref, id) async { return UserApi.getUser(id); }); ``` ```dart [lib/user_screen.dart] final userAsync = ref.watch(userProvider(userId)); return userAsync.when( loading: () => const CircularProgressIndicator(), error: (e, _) => ErrorWidget('$e'), data: (user) => UserCard(user), ); ``` Limitations: - **Full-screen spinner on every refresh**: old data is discarded, no stale-while-revalidate. - **No retry with backoff**: a single failure is surfaced immediately. - **No background refetch** on app focus or reconnect. - **No offline support**: no `FetchStatus`, and no pause/replay behavior on reconnect. - **`AsyncValue` has no `previousData`**: no graceful degradation path. --- ## Hooks Path If you are already using `flutter_hooks` (which Riverpod developers often are via `hooks_riverpod`), `qora_hooks` provides a `useQuery` hook that feels native: ```yaml [pubspec.yaml] dependencies: qora_flutter: ^ 1.3.1 qora_hooks: ^ 1.3.1 # requires flutter_hooks ``` ```dart [lib/user_screen.dart] class UserScreen extends HookWidget { const UserScreen({required this.userId, super.key}); final int userId; @override Widget build(BuildContext context) { final state = useQuery( key: ['users', userId], fetcher: () => UserApi.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); return switch (state) { Success(:final data) => UserCard(data), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorWidget('$error'), Initial() => const SizedBox.shrink(), }; } } ``` `useQuery` returns `QoraState`, the same sealed type used across Qora APIs. Stale-while-revalidate, retry, background refetch, and deduplication are built in. ### Graceful Degradation with previousData `Loading` and `Failure` carry `previousData`, which is the last successful value: ```dart [lib/user_screen.dart] final state = useQuery( key: ['users', userId], fetcher: () => UserApi.getUser(userId), ); return switch (state) { Success(:final data) => UserCard(data), Loading(:final previousData) when previousData != null => Stack(children: [ UserCard(previousData), const LoadingOverlay(), ]), Loading() => const CircularProgressIndicator(), Failure(:final error, :final previousData) when previousData != null => Column(children: [ UserCard(previousData), ErrorBanner('$error'), ]), Failure(:final error) => ErrorWidget('$error'), Initial() => const SizedBox.shrink(), }; ``` --- ## Mutations with useMutation ```dart [lib/update_name_form.dart] class UpdateNameForm extends HookWidget { const UpdateNameForm({required this.userId, super.key}); final int userId; @override Widget build(BuildContext context) { final handle = useMutation( mutator: (name) => UserApi.updateUser(userId, name), options: MutationOptions( onSuccess: (data, _, __) async { QoraScope.of(context).setQueryData(['users', userId], data); QoraScope.of(context).invalidateWhere((k) => k.firstOrNull == 'users'); }, ), ); return ElevatedButton( onPressed: handle.isPending ? null : () => handle.mutate(nameController.text), child: handle.isPending ? const CircularProgressIndicator() : const Text('Save'), ); } } ``` `MutationHandle` exposes: `isIdle`, `isPending`, `isSuccess`, `isError`, `data`, `error`, `mutate(variables)`, `mutateAsync(variables)`, `reset()`. --- ## Widget API Alternative If you prefer to stay with `StatefulWidget` rather than `HookWidget`, `QoraBuilder` provides the same capabilities: ```dart [lib/user_screen.dart] QoraBuilder( queryKey: ['users', userId], fetcher: () => UserApi.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), 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(), }; }, ) ``` The third builder argument `fetchStatus` exposes `FetchStatus.paused` in offline states. Use `QoraBuilder` when this signal is required by the UI. --- ## Keeping Riverpod for DI and Client State Riverpod's `Provider` and `NotifierProvider` are unmatched for dependency injection and client state. The two scopes compose cleanly: ```dart [lib/main.dart] final qoraClient = QoraClient( config: const QoraClientConfig(debugMode: kDebugMode), ); runApp( ProviderScope( overrides: [ userApiProvider.overrideWithValue(UserApi(baseUrl: env.apiUrl)), ], child: QoraScope( client: qoraClient, lifecycleManager: FlutterLifecycleManager(qoraClient: qoraClient), connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ), ); ``` Use Riverpod providers to inject `UserApi`, `QoraClient`, or repositories, then consume them from `useQuery` or `QoraBuilder`. --- ## Feature Comparison | Feature | `FutureProvider` | `useQuery` / `QoraBuilder` | | ---------------------------------------- | :--------------: | :------------------------: | | SWR: serve stale, refetch in background | No | Yes | | `previousData` during refetch | No | Yes | | Auto retry with backoff | No | Yes | | Background refetch on focus / reconnect | No | Yes | | `FetchStatus.paused` (offline indicator) | No | Yes | | Offline persistence | No | Yes | | Infinite / paginated queries | No | Yes | | Deduplication of in-flight requests | Yes | Yes | --- ## Checklist - Add `qora_flutter` and `qora_hooks` to `pubspec.yaml` - Nest `QoraScope` inside `ProviderScope` - Replace `FutureProvider.family` with `useQuery` (hooks) or `QoraBuilder` (widgets) - Replace `AsyncNotifier` data-fetch methods with `useMutation` or `QoraMutationBuilder` - Keep Riverpod `NotifierProvider` for client state, DI, and service graphs - Replace `ref.invalidate(provider)` with `client.invalidate(key)` or `client.invalidateWhere` --- ## Related - [Setup](https://qora.meragix.dev/../flutter-integration/setup) - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder) - [Mutations Guide](https://qora.meragix.dev/../recipes/mutations) - [Comparison](https://qora.meragix.dev/../getting-started/comparison) # With RxDart ## Overview RxDart extends Dart streams with composable operators. When combined with `QoraClient.watchQuery`, the `switchMap` operator provides a declarative pattern for dynamic query key switching: the active subscription is cancelled atomically on each upstream emission, and a new subscription is opened against the new key. This pattern replaces manual subscription management in ViewModels and state notifiers that must respond to changes in a driving value (a selected ID, a filter, a tab index). --- ## Setup ```yaml [pubspec.yaml] dependencies: qora: ^ 1.3.1 rxdart: ^0.28.0 ``` --- ## Reactive Key Management ### The Pattern ```dart [lib/features/user/user_view_model.dart] import 'package:rxdart/rxdart.dart'; import 'package:qora/qora.dart'; class UserViewModel { UserViewModel(this._client); final QoraClient _client; // Driving subject: emits the currently selected user ID. final _idSubject = BehaviorSubject.seeded('1'); // Derived stream: switches the active watchQuery subscription on every // new ID. The previous subscription is cancelled before the next opens. late final Stream> userState = _idSubject .switchMap( (id) => _client.watchQuery( key: ['users', id], fetcher: () => api.getUser(id), options: const QoraOptions(staleTime: Duration(minutes: 5)), ), ) .doOnData((state) { // Side effects run after each state emission, before widget rebuild. if (state case Success(:final data)) { analytics.track('user_loaded', {'id': data.id}); } }); // Advance the driving subject. switchMap handles the rest. void selectUser(String id) => _idSubject.add(id); void dispose() => _idSubject.close(); } ``` ### Widget Binding ```dart [lib/features/user/user_screen.dart] class UserScreen extends StatefulWidget { const UserScreen({super.key}); @override State createState() => _UserScreenState(); } class _UserScreenState extends State { late final UserViewModel _vm; late final StreamSubscription> _sub; QoraState _state = const Initial(); @override void initState() { super.initState(); _vm = UserViewModel(context.qora); _sub = _vm.userState.listen((state) { setState(() => _state = state); }); } @override void dispose() { _sub.cancel(); _vm.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return switch (_state) { Initial() => const SizedBox.shrink(), Loading(:final previousData) => previousData != null ? UserCard(previousData) : const CircularProgressIndicator(), Success(:final data) => Column( children: [ UserCard(data), Row( children: [ TextButton( onPressed: () => _vm.selectUser('1'), child: const Text('User 1'), ), TextButton( onPressed: () => _vm.selectUser('2'), child: const Text('User 2'), ), ], ), ], ), Failure(:final error) => ErrorView(error), }; } } ``` --- ## Lifecycle Analysis ### What Happens on Key Switch When `_idSubject.add('2')` is called, the following sequence executes atomically: 1. `switchMap` calls `.cancel()` on the `StreamSubscription` to `watchQuery(key: ['users', '1'], ...)`. 2. Dart's async generator runtime fires the `finally` block inside `watchQuery`: ```dart // packages/dart: qora_client.dart, watchQuery finally block } finally { depSub?.cancel(); // cancel dependency subscription, if any localRefetchTimer?.cancel(); // cancel any active polling timer entry.removeSubscriber(); // decrement CacheEntry._subscriberCount _scheduleGC(normalized); // start GC timer if no more subscribers } ``` 3. `removeSubscriber()` decrements `CacheEntry._subscriberCount` and calls `touch()` (updates `lastAccessedAt`). If the count reaches zero, `entry.isActive` becomes `false`. 4. `_scheduleGC(key)` inspects `entry.isActive`. If `false`, it starts a `Timer` with a duration of `gcTime` (default: `Duration(minutes: 5)`). If `entry.isActive` is still `true`, because another part of the app holds a separate subscription to the same key, `_scheduleGC` is a no-op. 5. `switchMap` opens a new subscription to `watchQuery(key: ['users', '2'], ...)`. `addSubscriber()` increments the new entry's `_subscriberCount`. ### Why the Old Key's Data Survives The GC timer fires `gcTime` after the last subscriber cancels, not immediately. During this window: - The cache entry for `['users', '1']` remains in memory with its last `Success` state. - If `selectUser('1')` is called again before the timer fires, a new `watchQuery` subscription calls `addSubscriber()`, `entry.isActive` becomes `true`, and `_scheduleGC` is a no-op, the timer is cancelled if one was already running. - The next access serves the cached data immediately, then revalidates in the background if the data is stale. This means navigation between two user records with `staleTime: Duration(minutes: 5)` results in at most one network request per 5-minute window per key, regardless of how many times the user switches. ### Re-check at GC Expiry The GC timer re-checks `entry.isActive` before removing the entry: ```dart entry.gcTimer = Timer(config.defaultOptions.gcTime, () { if (!entry.isActive) { // re-check: a new subscriber may have attached _cache.remove(key); } }); ``` A new subscriber that attaches after the timer was scheduled but before it fires will find the entry intact and prevent its removal. --- ## Operators Reference | RxDart operator | Role in this pattern | | ------------------- | -------------------------------------------------------------------------------------------------- | | `BehaviorSubject` | Holds the current key value; replays the latest emission to new listeners. | | `switchMap` | Cancels the previous `watchQuery` subscription on each emission; opens a new one. | | `doOnData` | Runs side effects (logging, analytics) on each emitted state without modifying the stream. | | `debounceTime` | Optional: debounce rapid key changes (e.g. search input) to avoid launching a fetch per keystroke. | | `distinctUnchanged` | Optional: suppress re-subscription when the same key is emitted twice in succession. | ### Debouncing Rapid Key Changes For search inputs or other high-frequency drivers, add `debounceTime` before `switchMap`: ```dart late final Stream>> searchResults = _querySubject .debounceTime(const Duration(milliseconds: 300)) .distinct() .switchMap( (query) => _client.watchQuery>( key: ['products', 'search', query], fetcher: () => api.searchProducts(query), ), ); ``` `switchMap` still cancels the in-flight subscription on each emission, so a 300 ms debounce is sufficient to prevent launching a fetch per character while preserving instant cancellation of any request that does start. --- ## Hooks Equivalent If `qora_hooks` is available, the same behaviour is expressed without RxDart: ```dart [lib/features/user/user_screen.dart] class UserScreen extends HookWidget { const UserScreen({super.key}); @override Widget build(BuildContext context) { final selectedId = useState('1'); final state = useQuery( key: ['users', selectedId.value], fetcher: () => api.getUser(selectedId.value), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); // useQuery re-subscribes automatically when the key changes. // The previous subscription is cancelled in its useEffect cleanup. return switch (state) { Initial() => const SizedBox.shrink(), Loading(:final previousData) => previousData != null ? UserCard(previousData) : const CircularProgressIndicator(), Success(:final data) => UserCard(data), Failure(:final error) => ErrorView(error), }; } } ``` `useQuery` uses `useEffect([Object.hashAll(key)])` to cancel and re-subscribe on key change. The GC lifecycle is identical: the cancellation path is the same `watchQuery` `finally` block. Use the RxDart pattern in non-`HookWidget` contexts or when the key-driving logic lives in a ViewModel layer outside the widget tree. # With Dio Dio is the most popular HTTP client in the Dart/Flutter ecosystem. This guide covers idiomatic Qora+Dio usage: error mapping, interceptors for auth tokens and token refresh, and copy-paste ready fetcher functions. --- ## Setup ```yaml [pubspec.yaml] dependencies: qora: ^ 1.3.1 dio: ^5.0.0 ``` --- ## Core Pattern: Dio as Fetcher The fetcher function passed to `QoraClient.fetchQuery` or `QoraBuilder` should use Dio for the HTTP call and let Dio exceptions propagate up. Qora catches them, applies retry logic, and surfaces the error via `QoraState`. ```dart [lib/data/user_api.dart] import 'package:dio/dio.dart'; import 'package:qora/qora.dart'; final api = Dio(BaseOptions(baseUrl: 'https://api.example.com')); class UserApi { Future getUser(String id) async { final res = await api.get('/users/$id'); return User.fromJson(res.data); } Future> listUsers() async { final res = await api.get('/users'); return (res.data as List).map((j) => User.fromJson(j)).toList(); } } ``` ```dart [lib/features/user_screen.dart] QoraBuilder( queryKey: ['user', userId], fetcher: () => userApi.getUser(userId), builder: (context, state, _) => switch (state) { Success(:final data) => UserCard(data), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorView(error), _ => const SizedBox.shrink(), }, ) ``` --- ## Error Mapping: DioException to App Errors By default, Qora stores the raw `DioException` in the `Failure` state. Register a global `errorMapper` on `QoraClientConfig` to convert Dio errors into typed app errors, or use per-query `QoraOptions.transformError`. ### Global Error Mapper ```dart [lib/core/qora_setup.dart] import 'package:dio/dio.dart'; import 'package:qora/qora.dart'; /// Typed app error. `errorMapper` must return a `QoraException`, so extend it. class AppError extends QoraException { final int? statusCode; const AppError(super.message, {this.statusCode, super.originalError, super.stackTrace}); } final client = QoraClient( config: QoraClientConfig( errorMapper: (Object error, StackTrace? stackTrace) { if (error is DioException) { return switch (error.type) { DioExceptionType.connectionTimeout => AppError('Connection timed out', originalError: error), DioExceptionType.receiveTimeout => AppError('Server is not responding', originalError: error), DioExceptionType.badResponse => _mapHttpStatus(error), DioExceptionType.connectionError => AppError('No internet connection', originalError: error), _ => AppError(error.message ?? 'Request failed', originalError: error), }; } if (error is QoraException) return error; return QoraException(error.toString(), originalError: error, stackTrace: stackTrace); }, ), ); AppError _mapHttpStatus(DioException error) { final status = error.response?.statusCode ?? 0; final message = error.response?.data?['message'] as String? ?? 'Request failed'; return AppError(message, statusCode: status, originalError: error); } ``` ### Per-Query Error Transform ```dart [lib/features/profile_screen.dart] QoraBuilder( queryKey: ['profile'], fetcher: () => api.get('/profile').then((r) => Profile.fromJson(r.data)), options: QoraOptions( transformError: (error) { if (error is DioException && error.type == DioExceptionType.badResponse) { return ApiError.fromResponse(error.response!); } return error; }, ), builder: (context, state, _) { ... }, ) ``` --- ## Auth Interceptor Use a Dio interceptor to attach an access token to every request. The token source (e.g., secure storage) is consulted on each request: ```dart [lib/core/dio_setup.dart] import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; final storage = const FlutterSecureStorage(); final api = Dio(BaseOptions(baseUrl: 'https://api.example.com')) ..interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await storage.read(key: 'access_token'); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); }, )); ``` --- ## Token Refresh Interceptor When the server returns a 401, attempt a silent token refresh, then retry the original request exactly once: ```dart [lib/core/dio_setup.dart] final api = Dio(BaseOptions(baseUrl: 'https://api.example.com')) ..interceptors.addAll([ _AuthInterceptor(), _TokenRefreshInterceptor(api, storage), ]); class _AuthInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await storage.read(key: 'access_token'); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } } class _TokenRefreshInterceptor extends QueuedInterceptor { _TokenRefreshInterceptor(this._dio, this._storage); final Dio _dio; final FlutterSecureStorage _storage; bool _isRefreshing = false; @override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode != 401 || _isRefreshing) { return handler.next(err); } _isRefreshing = true; try { final refreshToken = await _storage.read(key: 'refresh_token'); if (refreshToken == null) { _isRefreshing = false; return handler.next(err); } final res = await _dio.post('/auth/refresh', data: {'refresh_token': refreshToken}); final newAccessToken = res.data['access_token'] as String; await _storage.write(key: 'access_token', value: newAccessToken); _isRefreshing = false; // Retry the original request with the new token. final opts = err.requestOptions; opts.headers['Authorization'] = 'Bearer $newAccessToken'; final retryResponse = await _dio.fetch(opts); return handler.resolve(retryResponse); } catch (e) { _isRefreshing = false; return handler.next(err); } } } ``` Qora's retry mechanism works above this layer. If the token refresh fails or the retried request still fails, the `DioException` propagates to Qora, which applies its own `retryCount` / `retryDelay` logic. --- ## Complete Fetcher with Error Typing For a fully typed fetcher that returns domain models: ```dart [lib/data/todo_api.dart] class TodoApi { TodoApi(this._api); final Dio _api; Future getTodo(String id) async { try { final res = await _api.get('/todos/$id'); return Todo.fromJson(res.data); } on DioException { rethrow; // let Qora errorMapper handle it } } Future> listTodos({String? filter}) async { try { final res = await _api.get('/todos', queryParameters: { if (filter != null) 'filter': filter, }); return (res.data as List).map((j) => Todo.fromJson(j)).toList(); } on DioException { rethrow; } } } ``` ```dart [lib/features/todo_screen.dart] QoraBuilder>( queryKey: ['todos', filter], fetcher: () => todoApi.listTodos(filter: filter), options: const QoraOptions( staleTime: Duration(minutes: 1), retryCount: 2, ), builder: (context, state, _) { if (state.errorOrNull case final error?) { return TodoErrorView(error, onRetry: () => context.qora.invalidate(['todos', filter])); } return ListView.builder( itemCount: (state.dataOrNull ?? []).length, itemBuilder: (_, i) => TodoTile(state.dataOrNull![i]), ); }, ) ``` --- ## Testing Dio Fetchers Use `Dio` with an adapter override (`HttpClientAdapter` or `DioMixin`) so tests run without a real network: ```dart [test/data/todo_api_test.dart] import 'package:dio/dio.dart'; import 'package:qora/qora.dart'; import 'package:test/test.dart'; void main() { late Dio dio; late QoraClient client; setUp(() { dio = Dio(BaseOptions(baseUrl: 'https://test.example.com')); // Replace with a mock adapter for offline testing. dio.httpClientAdapter = _MockAdapter(); client = QoraClient(); }); test('fetcher success flows into Qora Success state', () async { final result = await client.fetchQuery( key: ['todo', 1], fetcher: () async { final res = await dio.get('/todos/1'); return Todo.fromJson(res.data); }, ); expect(result.title, 'Test Todo'); expect(client.getQueryState(['todo', 1]), isA>()); }); } ``` --- ## Next Steps - [Error Handling](https://qora.meragix.dev/../recipes/error-handling): Global `errorMapper` and per-query `transformError` - [QoraOptions API](https://qora.meragix.dev/../api-reference/qora-options): `retryCount`, `retryDelay`, `retryCondition` - [Mutations](https://qora.meragix.dev/../recipes/mutations): Write operations with Dio and optimistic updates - [Network Aware](https://qora.meragix.dev/../recipes/network-aware): Dio with offline-first patterns and `NetworkMode` # With GraphQL GraphQL APIs benefit from Qora's stale-while-revalidate pattern, automatic invalidation, and normalized cache integration. This guide covers wrapping the `graphql` and `ferry` packages in Qora fetchers, error handling, and pagination with cursor-based connections. --- ## Setup ```yaml [pubspec.yaml] dependencies: qora: ^ 1.3.1 gql: ^1.0.0 # for ferry: gql_http_link, ferry, ferry_cache ``` --- ## Option A: graphql Package The `graphql` package provides a `GraphQLClient` that runs queries and mutations. Wrap each operation in a Qora fetcher: ```dart [lib/data/graphql_client.dart] import 'package:graphql/client.dart'; import 'package:qora/qora.dart'; final gqlClient = GraphQLClient( link: HttpLink('https://api.example.com/graphql'), cache: GraphQLCache(), ); final queryOptions = QueryOptions( document: gql(r''' query GetUser($id: ID!) { user(id: $id) { id name email } } '''), ); ``` ```dart [lib/data/user_api.dart] class UserApi { UserApi(this._client); final GraphQLClient _client; Future getUser(String id) async { final result = await _client.query( QueryOptions( document: gql(r''' query GetUser($id: ID!) { user(id: $id) { id name email } } '''), variables: {'id': id}, ), ); if (result.hasException) throw result.exception!; return User.fromJson(result.data!['user']); } } ``` ```dart [lib/features/user_screen.dart] QoraBuilder( queryKey: ['user', userId], fetcher: () => userApi.getUser(userId), builder: (context, state, _) => switch (state) { Success(:final data) => UserCard(data), Loading() => const CircularProgressIndicator(), Failure(:final error) => ErrorView(error), _ => const SizedBox.shrink(), }, ) ``` ### Mutation Example ```dart [lib/data/user_api.dart] Future updateUser(String id, String name) async { final result = await _client.mutate( MutationOptions( document: gql(r''' mutation UpdateUser($id: ID!, $name: String!) { updateUser(id: $id, name: $name) { id name email } } '''), variables: {'id': id, 'name': name}, ), ); if (result.hasException) throw result.exception!; return User.fromJson(result.data!['updateUser']); } ``` ```dart [lib/features/user_edit_screen.dart] QoraMutationBuilder( mutator: (name) => userApi.updateUser(userId, name), options: MutationOptions( invalidates: [['user', userId]], ), builder: (context, state, mutate) { ... }, ) ``` --- ## Option B: Ferry Package Ferry provides a typed, code-generated GraphQL client with a built-in normalized cache. Use Qora as the caching layer for view state (UI reactivity, invalidation, SWR) while Ferry handles GraphQL transport and normalized entity storage: ```dart [lib/data/ferry_client.dart] import 'package:ferry/ferry.dart'; import 'package:gql_http_link/gql_http_link.dart'; import 'package:qora/qora.dart'; final ferryClient = Client( link: HttpLink('https://api.example.com/graphql'), cache: Cache(), ); ``` ### Query Fetcher ```dart [lib/data/user_api.dart] class UserApi { UserApi(this._client); final Client _client; Future getUser(String id) async { final req = GGetUserReq((b) => b..vars.id = id); final result = await _client.request(req).first; if (result.hasErrors) { throw result.graphqlErrors!.first; } return User.fromFragment(result.data!.user); } Future> listUsers({int first = 20, String? cursor}) async { final req = GListUsersReq((b) => b ..vars.first = first ..vars.after = cursor); final result = await _client.request(req).first; if (result.hasErrors) throw result.graphqlErrors!.first; return result.data!.users .map((u) => User.fromFragment(u)) .toList(); } } ``` ```dart [lib/features/user_list_screen.dart] QoraBuilder>( queryKey: ['users', first, cursor], fetcher: () => userApi.listUsers(first: first, cursor: cursor), options: const QoraOptions(staleTime: Duration(minutes: 2)), builder: (context, state, _) { ... }, ) ``` ### Mutation with Optimistic Update ```dart [lib/data/user_api.dart] Future updateUserName(String id, String name) async { final req = GUpdateUserNameReq((b) => b ..vars.id = id ..vars.name = name); final result = await _client.request(req).first; if (result.hasErrors) throw result.graphqlErrors!.first; return User.fromFragment(result.data!.updateUserName); } ``` ```dart [lib/features/user_edit_screen.dart] QoraMutationBuilder( mutator: (name) => userApi.updateUserName(userId, name), options: MutationOptions( onMutate: (name) { // Optimistic: apply the new name immediately to the cached user. final previous = context.qora.getQueryData(['user', userId]); context.qora.setQueryData(['user', userId], previous?.copyWith(name: name) ?? User(name: name)); return previous; }, onError: (error, variables, previous) { // Rollback on failure. if (previous != null) { context.qora.restoreQueryData(['user', userId], previous); } }, ), builder: (context, state, mutate) { ... }, ) ``` --- ## Normalized Cache Interaction Qora does not replace Ferry's or Apollo's normalized cache: it lives above it. Use Qora's key-based cache for UI state (is the data fresh? should I re-fetch on mount?), while the GraphQL cache handles normalized entity storage and identity. ### Strategy | Layer | Responsibility | | ---------------------------- | ------------------------------------------------------------------- | | Qora cache | SWR, invalidation, retry, polling, stale-time management | | GraphQL cache (Ferry/Apollo) | Normalized entities, data identity, deduplication of nested objects | A common pattern: after a mutation writes to the GraphQL cache, invalidate the corresponding Qora key so the widget refetches: ```dart [lib/features/user_edit_screen.dart] onSuccess: () { // The GraphQL cache is already updated. Invalidate Qora // so the widget tree picks up the fresh data on next rebuild. context.qora.invalidate(['user', userId]); }, ``` ### Write-through cache with Ferry Ferry's `Cache.writeFragment` can update normalized entities immediately. Combine with Qora's `setQueryData` for an instant UI update: ```dart [lib/data/user_api.dart] void optimisticUpdate(String userId, String name) { // Write to Ferry's normalized cache. _client.cache.writeFragment( GUserFragmentReq((b) => b..idFields = {'id': userId}).createRequest(), {'id': userId, 'name': name, '__typename': 'User'}, ); } ``` --- ## Error Handling GraphQL errors come in two forms: transport-level exceptions and `GraphQLError` objects in the response body. Map both to domain errors: ```dart [lib/data/graphql_error_mapper.dart] import 'package:graphql/client.dart'; import 'package:qora/qora.dart'; /// `errorMapper` must return a `QoraException`, so the domain error extends it. class GraphQLAppError extends QoraException { final bool isNetwork; const GraphQLAppError(super.message, {this.isNetwork = false, super.originalError}); } QoraException mapGraphQLError(Object error, StackTrace? stackTrace) { if (error is OperationException) { if (error.graphqlErrors.isNotEmpty) { final messages = error.graphqlErrors.map((e) => e.message).join(', '); return GraphQLAppError(messages, originalError: error); } if (error.linkException != null) { return GraphQLAppError( error.linkException.toString(), isNetwork: true, originalError: error, ); } } if (error is QoraException) return error; return QoraException(error.toString(), originalError: error, stackTrace: stackTrace); } ``` Register with Qora's global `errorMapper`: ```dart [lib/core/qora_setup.dart] final client = QoraClient( config: QoraClientConfig( errorMapper: mapGraphQLError, ), ); ``` --- ## Cursor-Based Pagination Use Qora's pagination support with cursor-based connections: ```dart [lib/data/user_api.dart] Future> listUsers({int first = 20, String? after}) async { final req = GListUsersReq((b) => b ..vars.first = first ..vars.after = after); final result = await _client.request(req).first; if (result.hasErrors) throw result.graphqlErrors!.first; final users = result.data!.users .map((u) => User.fromFragment(u)) .toList(); final pageInfo = result.data!.usersConnection.pageInfo; return Connection( nodes: users, hasNextPage: pageInfo.hasNextPage, endCursor: pageInfo.endCursor, ); } ``` ```dart [lib/features/user_list_screen.dart] class UserListScreen extends StatefulWidget { ... } class _UserListScreenState extends State { String? _cursor; @override Widget build(BuildContext context) { return QoraBuilder>( queryKey: ['users', 20, _cursor], fetcher: () => userApi.listUsers(after: _cursor), keepPreviousData: true, builder: (context, state, _) { final users = state.dataOrNull?.nodes ?? []; return ListView.builder( itemCount: users.length + 1, itemBuilder: (_, i) { if (i == users.length) { return TextButton( onPressed: () => setState(() => _cursor = state.dataOrNull?.endCursor), child: const Text('Load more'), ); } return UserTile(users[i]); }, ); }, ); } } ``` --- ## Testing GraphQL Fetchers Mock the GraphQL client transport link to avoid real network calls: ```dart [test/data/user_api_test.dart] import 'package:graphql/client.dart'; import 'package:qora/qora.dart'; import 'package:test/test.dart'; void main() { late GraphQLClient gqlClient; late QoraClient client; setUp(() { gqlClient = GraphQLClient( link: _MockLink(), // implements Link cache: GraphQLCache(), ); client = QoraClient(); }); test('fetcher returns parsed User on success', () async { final api = UserApi(gqlClient); final result = await client.fetchQuery( key: ['user', '1'], fetcher: () => api.getUser('1'), ); expect(result.name, 'Test User'); }); } ``` --- ## Next Steps - [Mutations](https://qora.meragix.dev/../recipes/mutations): Write operations with optimistic updates and automatic rollback - [Error Handling](https://qora.meragix.dev/../recipes/error-handling): Global `errorMapper` and per-query `transformError` - [Infinite Queries](https://qora.meragix.dev/../recipes/infinite-queries): Proper bi-directional pagination with `InfiniteQueryObserver` - [With Dio](https://qora.meragix.dev/with-dio): REST counterpart for teams using both REST and GraphQL # Case Study: User List & Detail ## Objective **Context**: Every network-backed screen needs to manage the same five concerns: loading state, error state, cached data, background refresh, and connectivity. With raw `FutureBuilder` or manual `setState`, each screen re-implements this logic independently. The code is verbose, inconsistent, and fragile under navigation. This example shows how `QoraBuilder` consolidates these concerns into a single declarative builder. Qora SWR caching allows users to reopen a visited screen without an additional network request. --- ## Key Features Demonstrated - **`QoraScope` setup**: `QoraClient` injected at the root with `FlutterLifecycleManager` and `FlutterConnectivityManager` - **`QoraBuilder`**: fetches on mount, subscribes to state changes, disposes cleanly - **Stale-While-Revalidate**: cached data shown instantly on re-navigation; background refetch only when stale - **`FetchStatus` as a second UI axis**: distinguishes active fetch (`fetching`), offline pause (`paused`), and idle without any custom boolean flags - **`previousData` graceful degradation**: stale data stays visible during background refresh and on soft errors - **Per-item query keys**: `['users', userId]` gives each user a separate, independent cache entry - **`invalidateWhere` predicate**: one call invalidates both the list and all open detail screens simultaneously - **Pull-to-refresh**: `RefreshIndicator` wired to `invalidateWhere`, no manual state required --- ## Architecture Highlights Widgets never own data: they declare **what they want** and Qora decides whether to fetch, serve from cache, or wait for connectivity: ```text ex_01_basic_query/lib/ ├── main.dart ← QoraClient + QoraScope at root ├── models/ │ └── user.dart ← pure data class, no Qora dependency ├── services/ │ └── fake_api.dart ← async functions (2 s simulated latency + ~30 % failure mode) └── screens/ ├── user_list_screen.dart ← QoraBuilder>, key: ['users'] └── user_detail_screen.dart ← QoraBuilder, key: ['users', userId] ``` `QoraClient` is configured once at the root. Screens access it via `context.qora`: the `BuildContextExtension` shortcut to `QoraScope.of(context)`. No `Provider`, no `ref`, no `context.read`: ```dart [lib/main.dart] final client = QoraClient( config: const QoraClientConfig( defaultOptions: QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), ), debugMode: kDebugMode, ), ); runApp( QoraScope( client: client, lifecycleManager: FlutterLifecycleManager(qoraClient: client), connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ), ); ``` `staleTime: 5 minutes` means: if you navigate to a screen visited less than 5 minutes ago, **no network call fires**: the cache serves data on the first frame. After 5 minutes, the cached data is shown immediately while Qora revalidates in the background. --- ## Implementation Details ### The two-dimensional state matrix Most state management solutions give you one axis: `loading / data / error`. Qora gives you **two**: - `QoraState`: **what you have**: `Initial`, `Loading`, `Success`, `Failure` - `FetchStatus`: **what Qora is doing right now**: `fetching`, `paused`, `idle` These two axes are independent. A widget can be in `Success` state (data is visible) while `FetchStatus` is `fetching` (SWR background refresh running): the user sees their data and a subtle "Updating…" banner, but no spinner, no blank screen. The `UserListScreen` renders all meaningful combinations without a single `bool _isLoading` or `bool _isOffline`: ```dart [lib/screens/user_list_screen.dart] QoraBuilder>( queryKey: const ['users'], fetcher: FakeApi.getUsers, options: const QoraOptions( staleTime: Duration(minutes: 5), gcTime: Duration(minutes: 10), ), builder: (context, state, fetchStatus) { // ── Axis 2: FetchStatus → top status banner ──────────────────────── final banner = switch (fetchStatus) { FetchStatus.fetching => const _StatusBanner( icon: Icons.sync, label: 'Updating…', color: Colors.blue, ), FetchStatus.paused => const _StatusBanner( icon: Icons.wifi_off, label: 'Offline: showing cached data', color: Colors.orange, ), FetchStatus.idle => const SizedBox.shrink(), }; // ── Axis 1: QoraState → main content ────────────────────────────── return switch (state) { // First load with no cache, or offline with no cache at all Initial() || Loading(previousData: null) => const Center(child: CircularProgressIndicator()), // Hard error: nothing to show Failure(:final error, previousData: null) => _ErrorScreen( error: error, onRetry: () => context.qora.invalidateWhere( (key) => key.firstOrNull == 'users', ), ), // Data available in ALL remaining cases: // Success → fresh data // Loading(data) → stale data, SWR running // Failure(data) → refresh failed, stale data still shown _ => Column( children: [ banner, if (state is Failure>) _ErrorBanner(error: state.error), // soft error: list still visible Expanded( child: RefreshIndicator( onRefresh: () async { context.qora.invalidateWhere( (key) => key.firstOrNull == 'users', ); await Future.delayed(const Duration(milliseconds: 300)); }, child: ListView.builder( itemCount: state.dataOrNull?.length ?? 0, itemBuilder: (_, i) => _UserTile(user: state.dataOrNull![i]), ), ), ), ], ), }; }, ) ``` The pattern `Loading(previousData: null)` is a Dart 3 destructure on the sealed class: it matches `Loading` **only when `previousData` is null**, i.e. the very first load with no cache. Any subsequent `Loading` state (SWR background refresh) carries `previousData` and falls through to the `_` arm, keeping the list visible. This is why users never see an empty screen or a full-page spinner on re-navigation. ### `invalidateWhere`: one call, every related key After pull-to-refresh, both `['users']` (the list) and every `['users', userId]` (open detail screens) need fresh data. A predicate covers them all: ```dart [lib/screens/user_list_screen.dart] context.qora.invalidateWhere((key) => key.firstOrNull == 'users'); // Invalidates: ['users'], ['users', '1'], ['users', '2'], … ``` Every active `QoraBuilder` subscribed to a matching key transitions to `Loading(previousData: data)` and re-fetches. Existing data stays visible: no blank screens anywhere in the navigation stack. ### Per-item cache and `updatedAt` on the detail screen Each `UserDetailScreen` uses a **parameterised key**: `['users', userId]`. This gives every user their own independent cache entry. The screen also reads `state.updatedAt`: the `DateTime` of the last successful fetch: to show a live "last fetched X minutes ago" timestamp, making the SWR behaviour **visible to the user**: ```dart [lib/screens/user_detail_screen.dart] QoraBuilder( queryKey: ['users', userId], // e.g. ['users', '1'] fetcher: () => FakeApi.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), builder: (context, state, fetchStatus) { return switch (state) { Initial() || Loading(previousData: null) => const Center(child: CircularProgressIndicator()), Failure(:final error, previousData: null) => _ErrorScreen(error), _ => Column( children: [ if (fetchStatus == FetchStatus.paused) const _Banner(label: 'Offline: showing cached data'), if (state is Failure) _Banner(label: 'Refresh failed: ${state.error}'), if (fetchStatus == FetchStatus.fetching) const LinearProgressIndicator(minHeight: 2), Expanded( child: _UserDetailView( user: state.dataOrNull!, updatedAt: state.updatedAt, // ← timestamp from Success state isStale: state is! Success, ), ), ], ), }; }, ) ``` Navigate to a user, go back, reopen: data appears on the **first frame**, `updatedAt` shows the original fetch time, and a 2 px `LinearProgressIndicator` fires only if the data was stale. The network call happens silently in the background. --- ## Validation Steps The example ships with an interactive guide to make the cache behaviour observable: 1. Open **User List** → wait 2 s for initial load 2. Go back and reopen → **instant from cache**, no network call 3. Pull to refresh → stale list stays visible; blue "Updating…" banner appears simultaneously 4. Open a user → note the **"Last fetched"** timestamp 5. Go back and reopen within 5 min → **instant from cache**, timestamp unchanged 6. Toggle airplane mode → orange "Offline" banner; list remains fully usable --- ## Source Code ```text examples/ex_01_basic_query/ ├── lib/ │ ├── main.dart ← QoraClient + QoraScope setup │ ├── models/user.dart ← pure data class │ ├── services/fake_api.dart ← simulated API (2 s delay, ~30 % failure mode) │ └── screens/ │ ├── user_list_screen.dart ← QoraBuilder + FetchStatus × QoraState matrix │ └── user_detail_screen.dart ← per-item key, updatedAt, previousData └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_01_basic_query){rel=""nofollow""} --- ## Related - [Quick Start](https://qora.meragix.dev/../getting-started/quick-start): step-by-step conceptual walkthrough of the same feature - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder): full widget API - [Stale-While-Revalidate](https://qora.meragix.dev/../core-concepts/stale-while-revalidate): how the cache freshness model works - [Query States](https://qora.meragix.dev/../core-concepts/query-states): `QoraState` sealed class reference # Case Study: Optimistic Mutations & Rollback ## Objective **Context**: Write operations introduce a latency problem. A round-trip to the server takes 1–3 seconds. Blocking the UI until the server responds produces sluggish interactions and breaks the perceived responsiveness of the application. Optimistic updates resolve this by applying the expected result to the local cache immediately, before the server responds. If the server confirms the operation, the optimistic data is replaced by the authoritative server response. If the server rejects it, the cache is restored to its pre-mutation state. This example implements the full optimistic update protocol using `QoraMutationBuilder`, `setQueryData`, and `restoreQueryData`. The simulated API fails \~30 % of the time to make rollback observable. --- ## Key Features Demonstrated - **`QoraMutationBuilder`**: manages `MutationController` lifecycle (create on mount, dispose on unmount), wires offline queue automatically - **`MutationOptions.onMutate`**: captures a pre-mutation cache snapshot (`TContext`) and applies `setQueryData` before the server call - **`MutationOptions.onError`**: calls `restoreQueryData` with the `TContext` snapshot to revert both affected cache entries simultaneously - **`MutationOptions.onSuccess`**: calls `invalidateWhere` to trigger background refetch and reconcile the cache with the server-confirmed state - **Cross-cache optimistic update**: a single mutation updates two independent cache entries (`['users', id]` and `['users']`) atomically via `TContext` - **`MutationState` pattern matching**: four states (`MutationIdle`, `MutationPending`, `MutationSuccess`, `MutationFailure`) handled exhaustively with Dart 3 switch expressions - **`QoraBuilder` + `QoraMutationBuilder` composition**: read and write operations coexist on the same screen without shared mutable state --- ## Architecture Highlights The mutation lifecycle is entirely managed by `QoraMutationBuilder`. The widget tree declares intent; Qora coordinates cache, network, and state transitions: ```text ex_02_mutations_optimistic/lib/ ├── main.dart ← QoraClient + QoraScope at root ├── models/ │ └── user.dart ← pure data class with copyWith ├── services/ │ └── fake_api.dart ← async API (2 s latency, ~30 % failure on updateUser) └── screens/ ├── user_list_screen.dart ← QoraBuilder>, key: ['users'] └── rename_user_sheet.dart ← QoraMutationBuilder + three-step optimistic protocol ``` `QoraClient` is configured once at the root. `UserListScreen` reads the user list via `QoraBuilder`. The edit action captures `context.qora` and passes it explicitly to `RenameUserSheet`, since modal routes run in a separate navigator overlay: ```dart [lib/screens/user_list_screen.dart] Future _showRenameSheet(BuildContext context, User user) { // Capture the client before the sheet opens: modal routes run in a // separate navigator overlay and may not resolve QoraScope on their own. final client = context.qora; return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (_) => RenameUserSheet(user: user, client: client), ); } ``` --- ## Implementation Details ### The three-step optimistic update protocol Every optimistic mutation follows three sequential hooks defined in `MutationOptions`. The type parameters encode the domain types precisely: - `TData = User`: value returned by the server on success - `TVariables = ({String id, String name})`: input to the mutator - `TContext = ({User? user, List? list})`: pre-mutation snapshot for rollback ```dart [lib/screens/rename_user_sheet.dart] QoraMutationBuilder( mutator: (vars) => FakeApi.updateUser(vars.id, name: vars.name), options: MutationOptions( onMutate: ..., // Step 1: snapshot + setQueryData onError: ..., // Step 2: restoreQueryData (rollback) onSuccess: ..., // Step 3: invalidateWhere (reconciliation) ), builder: (context, state, mutate) { ... }, ) ``` The hooks execute in sequence: `onMutate` runs before the mutator; `onError` or `onSuccess` run after it settles. The `TContext` value returned by `onMutate` is forwarded to both `onError` and `onSuccess` as the third parameter. ### Cross-cache atomic snapshot `onMutate` captures two independent cache entries before modifying either. The Dart 3 record type `({User? user, List? list})` carries both snapshots as a single typed value: ```dart [lib/screens/rename_user_sheet.dart] onMutate: (vars) async { // Capture the current data for both cache entries before mutating. final prevUser = client.getQueryData(['users', vars.id]); final prevList = client.getQueryData>(['users']); // Apply the optimistic name change to the per-user detail cache. if (prevUser != null) { client.setQueryData( ['users', vars.id], prevUser.copyWith(name: vars.name), ); } // Apply the same change to the matching entry inside the list cache. if (prevList != null) { client.setQueryData>( ['users'], prevList .map((u) => u.id == vars.id ? u.copyWith(name: vars.name) : u) .toList(), ); } // Return both snapshots as the rollback context (TContext). return (user: prevUser, list: prevList); }, ``` `setQueryData` pushes the new value to all active `QoraBuilder` streams subscribed to the matching key. Both `UserListScreen` and any open detail widget receive the update on the next frame, before the network call completes. ### Automatic rollback on failure `onError` receives the `TContext` snapshot returned by `onMutate`. `restoreQueryData` is a no-op when the snapshot is `null`, so null-safety is preserved without defensive branching: ```dart [lib/screens/rename_user_sheet.dart] onError: (error, vars, ctx) async { // Restore both cache entries from the snapshot returned by onMutate. client.restoreQueryData(['users', vars.id], ctx?.user); client.restoreQueryData>(['users'], ctx?.list); }, ``` When the server rejects the mutation, both cache entries revert to their pre-mutation values in a single synchronous operation. All subscribed widgets rebuild immediately, displaying the original data without any user intervention. ### Server reconciliation on success `onSuccess` uses `invalidateWhere` to mark all user-related cache entries as stale. Qora triggers background refetches for every active subscriber, replacing the optimistic data with the server-confirmed state: ```dart [lib/screens/rename_user_sheet.dart] onSuccess: (user, vars, _) async { // Trigger a background refetch for all user-related entries // so the cache reflects the server-confirmed state. client.invalidateWhere((key) => key.firstOrNull == 'users'); }, ``` The predicate matches both `['users']` and `['users', id]` simultaneously. Active subscribers transition to `Loading(previousData: data)`: the optimistic data remains visible during the background fetch, and the final server response replaces it without a blank-screen transition. ### `MutationState` pattern matching `QoraMutationBuilder` rebuilds whenever the `MutationController` emits a new state. The `builder` maps all four states to UI: ```dart [lib/screens/rename_user_sheet.dart] builder: (context, state, mutate) { return Column( children: [ TextField( controller: _nameController, enabled: !state.isPending, // disable input while the server call is in flight ... ), // Inline status feedback via Dart 3 switch expression switch (state) { MutationFailure(:final error) => _FeedbackBanner( icon: Icons.undo_rounded, message: 'Save failed, name restored. $error', color: Colors.red, ), MutationSuccess() => const _FeedbackBanner( icon: Icons.check_circle_outline, message: 'Saved. Cache updated from server.', color: Colors.green, ), _ => const SizedBox.shrink(), // MutationIdle, MutationPending }, FilledButton( onPressed: state.isPending ? null : () => mutate((id: user.id, name: name)), child: state.isPending ? const CircularProgressIndicator(strokeWidth: 2.5) : const Text('Save'), ), ], ); }, ``` `MutationIdle` and `MutationPending` share the `_` arm: the feedback banner is absent and the button is enabled or disabled by `state.isPending`. `MutationFailure` and `MutationSuccess` each render a distinct inline banner. No boolean flags, no `setState` calls. --- ## Validation Steps The example ships with an observable failure mode to make rollback visible: 1. Open **User List** and wait for the initial load 2. Tap the edit icon on any user 3. Enter a new name and tap **Save**: the list updates **immediately** in the background 4. Wait \~2 s for the server response: - On success (\~70 %): the new name persists; a green confirmation appears - On failure (\~30 %): the name reverts to the original; a red rollback banner appears 5. Tap the refresh icon in the app bar to force a full invalidation and observe the server-confirmed state --- ## Source Code ```text examples/ex_02_mutations_optimistic/ ├── lib/ │ ├── main.dart ← QoraClient + QoraScope setup │ ├── models/user.dart ← pure data class with copyWith │ ├── services/fake_api.dart ← simulated API (2 s delay, ~30 % failure on updateUser) │ └── screens/ │ ├── user_list_screen.dart ← QoraBuilder + edit trigger │ └── rename_user_sheet.dart ← QoraMutationBuilder + full optimistic protocol └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_02_mutations_optimistic){rel=""nofollow""} --- ## Related - [Optimistic Updates Recipe](https://qora.meragix.dev/../recipes/optimistic-updates): step-by-step conceptual walkthrough of the same pattern - [Mutations Recipe](https://qora.meragix.dev/../recipes/mutations): `QoraMutationBuilder` and `MutationOptions` API reference - [QoraMutationBuilder](https://qora.meragix.dev/../flutter-integration/qora-mutation-builder): full widget API - [Case Study: User List & Detail](https://qora.meragix.dev/basic-query): `QoraBuilder`, SWR caching, and `FetchStatus` # Case Study: Infinite Scroll Feed ## Objective **Context**: An infinite scroll feed sounds simple: load more data as the user scrolls. The production constraints are strict: after 30 minutes of scrolling, your `ListView` holds 2,000 widgets, memory spikes, and the OS terminates your app. Alternatively, if you use integer page numbers and a post is inserted mid-scroll, every subsequent page is offset by one and users see duplicates. This example demonstrates two core controls. Cursor-based pagination eliminates offset drift. Windowed paging with `maxPages` enforces a bounded in-memory footprint while allowing backward page recovery on demand. --- ## Key Features Demonstrated - **`InfiniteQueryBuilder`**: accumulates pages and exposes `fetchNextPage` / `fetchPreviousPage` through its controller - **Cursor-based pagination**: stable results regardless of server-side insertions - **`maxPages` windowed memory management**: caps the in-memory page window; drops oldest/newest when exceeded - **`isFetchingNextPage` + `isFetchingPreviousPage`**: drives the scroll-end loader without hiding existing content - **`InfiniteData.flatten()`**: converts the page matrix into a flat item list for `SliverList` - **Optimistic post creation**: `setInfiniteQueryData` prepends a new post instantly; rollback on failure - **`refetch()`**: pull-to-refresh that re-fetches all loaded pages while preserving the page count - **`InfiniteFailure` with `previousData`**: load-more errors show a retry button without clearing the feed --- ## Architecture Highlights The UI layer is built on a `CustomScrollView` with two slivers: a `CupertinoSliverRefreshControl` for pull-to-refresh and a `SliverList.builder` for the feed items. The `InfiniteQueryBuilder` wraps the scroll view and owns the observer lifecycle. ```text FeedScreen └── CustomScrollView ├── CupertinoSliverRefreshControl ← controller.refetch() on pull └── SliverList.builder ├── items = data.flatten((p) => p.posts) ← flat list from all pages ├── [last item] │ ├── hasNextPage → LoadMoreTrigger ← fires controller.fetchNextPage() │ └── isFetchingNextPage → PageLoader ← spinner at list bottom └── InfiniteFailure with previousData → ErrorRetryBanner ``` The `InfiniteQueryBuilder` is configured with a `String` cursor. An empty string means "start from the beginning": the server returns the most recent posts and a `nextCursor` pointing to older content. ```dart [lib/features/feed/ui/feed_screen.dart] InfiniteQueryBuilder( queryKey: const ['feed'], fetcher: FeedApi.getFeed, options: InfiniteQueryOptions( initialPageParam: '', // '' = latest posts getNextPageParam: (last, _) => last.nextCursor, // older posts getPreviousPageParam: (first, _) => first.previousCursor, // non-null only after an eviction maxPages: 3, // 3 to see eviction quickly; 8 to 10 in production baseOptions: const QoraOptions( staleTime: Duration(minutes: 2), retryCount: 3, ), ), builder: (context, state, controller) => FeedList(state: state, controller: controller), ) ``` `maxPages` bounds the `InfiniteData` held in memory. The example uses 3 so that eviction is visible after a few pages; at 20 posts per page, a production feed would typically use 8 to 10 (160 to 200 posts). --- ## Implementation Details ### Windowed paging: scrolling back in time When the user scrolls down far enough to load page 4, `maxPages: 3` causes page 1 (the newest posts) to be **dropped** from `InfiniteData`. The state's `hasPreviousPage` becomes `true`. If the user scrolls back up to the top, the `SliverList` detects this and calls `controller.fetchPreviousPage()`, which re-fetches the dropped page using `getPreviousPageParam`. This entire mechanism: drop, detect and re-fetch is why **cursor-based pagination is mandatory** with `maxPages`. Integer page numbers would break here: if 3 new posts were inserted while the user was scrolling, re-fetching "page 1" with `?page=1&limit=20` would return a different 20 posts than the first load. Cursors anchor to a specific server-side position. ```dart [lib/features/feed/ui/feed_screen.dart] void _onScroll() { final pos = _scrollController.position; // Trigger next page when within 300px of the bottom if (pos.extentAfter < 300 && _latestState case InfiniteSuccess(:final hasNextPage, :final isFetchingNextPage) when hasNextPage && !isFetchingNextPage) { controller.fetchNextPage(); } // Trigger previous page when within 300px of the top // (only relevant after maxPages eviction has dropped page 1) if (pos.extentBefore < 300 && _latestState case InfiniteSuccess(:final hasPreviousPage, :final isFetchingPreviousPage) when hasPreviousPage && !isFetchingPreviousPage) { controller.fetchPreviousPage(); } } ``` ### Optimistic post creation with `setInfiniteQueryData` When the user creates a new post, it should appear at the top of the feed instantly: before the server responds. Qora exposes `setInfiniteQueryData` to write directly into the infinite cache: ```dart [lib/features/feed/ui/compose_sheet.dart] Future _publish(String content) async { final client = context.qora; const feedKey = ['feed']; // 1. Snapshot for rollback final snapshot = client.getInfiniteQueryData(feedKey); // 2. Optimistic prepend to first page if (snapshot != null) { client.setInfiniteQueryData( feedKey, InfiniteData( pages: [ FeedPage( posts: [ Post.optimistic( id: 'temp-${DateTime.now().millisecondsSinceEpoch}', content: content, author: currentUser, ), ...snapshot.pages.first.posts, ], nextCursor: snapshot.pages.first.nextCursor, previousCursor: null, // optimistic post has no stable cursor yet ), ...snapshot.pages.skip(1), ], pageParams: snapshot.pageParams, ), ); } try { // 3. Fire the real mutation await api.createPost(content); // 4. Invalidate: replace the optimistic item with the server response // refetch() re-fetches all current pages preserving the page count await controller.refetch(); } catch (error) { // 5. Rollback: restore the exact pre-mutation snapshot if (snapshot != null) { client.setInfiniteQueryData(feedKey, snapshot); } _showError(error); } } ``` The critical detail here is **step 4**: calling `controller.refetch()` rather than `invalidateInfiniteQuery`. `refetch()` re-fetches all currently loaded pages (8 pages × 20 items = 160 posts re-validated in sequence), which replaces the temp-ID optimistic post with the real server object. `invalidateInfiniteQuery` would reset the state to `InfiniteInitial` and restart from page 1, losing the user's scroll position. ### `InfiniteFailure` with graceful degradation Load-more errors must never erase the feed the user already loaded. `InfiniteFailure` preserves `previousData`: the last successful `InfiniteData`: for exactly this purpose: ```dart [lib/features/feed/ui/feed_screen.dart] case InfiniteFailure(:final error, :final previousData): if (previousData != null) { final posts = previousData.flatten((p) => p.posts); return SliverList.builder( itemCount: posts.length + 1, // +1 for the error footer itemBuilder: (context, i) { if (i < posts.length) return PostTile(post: posts[i]); // Error footer at the very end return _LoadMoreErrorBanner( error: error, onRetry: controller.fetchNextPage, ); }, ); } // No data at all: full error screen return SliverFillRemaining(child: ErrorScreen(error)); ``` ### The `flatten` call `InfiniteData` holds a `List`: one item per loaded page. `flatten` extracts the inner list from each page and concatenates them: ```dart [lib/features/feed/ui/feed_screen.dart] case InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage): final posts = data.flatten((page) => page.posts); // List return SliverList.builder( itemCount: posts.length + 1, itemBuilder: (context, index) { if (index < posts.length) return PostTile(post: posts[index]); // Footer: spinner, "load more" trigger, or end-of-feed message if (isFetchingNextPage) return const _PageLoader(); if (hasNextPage) return _LoadMoreButton(onTap: controller.fetchNextPage); return const _EndOfFeedBanner(); }, ); ``` --- ## Source Code ```text examples/ex_03_infinite_scroll/ ├── lib/ │ ├── main.dart ← QoraClient + QoraScope setup │ ├── features/ │ │ └── feed/ │ │ ├── data/ │ │ │ └── feed_api.dart ← cursor-based HTTP calls │ │ ├── models/ │ │ │ ├── feed_page.dart ← { posts, nextCursor, previousCursor } │ │ │ └── post.dart ← Post.optimistic() constructor │ │ └── ui/ │ │ ├── feed_screen.dart ← InfiniteQueryBuilder + scroll logic │ │ ├── post_tile.dart ← single post widget │ │ ├── compose_sheet.dart ← optimistic post creation │ │ └── widgets/ │ │ ├── page_loader.dart ← bottom-of-list spinner │ │ ├── load_more_error.dart ← retry banner on page failure │ │ └── end_of_feed.dart ← "You're all caught up" banner └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_03_infinite_scroll){rel=""nofollow""} --- ## Performance Notes | Concern | Solution | | -------------------------------- | ------------------------------------------------------------------------------------------------------ | | Memory bloat from long sessions | `maxPages` caps the page window; evicted pages are re-fetched on demand | | Scroll jank from large lists | `SliverList.builder` renders only visible items; `InfiniteData` object itself is bounded | | Cursor drift from server inserts | Cursor-based pagination anchors each page to a stable server-side position | | Thundering-herd on `refetch()` | Pages are re-fetched **sequentially**, not concurrently | | Stale feed on return to tab | `staleTime: 2 minutes` triggers a background `refetch()` on tab re-focus via `FlutterLifecycleManager` | --- ## Related Guides - [Infinite Queries Guide](https://qora.meragix.dev/../recipes/infinite-queries): `InfiniteData`, `InfiniteQueryObserver` for pure Dart, windowed paging, and bi-directional scroll - [InfiniteQueryBuilder](https://qora.meragix.dev/../flutter-integration/infinite-query-builder): Flutter widget binding - [Optimistic Updates Guide](https://qora.meragix.dev/../recipes/optimistic-updates): `setQueryData`, `getQueryData`, `restoreQueryData` - [Infinite Query API Reference](https://qora.meragix.dev/../api-reference/infinite-query): full method reference # Case Study: Offline-First Todo App ## Objective Mobile connectivity drops all the time: tunnels, elevators, airplane mode. This example shows a todo app that stays usable through it: - the list is shown from the cache, and from storage on a cold start; - the UI knows when it is offline, instead of spinning forever; - todos created, toggled or deleted offline appear immediately and are sent in order when the network returns. There is no custom sync code: everything below is Qora configuration and two widgets. ::tip **Try it without touching your Wi-Fi.** The example ships a `SimulatedConnectivityManager` : the antenna button in the app bar switches the app between online and offline, and the fake API fails while offline. :: --- ## Features Demonstrated - **`PersistQoraClient`**: successful fetches are written to a `StorageAdapter` and restored by `hydrate()` before the first frame - **`NetworkMode.offlineFirst`**: cached data is served immediately; fetches resume when the device reconnects - **Offline mutation queue**: `offlineQueue: true` queues writes made offline and replays them in FIFO order on reconnect - **`onMutate` + `onError`**: the new todo is written into the list cache right away, and removed again if the server rejects it - **`FetchStatus.paused`**: tells the UI "no network" apart from "loading" - **`NetworkStatusIndicator`**: app-wide offline banner - **`ReconnectStrategy`**: limits concurrent refetches and adds jitter when the network returns --- ## Architecture `PersistQoraClient` extends `QoraClient`: `fetchQuery`, `watchQuery`, `invalidate` and the widgets work unchanged. The rest of the app does not know persistence exists. ```text main.dart ├── InMemoryStorageAdapter().init() ← storage backend (swap for your own adapter) ├── SimulatedConnectivityManager(api) ← online/offline toggle for the demo ├── PersistQoraClient(storage, ...) ← drop-in replacement for QoraClient ├── registerAllSerializers(client) ← every type written to storage ├── client.hydrate() ← restore saved data before runApp └── QoraScope( client: client, lifecycleManager: FlutterLifecycleManager(qoraClient: client), connectivityManager: connectivity, child: App(), ← OfflineBannerWrapper in MaterialApp.builder ) ``` ```dart [lib/main.dart] final storage = InMemoryStorageAdapter(); await storage.init(); final api = TodoApi(); final connectivity = SimulatedConnectivityManager(api); final client = PersistQoraClient( storage: storage, persistDuration: const Duration(days: 30), config: QoraClientConfig( defaultOptions: const QoraOptions( staleTime: Duration(minutes: 5), networkMode: NetworkMode.offlineFirst, ), // At most 3 concurrent refetches on reconnect, 150 ms jitter between them. reconnectStrategy: const ReconnectStrategy( maxConcurrent: 3, jitter: Duration(milliseconds: 150), ), debugMode: kDebugMode, ), ); registerAllSerializers(client); await client.hydrate(); // saved todos are in the cache before the first frame ``` In a real app, replace `SimulatedConnectivityManager` with `FlutterConnectivityManager()` (or use `QoraScope.auto`), and `InMemoryStorageAdapter` with an adapter backed by real storage. `StorageAdapter` has seven methods (`init`, `read`, `write`, `delete`, `keys`, `clear`, `dispose`); see the [Persistence recipe](https://qora.meragix.dev/../recipes/persistence). Serializers live in one function, which doubles as the list of every type written to storage: ```dart [lib/core/persistence/serializers.dart] void registerAllSerializers(PersistQoraClient client) { client.registerSerializer( QoraSerializer(toJson: (t) => t.toJson(), fromJson: Todo.fromJson), name: 'Todo', // explicit name: stable with --obfuscate and on the web ); client.registerSerializer>( QoraSerializer( toJson: (list) => list.map((t) => t.toJson()).toList(), fromJson: (json) => (json as List).map(Todo.fromJson).toList(), ), name: 'List', ); } ``` --- ## Implementation Details ### Creating a todo offline `onMutate` runs immediately, even offline. It writes a temporary todo into the `['todos']` cache, so the list updates at once, and returns the previous list as the rollback context. The mutation itself is queued until the device reconnects. ```dart [lib/features/todos/ui/todo_screen.dart] QoraMutationBuilder?>( queryKey: const ['todos'], mutator: api.createTodo, options: MutationOptions( offlineQueue: true, onMutate: (input) async { final prev = context.qora.getQueryData>(const ['todos']) ?? []; context.qora.setQueryData>(const ['todos'], [ ...prev, Todo( id: 'temp-${DateTime.now().millisecondsSinceEpoch}', title: input.title, completed: false, isPending: true, // local-only flag, never persisted ), ]); return prev; }, // Server rejected the todo: put the previous list back. onError: (_, __, prev) async => context.qora.restoreQueryData(const ['todos'], prev), // Server accepted it: refetch to replace the temporary id. onSuccess: (_, __, ___) async => context.qora.invalidate(const ['todos']), ), builder: (context, state, mutate) => FloatingActionButton.extended( onPressed: state.isPending ? null : () => _showAddDialog(context, mutate), icon: Icon(state.isPending ? Icons.schedule : Icons.add), label: Text(state.isPending ? 'Queued, syncing soon' : 'Add Todo'), ), ) ``` `optimisticResponse` is the alternative when you only need the mutation's own state (for example a "sent" confirmation) rather than an updated list: it makes the builder receive `MutationSuccess(isOptimistic: true)` immediately. ### Rendering pending todos `TodoTile` reads the local `isPending` flag to show that an item has not reached the server yet: ```dart [lib/features/todos/ui/todo_tile.dart] ListTile( leading: todo.isPending ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : Checkbox( value: todo.completed, onChanged: (_) => onToggle( ToggleTodoInput(id: todo.id, completed: !todo.completed), ), ), title: Text( todo.title, style: TextStyle( color: todo.isPending ? Colors.grey.shade500 : null, fontStyle: todo.isPending ? FontStyle.italic : FontStyle.normal, ), ), trailing: todo.isPending ? Icon(Icons.cloud_off, size: 16, color: Colors.grey.shade400) : null, ) ``` ### Why replay order matters ```text 1. Create "Buy milk" offline → queued as mutation #1 2. Mark it complete → queued as mutation #2 3. Network returns 4. #1 replays → server creates the todo 5. #2 replays → server marks it complete ``` Replay is sequential: #2 is sent only once #1 has completed. If the server rejects #1, its `onError` runs with the snapshot taken by `onMutate`, which removes the optimistic todo, and replay moves on to #2. A queued write is sent once: it is not retried on a later reconnect, so `onError` is where the user learns that it did not go through. ### Offline with nothing in the cache When the device is offline and nothing is cached, the fetch pauses instead of running: the state is `Loading` with no previous data, and `fetchStatus` is `FetchStatus.paused`. A spinner would spin until the network returns, so this case deserves its own screen: ```dart [lib/features/todos/ui/todo_screen.dart] QoraBuilder>( queryKey: const ['todos'], fetcher: api.getTodos, builder: (context, state, fetchStatus) { if (state.dataOrNull == null && fetchStatus == FetchStatus.paused) { return const OfflineEmptyState(); // "No connection: connect to load your todos" } return switch (state) { Initial() || Loading(previousData: null) => const Center(child: CircularProgressIndicator()), Failure(:final error, previousData: null) => ErrorView(error), // Data available: Success, or Loading/Failure with previousData _ => TodoList( todos: state.dataOrNull ?? const [], isRefreshing: fetchStatus == FetchStatus.fetching, isOffline: fetchStatus == FetchStatus.paused, ), }; }, ) ``` --- ## Limits to Know - **The mutation queue lives in memory.** It survives connectivity changes, not an app restart: writes still queued when the app is killed are lost. Persisting the queue is the focus of Qora 1.4. - **This demo uses `InMemoryStorageAdapter`**, so saved data is also reset when the process restarts. With a disk-backed adapter, `hydrate()` restores the list on a cold start. --- ## Source Code ```text examples/ex_04_offline_first/ ├── lib/ │ ├── main.dart ← PersistQoraClient, hydrate(), QoraScope │ ├── core/persistence/serializers.dart ← registerAllSerializers() │ ├── features/todos/ │ │ ├── models/todo.dart ← Todo with toJson / fromJson │ │ ├── data/todo_api.dart ← fake API that fails while offline │ │ └── ui/ │ │ ├── todo_screen.dart ← queries, mutations, FetchStatus handling │ │ └── todo_tile.dart ← pending rendering │ └── shared/ │ ├── connectivity/simulated_connectivity_manager.dart ← online/offline toggle │ └── widgets/offline_banner.dart ← NetworkStatusIndicator wrapper └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_04_offline_first){rel=""nofollow""} --- ## Related Guides - [Persistence](https://qora.meragix.dev/../recipes/persistence): `PersistQoraClient`, serializers, storage adapters, model migrations - [Network Aware](https://qora.meragix.dev/../recipes/network-aware): `NetworkMode`, `FetchStatus`, offline mutation queue, `ReconnectStrategy` - [Optimistic Updates](https://qora.meragix.dev/../recipes/optimistic-updates): `onMutate`, `setQueryData`, `restoreQueryData` - [PersistQoraClient API](https://qora.meragix.dev/../api-reference/persist-qora-client): full method reference # Case Study: Network Awareness ## Objective **Context**: When a device loses connectivity, a naive implementation either crashes (uncaught network exception), freezes (spinner that never resolves), or silently drops the user's last action (mutation lost forever). None of these are acceptable in a production app. This example demonstrates Qora's network-aware layer: queries **pause** automatically when offline and **replay** on reconnect, mutations are **queued** and replayed in FIFO order, and reconnect replay uses **controlled concurrency** so that a sudden flood of paused queries doesn't spike the server. --- ## Key Features Demonstrated - **`NetworkMode.online`** (default): queries pause at `FetchStatus.paused` when offline; replay automatically on reconnect - **`NetworkMode.offlineFirst`**: serves cached data immediately, schedules background revalidation when back online - **`NetworkMode.always`**: bypasses connectivity checks entirely (for local-DB-backed queries) - **`FetchStatus.paused`**: surfaces the "waiting for connection" state to the UI without any custom boolean - **`OfflineMutationQueue`**: FIFO queue; mutations fired offline are replayed in order on reconnect - **`ReconnectStrategy`**: `maxConcurrent` + `jitter` prevent thundering-herd when dozens of queries resume simultaneously - **`NetworkStatusIndicator`**: automatic offline banner widget, zero configuration - **`FlutterConnectivityManager`**: wires `connectivity_plus` into `QoraScope` in one line --- ## Architecture Highlights Network awareness is **orthogonal** to query state. A query can be in `Success` state (data visible) while `FetchStatus` is `paused` (connectivity lost, background revalidation waiting). The widget tree is never blocked: it always has something to render. ```text main.dart └── QoraScope( client: QoraClient(config: reconnectStrategy + networkMode) connectivityManager: FlutterConnectivityManager() ← plugs in connectivity_plus lifecycleManager: FlutterLifecycleManager(...) ← refetch on resume child: NetworkStatusIndicator( ← offline banner, zero config child: MyApp(), ), ) ``` The `QoraClient` is configured with a `ReconnectStrategy` that controls how paused queries are replayed when connectivity returns: ```dart [lib/main.dart] final client = QoraClient( config: QoraClientConfig( defaultOptions: const QoraOptions( networkMode: NetworkMode.online, // pause when offline, replay on reconnect staleTime: Duration(minutes: 5), ), reconnectStrategy: const ReconnectStrategy( maxConcurrent: 5, // replay 5 queries at a time jitter: Duration(milliseconds: 200), // random delay between batches ), debugMode: kDebugMode, ), ); runApp( QoraScope( client: client, connectivityManager: FlutterConnectivityManager(), lifecycleManager: FlutterLifecycleManager(qoraClient: client), child: NetworkStatusIndicator(child: const MyApp()), ), ); ``` --- ## Implementation Details ### `ReconnectStrategy`: thundering-herd prevention Consider an app with 20 queries active across 8 screens. The user takes the subway for 20 minutes: all 20 queries pause. On reconnect, without a strategy, 20 simultaneous requests fire. If 200 users come out of the same subway station at the same time, the server receives 4,000 simultaneous requests. `ReconnectStrategy(maxConcurrent: 5, jitter: Duration(milliseconds: 200))` staggers replay into batches of 5, with a random delay (up to 200 ms) between each batch: ```dart [lib/main.dart] const ReconnectStrategy( maxConcurrent: 5, jitter: Duration(milliseconds: 200), ) // Named constructors for common configurations: ReconnectStrategy.instant() // all at once: useful for tests ReconnectStrategy.conservative() // maxConcurrent: 2, jitter: 500ms: battery-conscious ``` The jitter is **random per client instance**: so even if 1,000 devices reconnect simultaneously, their replay windows are spread across a 200 ms window, distributing the load. ### `FetchStatus.paused`: accurate UI state without custom flags `QoraBuilder` exposes `FetchStatus` as its third builder argument. `paused` means "a fetch is queued but waiting for connectivity": distinct from `fetching` (actively running) and `idle` (nothing pending). This lets widgets render an accurate offline indicator without a single `bool _isOffline`: ```dart [lib/features/feed/ui/feed_screen.dart] QoraBuilder>( queryKey: const ['posts'], fetcher: api.getPosts, builder: (context, state, fetchStatus) { return switch (state) { // First load, offline: nothing to show yet Initial() || Loading(previousData: null) when fetchStatus == FetchStatus.paused => const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.wifi_off, size: 48, color: Colors.grey), SizedBox(height: 12), Text('No connection: connect to load posts'), ], ), ), // First load, online: standard spinner Initial() || Loading(previousData: null) => const Center(child: CircularProgressIndicator()), // Data available: show feed regardless of connectivity _ => Column( children: [ // Paused: offline but data visible if (fetchStatus == FetchStatus.paused) const _OfflineBanner(), // Fetching: SWR background revalidation running if (fetchStatus == FetchStatus.fetching) const LinearProgressIndicator(minHeight: 2), if (state is Failure>) _ErrorBanner(error: state.error), Expanded(child: PostList(posts: state.dataOrNull!)), ], ), }; }, ) ``` ### Offline mutation queue: write now, sync later Mutations fired while offline are enqueued and replayed in FIFO order on reconnect. The `isOptimistic` flag on `MutationSuccess` drives a "pending sync" visual so the UI is never misleading: ```dart [lib/features/feed/ui/compose_screen.dart] QoraMutationBuilder( mutator: (content) => api.createPost(content), options: MutationOptions( offlineQueue: true, optimisticResponse: (content) => Post.optimistic(content: content), onSuccess: (_, __, ___) async => context.qora.invalidate(const ['posts']), ), builder: (context, state, mutate) { final isQueued = state is MutationSuccess && state.isOptimistic; return Column( children: [ if (isQueued) const _SyncPendingBanner(), // "Will sync when online" SubmitButton( label: isQueued ? 'Queued' : 'Post', icon: isQueued ? Icons.schedule : Icons.send, onPressed: state.isPending ? null : () => mutate(_controller.text), ), ], ); }, ) ``` On reconnect, `QoraClient` replays the queue in FIFO order automatically. The `onSuccess` callback (which calls `invalidate`) fires only for **real** server confirmations (`isOptimistic: false`): never for the optimistic write. ### `NetworkMode.always` for local-database queries Not all queries need connectivity. A query backed by a local Hive box or SQLite database should never be paused: ```dart [settings_screen.dart] QoraBuilder( queryKey: const ['settings'], fetcher: localDb.getSettings, // reads from Hive: no network needed options: const QoraOptions( networkMode: NetworkMode.always, // never pauses, never waits for connectivity staleTime: Duration(hours: 1), ), builder: (context, state, _) { /* ... */ }, ) ``` This snippet is not part of the example app, which only talks to a remote API. Mix `NetworkMode.always` (local) and `NetworkMode.online` (remote) queries freely in the same screen: Qora handles them independently. --- ## Source Code ```text examples/ex_05_network_aware/ ├── lib/ │ ├── main.dart ← QoraClient + ReconnectStrategy + QoraScope │ ├── features/ │ │ └── feed/ │ │ ├── data/feed_api.dart ← HTTP layer │ │ ├── models/post.dart ← Post model │ │ └── ui/ │ │ ├── feed_screen.dart ← FetchStatus.paused handling │ │ └── compose_screen.dart ← OfflineMutationQueue + isOptimistic │ └── shared/ │ ├── connectivity/simulated_connectivity_manager.dart ← online/offline toggle for the demo │ └── widgets/ │ └── offline_banner.dart ← custom banner wrapped around NetworkStatusIndicator └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_05_network_aware){rel=""nofollow""} --- ## Related - [Network Aware Recipe](https://qora.meragix.dev/../recipes/network-aware): `NetworkMode`, `FetchStatus`, `OfflineMutationQueue`, and `ReconnectStrategy` in depth - [Network Status](https://qora.meragix.dev/../flutter-integration/network-status): `NetworkStatusIndicator` and `NetworkStatusBuilder` widgets - [Offline-First Case Study](https://qora.meragix.dev/offline-first): persistence across restarts (different concern from network awareness) - [QoraOptions API](https://qora.meragix.dev/../api-reference/qora-options): `networkMode`, `refetchOnReconnect` # Case Study: Hooks Integration ## Objective **Context**: `QoraBuilder` introduces nested builder callbacks on screens that combine read and write operations. This structure increases indentation depth and reduces maintainability on complex views. `qora_hooks` flattens this structure. `useQuery` and `useMutation` are declared at the top of `build()`, and the widget tree is rendered from plain values. Cache semantics and deduplication remain identical. --- ## Key Features Demonstrated - **`useQuery`**: subscribes to a query, returns `QoraState` as a plain value; re-renders on every state change - **`useMutation`**: returns a `MutationHandle` with `mutate`, `mutateAsync`, `isPending`, `isError`, `data`, `reset` - **`useInfiniteQuery`**: returns an `InfiniteQueryHandle` with `pages`, `fetchNextPage`, `hasNextPage`, `isFetchingNextPage` - **`useQueryClient`**: returns the nearest `QoraClient` for imperative operations (`invalidate`, `setQueryData`) - **`useIsFetching`**: global `bool`: `true` while any query has a request in flight: drives a top-level `LinearProgressIndicator` without coupling to individual queries - **`useIsMutating`**: global `bool`: `true` while any mutation is pending: disables submit buttons app-wide - **Hook composition**: multiple hooks in one `build()` method; they share the same underlying cache transparently --- ## Architecture Highlights The package is a thin adapter. Every hook calls the same `QoraClient` methods that `QoraBuilder` uses: `watchQuery`, `watchState`, `watchFetchStatus`: via `useEffect` and `useState`. The cache is shared: if a `QoraBuilder` already fetched `['users', id]`, `useQuery` gets it on the first frame from the same entry. ```text qora_hooks/ └── lib/src/hooks/ ├── use_query.dart ← useState(client.getQueryState) + useEffect(watchQuery.listen) ├── use_mutation.dart ← useRef(MutationController) + useState(MutationState) ├── use_infinite_query.dart ← useState(InfiniteQueryState) + useEffect(observer.stream.listen) ├── use_query_client.dart ← QoraScope.of(context) ├── use_is_fetching.dart ← client.fetchingCountStream.listen └── use_is_mutating.dart ← client.mutationEvents + activeMutations ``` Widgets must extend `HookWidget` (or `HookConsumerWidget` for Riverpod). Standard `StatelessWidget` and `StatefulWidget` are not supported: use `QoraBuilder` instead. --- ## Implementation Details ### Hook composition: a complete screen in one `build()` The flagship pattern: `useQuery` + `useMutation` + `useQueryClient` in the same `build()` method, no nesting, no custom `State`: ```dart [lib/features/profile/ui/profile_screen.dart] class ProfileScreen extends HookWidget { final String userId; const ProfileScreen({super.key, required this.userId}); @override Widget build(BuildContext context) { // ── Data ───────────────────────────────────────────────────────────── final client = useQueryClient(); final userQuery = useQuery( key: ['users', userId], fetcher: () => Api.getUser(userId), options: const QoraOptions(staleTime: Duration(minutes: 5)), ); final updateMutation = useMutation( mutator: Api.updateUser, options: MutationOptions( onSuccess: (user, _, __) async => client.invalidate(['users', userId]), ), ); // ── UI ─────────────────────────────────────────────────────────────── return Scaffold( appBar: AppBar( title: const Text('Profile'), actions: [ if (updateMutation.isError) IconButton( icon: const Icon(Icons.warning_amber, color: Colors.orange), tooltip: 'Save failed: tap to retry', onPressed: updateMutation.reset, ), ], ), body: switch (userQuery) { Initial() || Loading(previousData: null) => const Center(child: CircularProgressIndicator()), Failure(:final error, previousData: null) => ErrorScreen(error), _ => ProfileForm( user: userQuery.dataOrNull!, isSaving: updateMutation.isPending, saveError: updateMutation.error, onSave: (input) => updateMutation.mutate(input), ), }, ); } } ``` The equivalent using `QoraBuilder` + `QoraMutationBuilder` would require two nested `builder` callbacks and a `Stack` to overlay the save state: roughly 2× the line count with the same logic. ### `useQuery` is synchronous on cache hit The key implementation detail in `use_query.dart`: ```dart // Initialise from cache: avoids a loading flash when data is already fresh. final state = useState>(client.getQueryState(key)); ``` `getQueryState` is a **synchronous** read of the current cache entry. If another widget already fetched `['users', userId]` with `staleTime: 5 minutes`, `useQuery` returns `Success(data: user)` on the **first frame**: no `Initial` flash, no `Loading` state. This is identical to how `QoraBuilder` uses `watchState` (which also reads the current value synchronously before the stream starts). ### `useIsFetching`: global progress bar without coupling A common UX requirement: show a thin `LinearProgressIndicator` at the top of the app whenever *any* query is loading. With hooks, this is a one-liner in any widget, with zero knowledge of specific queries: ```dart [lib/shared/widgets/app_progress_bar.dart] class AppProgressBar extends HookWidget { const AppProgressBar({super.key}); @override Widget build(BuildContext context) { final isFetching = useIsFetching(); // true if ANY query has a request in flight return AnimatedOpacity( opacity: isFetching ? 1.0 : 0.0, duration: const Duration(milliseconds: 250), child: const LinearProgressIndicator(minHeight: 2), ); } } ``` `useIsFetching` only rebuilds when the value **crosses zero**: not on every count change. If 5 queries are running and one finishes, the widget does not rebuild (still `true`). It rebuilds only when the last query finishes (`true → false`) or when the first starts (`false → true`). ### `useInfiniteQuery`: infinite scroll without an observer The hooks API wraps `InfiniteQueryObserver` into a `InfiniteQueryHandle` with a flat interface: ```dart [lib/features/posts/ui/posts_screen.dart] class PostsScreen extends HookWidget { @override Widget build(BuildContext context) { final query = useInfiniteQuery( key: const ['posts'], fetcher: (cursor) => Api.getPosts(cursor: cursor), getNextPageParam: (page) => page.nextCursor, initialPageParam: null, ); if (query.isLoading) return const Center(child: CircularProgressIndicator()); if (query.error != null && query.pages.isEmpty) return ErrorScreen(query.error!); final posts = query.pages.expand((p) => p.posts).toList(); return ListView.builder( itemCount: posts.length + (query.hasNextPage ? 1 : 0), itemBuilder: (context, i) { if (i == posts.length) { // Auto-trigger next page when the sentinel item is built if (!query.isFetchingNextPage) query.fetchNextPage(); return const Center(child: CircularProgressIndicator()); } return PostTile(posts[i]); }, ); } } ``` No explicit `dispose` is required: the observer is tied to the hook's lifecycle and cleaned up automatically when the widget unmounts. --- ## Source Code ```text examples/ex_06_hooks_integration/ ├── lib/ │ ├── main.dart ← QoraClient + QoraScope setup │ ├── shared/ │ │ ├── api/json_placeholder_api.dart ← shared JSONPlaceholder client │ │ └── widgets/ │ │ └── app_progress_bar.dart ← useIsFetching global indicator │ └── features/ │ ├── profile/ │ │ ├── models/user.dart ← User model │ │ └── ui/profile_screen.dart ← useQuery + useMutation composition │ └── posts/ │ ├── models/posts_page.dart ← PostsPage model │ └── ui/posts_screen.dart ← useInfiniteQuery └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_06_hooks_integration){rel=""nofollow""} --- ## When to use hooks vs builders | Situation | Prefer | | --------------------------------------------- | ------------------------------------------------------------------------- | | Single query, simple screen | `QoraBuilder` or hooks | | Query + mutation on same screen | `useQuery` + `useMutation`: eliminates nesting | | Global loading/mutating indicator | `useIsFetching` / `useIsMutating`: impossible to do cleanly with builders | | Already using `flutter_hooks` in the project | `qora_hooks`: consistent style across the codebase | | `StatelessWidget` / `StatefulWidget` codebase | `QoraBuilder` / `QoraMutationBuilder`: hooks require `HookWidget` | --- ## Related - [Hooks Reference](https://qora.meragix.dev/../flutter-integration/hooks): full API for all hooks - [QoraBuilder](https://qora.meragix.dev/../flutter-integration/qora-builder): widget alternative to `useQuery` - [Mutations Recipe](https://qora.meragix.dev/../recipes/mutations): `useMutation` and `QoraMutationBuilder` patterns - [Infinite Scroll Case Study](https://qora.meragix.dev/infinite-scroll): `useInfiniteQuery` in a production feed # Case Study: Complete Todo App ## Objective **Context**: Real apps rarely use one feature in isolation. Authentication gates data access, infinite scroll fetches paginated server data, mutations need optimistic feedback with rollback, and connectivity is unreliable. Each concern is straightforward in isolation; the challenge is composing them without coupling. This example combines every production-grade Qora capability into a single app against the live [JSONPlaceholder](https://jsonplaceholder.typicode.com){rel=""nofollow""} API. The result is a reference implementation: each pattern is self-contained and extractable to a real project. --- ## Key Features Demonstrated - **`PersistQoraClient` + `HiveStorageAdapter`**: write-through persistence; `hydrate()` warms the cache before the first frame - **`AuthService` (`ValueNotifier`)**: user session state independent of Qora; gates which todo list is fetched - **`InfiniteQueryBuilder`**: cursor-based pagination with a sentinel-item scroll trigger - **`InfiniteQueryController.refetch()`**: pull-to-refresh that resets pagination and re-fetches from page 1 - **Optimistic create with offline queue**: `QoraMutationBuilder` with `offlineQueue: true`, `optimisticResponse`, and a `_pendingTodos` local list for instant UI feedback - **Per-tile toggle and delete mutations**: independent `QoraMutationBuilder` per tile; toggling one item never blocks another - **`SimulatedConnectivityManager`**: custom `ConnectivityManager` implementation for self-contained offline demo via an AppBar toggle - **`NetworkMode.offlineFirst`**: stale cache served instantly on reconnect; background revalidation triggered automatically - **`NetworkStatusIndicator`**: zero-boilerplate offline banner using the built-in widget --- ## Architecture Highlights The app is organised into feature modules with a shared infrastructure core. Qora APIs surface only at the UI layer; models, API clients, and serializers have no Qora dependency: ```text ex_07_complete_todo_app/lib/ ├── main.dart ← bootstrap: PersistQoraClient, hydrate, QoraScope ├── core/ │ ├── auth/ │ │ └── auth_service.dart ← ValueNotifier, login/logout │ ├── connectivity/ │ │ └── simulated_connectivity_manager.dart ← ConnectivityManager implementation (toggle button) │ └── persistence/ │ ├── hive_storage_adapter.dart ← StorageAdapter over Hive box │ └── serializers.dart ← registerAllSerializers() ├── features/ │ ├── auth/ui/login_screen.dart ← form, calls AuthService.login() │ └── todos/ │ ├── data/todo_api.dart ← JSONPlaceholder HTTP client │ ├── models/todo.dart ← Todo, TodosPage, CreateTodoInput, ToggleTodoInput │ └── ui/ │ ├── todo_list_screen.dart ← all Qora patterns composed together │ └── todo_tile.dart ← presentation widget, decoupled from mutations └── shared/widgets/offline_banner.dart ← OfflineBannerWrapper using NetworkStatusIndicator ``` ### Bootstrap sequence ```dart [lib/main.dart] Future main() async { WidgetsFlutterBinding.ensureInitialized(); final storage = await HiveStorageAdapter.open('qora_cache'); final connectivity = SimulatedConnectivityManager(); final client = PersistQoraClient( storage: storage, persistDuration: const Duration(days: 30), config: QoraClientConfig( defaultOptions: const QoraOptions( staleTime: Duration(minutes: 5), networkMode: NetworkMode.offlineFirst, ), reconnectStrategy: const ReconnectStrategy( maxConcurrent: 3, jitter: Duration(milliseconds: 150), ), debugMode: kDebugMode, ), ); registerAllSerializers(client); await client.hydrate(); // ← warm cache before first frame runApp( QoraScope( client: client, connectivityManager: connectivity, lifecycleManager: FlutterLifecycleManager(qoraClient: client), child: OfflineBannerWrapper(child: _App(auth: AuthService(), connectivity: connectivity)), ), ); } ``` `AuthService` extends `ValueNotifier`. `_App` listens to it with `ValueListenableBuilder` and navigates between `LoginScreen` and `TodoListScreen` reactively, no routing library, no extra state management layer. --- ## Implementation Details ### Infinite scroll with sentinel pattern `InfiniteQueryBuilder` fetches paginated todos from JSONPlaceholder. The sentinel pattern avoids a separate scroll controller and `notificationPredicate`: ```dart [lib/features/todos/ui/todo_list_screen.dart] InfiniteQueryBuilder( queryKey: ['todos', auth.id], fetcher: (page) => api.getTodosPage(auth.id, page), options: InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null, ), builder: (context, state, controller) { // ... state handling final allItems = [ ..._pendingTodos, // optimistic items first ...state.data!.flatten((p) => p.todos), ]; return RefreshIndicator( onRefresh: () => controller.refetch(), child: ListView.builder( itemCount: allItems.length + 1, // +1 for sentinel itemBuilder: (context, index) { if (index == allItems.length) { // Sentinel: auto-fetch next page when scrolled into view if (state.hasNextPage && !state.isFetchingNextPage) { controller.fetchNextPage(); } return state.isFetchingNextPage ? const _PageLoader() : const SizedBox.shrink(); } final todo = allItems[index]; return todo.isOptimistic ? TodoTile(todo: todo) // optimistic: no mutation callbacks : _MutableTodoTile(todo: todo, api: api, auth: auth); }, ), ); }, ) ``` `_pendingTodos` is a local `List` in the screen's `State`. It holds optimistic items created while a mutation is in-flight or queued offline. These items are prepended to the list and rendered with the `isOptimistic` visual style in `TodoTile`. When the mutation succeeds, the list is invalidated and `_pendingTodos` is cleared. ### Optimistic create with offline queue The create mutation uses a `TContext` snapshot to enable rollback: ```dart [lib/features/todos/ui/todo_list_screen.dart] QoraMutationBuilder>( mutator: api.createTodo, options: MutationOptions( offlineQueue: true, optimisticResponse: (input) => Todo.optimistic(title: input.title), onMutate: (input) async { // Snapshot current pending list for rollback final snapshot = List.from(_pendingTodos); setState(() => _pendingTodos.add(Todo.optimistic(title: input.title))); return snapshot; }, onSuccess: (_, __, ___) async { setState(() => _pendingTodos.clear()); context.qora.invalidate(['todos', auth.id]); }, onError: (_, __, snapshot) async { // Restore pre-mutation pending list setState(() => _pendingTodos = snapshot ?? []); }, ), builder: (context, state, mutate) { return FloatingActionButton( onPressed: state.isPending ? null : () => _showCreateDialog(mutate), child: state.isPending ? const CircularProgressIndicator(strokeWidth: 2) : const Icon(Icons.add), ); }, ) ``` When offline, `offlineQueue: true` causes the mutation to emit `MutationSuccess(isOptimistic: true)` immediately. The todo appears in the list with the pending style. On reconnect, the queue replays the mutation and `onSuccess` fires, which clears `_pendingTodos` and invalidates the cache to fetch the server-confirmed item. ### Per-tile toggle and delete Each tile wraps two independent `QoraMutationBuilder` instances. They are nested inside `_MutableTodoTile`, not in the list screen, so their state is scoped to the tile widget lifecycle: ```dart [lib/features/todos/ui/todo_list_screen.dart] class _MutableTodoTile extends StatelessWidget { @override Widget build(BuildContext context) { return QoraMutationBuilder( mutator: api.toggleTodo, options: MutationOptions( onSuccess: (_, __, ___) async => context.qora.invalidate(['todos', auth.id]), ), builder: (context, toggleState, toggle) { return QoraMutationBuilder( mutator: api.deleteTodo, options: MutationOptions( onSuccess: (_, __, ___) async => context.qora.invalidate(['todos', auth.id]), ), builder: (context, deleteState, delete) { return TodoTile( todo: todo, onToggle: toggleState.isPending ? null : () => toggle(ToggleTodoInput(id: todo.id, completed: !todo.completed)), onDelete: deleteState.isPending ? null : () => delete(todo.id), ); }, ); }, ); } } ``` Toggling item A and deleting item B are completely independent operations. Each `QoraMutationBuilder` has its own `MutationController` lifecycle. A server error on item A does not affect item B's state. ### Graceful degradation on infinite query failure `InfiniteFailure` with `previousData` keeps the feed visible when a page load fails: ```dart [lib/features/todos/ui/todo_list_screen.dart] case InfiniteFailure(:final error, :final previousData): if (previousData != null) { // Show stale data + error banner at top return Column( children: [ _ErrorBanner(error: error, onRetry: controller.fetchNextPage), Expanded(child: _buildList(previousData, controller)), ], ); } // No data at all: full error screen with retry return _ErrorView(error: error, onRetry: controller.refetch); ``` ### `SimulatedConnectivityManager` A custom `ConnectivityManager` that toggles between online and offline via an AppBar button. This removes any runtime dependency on `connectivity_plus` for the demo: ```dart [lib/core/connectivity/simulated_connectivity_manager.dart] class SimulatedConnectivityManager implements ConnectivityManager { final _controller = StreamController.broadcast(); NetworkStatus _status = NetworkStatus.online; @override Stream get statusStream => _controller.stream; @override NetworkStatus get currentStatus => _status; void toggle() { _status = _status == NetworkStatus.online ? NetworkStatus.offline : NetworkStatus.online; _controller.add(_status); } @override void dispose() => _controller.close(); } ``` The AppBar button calls `connectivity.toggle()`. All active queries pause immediately (`FetchStatus.paused`), and any mutations fired while offline are queued. On toggle back to online, the queue replays automatically. --- ## Validation Steps 1. Log in with any user ID (1–10) → todos load from JSONPlaceholder 2. Scroll to bottom → next page auto-loads via sentinel trigger 3. Pull to refresh → list resets and re-fetches from page 1 4. Add a todo → appears instantly in the list with pending style 5. Toggle the wifi icon in the AppBar → offline banner appears; existing todos remain visible 6. Add another todo while offline → appears with pending style; "queued" state on FAB 7. Toggle wifi back → queued mutation replays; list refreshes with server-confirmed data 8. Toggle a todo → checked/unchecked immediately; server sync in background 9. Delete a todo → removed immediately; server confirms in background 10. Force-kill and reopen the app → cached auth and todos restore on first frame (within stale window) --- ## Source Code ```text examples/ex_07_complete_todo_app/ ├── lib/ │ ├── main.dart │ ├── core/ │ │ ├── auth/auth_service.dart │ │ ├── connectivity/simulated_connectivity_manager.dart │ │ └── persistence/ │ │ ├── hive_storage_adapter.dart │ │ └── serializers.dart │ ├── features/ │ │ ├── auth/ui/login_screen.dart │ │ └── todos/ │ │ ├── data/todo_api.dart │ │ ├── models/todo.dart │ │ └── ui/ │ │ ├── todo_list_screen.dart │ │ └── todo_tile.dart │ └── shared/widgets/offline_banner.dart └── pubspec.yaml ``` [View full source code](https://github.com/meragix/qora/tree/main/examples/ex_07_complete_todo_app){rel=""nofollow""} --- ## Patterns at a glance | Pattern | File | Qora API | | --------------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | | Bootstrap + persistence | `main.dart` | `PersistQoraClient`, `QoraScope`, `.hydrate()` | | Auth-gated query key | `todo_list_screen.dart` | `['todos', auth.id]` key | | Infinite scroll sentinel | `todo_list_screen.dart` | `InfiniteQueryBuilder`, `InfiniteQueryController` | | Pull-to-refresh | `todo_list_screen.dart` | `controller.refetch()` | | Optimistic create + offline queue | `todo_list_screen.dart` | `QoraMutationBuilder`, `offlineQueue`, `optimisticResponse`, `onMutate` | | Per-tile independent mutations | `todo_list_screen.dart` | Nested `QoraMutationBuilder` | | Simulated offline toggle | `simulated_connectivity_manager.dart` | `ConnectivityManager` interface | | Offline banner | `offline_banner.dart` | `NetworkStatusIndicator` | --- ## Related - [Offline-First Case Study](https://qora.meragix.dev/offline-first): `PersistQoraClient` deep-dive - [Infinite Scroll Case Study](https://qora.meragix.dev/infinite-scroll): `InfiniteQueryBuilder` patterns - [Optimistic Mutations Case Study](https://qora.meragix.dev/mutations-optimistic): rollback protocol - [Network Awareness Case Study](https://qora.meragix.dev/network-awareness): `NetworkMode`, `ReconnectStrategy` # FAQ ## Core API ### What is the difference between `fetchQuery` and `watchQuery`? `fetchQuery` executes a one-time fetch and returns a `Future`. It does not subscribe to state changes. `watchQuery` returns a `Stream>` that emits on every state transition. It triggers a fetch on the first subscription and handles stale-while-revalidate, deduplication, and lifecycle events automatically. Use `watchQuery` whenever a widget needs to reflect live cache state. --- ### What is the difference between `getQueryData` and `getQueryState`? `getQueryData(key)` returns `T?`: the raw data value from the current cache entry, or `null` if no data is available. `getQueryState(key)` returns `QoraState`: the full sealed state including the variant (`Initial`, `Loading`, `Success`, `Failure`) and all associated fields such as `updatedAt` and `previousData`. Use `getQueryData` when you only need the data value (for example, to snapshot before an optimistic write). Use `getQueryState` when you need the full state for display or branching logic. --- ### What is the difference between `restoreQueryData` and `setQueryData`? `setQueryData(key, T data)` requires a non-null `T`. It always produces a `Success` state with `updatedAt: DateTime.now()`. `restoreQueryData(key, T? data)` accepts `T?`. When `data` is `null`, it calls `removeQuery(key)`, which restores `Initial` state. When `data` is non-null, it calls `setQueryData(key, data)`. This makes `restoreQueryData` the correct choice for rollback: the snapshot from `getQueryData` may be `null` when there was no prior data, and passing that `null` to `restoreQueryData` removes the optimistic entry cleanly. --- ### What is the difference between `invalidate`, `invalidateWhere`, and `invalidateQueries`? `invalidate(key)` marks a single specific key as stale. `invalidateWhere(bool Function(List key) predicate)` marks every cached key that satisfies the predicate as stale. Use this to invalidate a family of related keys with one call (for example, all keys starting with `'users'`). `invalidateQueries({required QueryFilter filter})` is the typed counterpart to `invalidateWhere`, accepting a `QueryFilter` object rather than a raw predicate. All three trigger a background refetch for every currently-active subscriber on the affected keys. --- ### What is the difference between `removeQuery` and `invalidate`? `invalidate(key)` marks the entry as stale and triggers a background refetch for active subscribers. The entry remains in cache. `removeQuery(key)` deletes the entry from cache entirely. Active subscribers transition to `Initial`. On `PersistQoraClient`, it also deletes the persisted entry from `StorageAdapter`. --- ### What is `QoraStatus` and when should I use it over the sealed class? `QoraStatus` is a plain enum (`initial | loading | success | error`) accessible via `state.status`. It is useful when branching logic does not need access to the state's associated fields (for example, toggling a spinner via `state.status.isLoading`). When you need `data`, `error`, `previousData`, or `updatedAt`, pattern-match the sealed class directly. --- ## Cache Semantics ### What is the difference between `staleTime` and `gcTime`? `staleTime` controls freshness. Data within the freshness window is served from cache without a network call. After `staleTime` elapses, data is still served from cache, but a background revalidation is triggered simultaneously (stale-while-revalidate). `gcTime` controls eviction. When the last `watchQuery` subscriber cancels, a GC timer is started. If no new subscriber attaches before `gcTime` elapses, the entry is removed from cache. The next access starts from `Initial`. A common configuration: `staleTime < gcTime`. Data goes stale and triggers background revalidation, but the entry survives long enough that navigating back to a screen is instant. --- ### When does a `QoraBuilder` refetch on mount? By default (`refetchOnMount: true` at the `QoraClientConfig` level), `watchQuery` triggers a fetch on the first subscription regardless of existing cache state. When `refetchOnMount: false` (set per-query via `QoraOptions` or globally via `QoraClientConfig`), `watchQuery` only fetches when the entry is in `Initial` state. If any cached state exists, including a stale `Success`, no fetch is triggered on mount. --- ### What happens when `maxCacheSize` is reached? When the cache reaches `maxCacheSize`, Qora evicts the least-recently-used **inactive** entry (an entry with no active `watchQuery` subscribers). Active entries are never evicted by LRU pressure. The `onCacheEvict` callback in `QoraClientConfig` is invoked for each evicted entry. --- ## Flutter Integration ### What is the difference between `QoraBuilder` and `QoraStateBuilder`? `QoraBuilder` calls `watchQuery` on mount, which triggers a fetch and subscribes to state changes. It takes a 3-parameter builder: `Widget Function(BuildContext, QoraState, FetchStatus)`. `QoraStateBuilder` calls `watchState` instead. It observes existing cache state but never triggers a fetch. It takes a 2-parameter builder: `Widget Function(BuildContext, QoraState)`. Use it when another widget owns the fetch lifecycle and this widget should only display the result. --- ### How do I get a global loading indicator? With `qora_hooks`, use `useIsFetching()`. It returns `true` while any query has a request in flight and rebuilds only when the value crosses the zero boundary: ```dart [lib/shared/widgets/app_progress_bar.dart] class AppProgressBar extends HookWidget { const AppProgressBar({super.key}); @override Widget build(BuildContext context) { final isFetching = useIsFetching(); return AnimatedOpacity( opacity: isFetching ? 1.0 : 0.0, duration: const Duration(milliseconds: 250), child: const LinearProgressIndicator(minHeight: 2), ); } } ``` Without hooks, subscribe to `client.fetchingCountStream` directly. --- ### Do I need `FlutterLifecycleManager` and `FlutterConnectivityManager`? Both are optional. Without `FlutterLifecycleManager`, queries do not refetch when the app returns to the foreground. Without `FlutterConnectivityManager`, queries do not pause when offline and do not replay on reconnect: all fetches always proceed regardless of connectivity status. For offline-aware apps, provide both. For apps running in controlled environments (dashboards, internal tools), omitting them is acceptable. `FlutterConnectivityManager` takes no constructor parameters. `FlutterLifecycleManager` requires `qoraClient: client`. --- ### Can I use Qora without Flutter? Yes. The `qora` package is pure Dart with no Flutter dependency. `QoraClient`, `QoraState`, `QoraOptions`, `MutationController`, and `PersistQoraClient` are all available in non-Flutter environments (Dart CLI, server-side, tests). The `qora_flutter` package adds `QoraScope`, `QoraBuilder`, `QoraMutationBuilder`, `InfiniteQueryBuilder`, `FlutterLifecycleManager`, `FlutterConnectivityManager`, and `NetworkStatusIndicator`. --- ## Persistence ### Do I need to call `StorageAdapter.init()` before `hydrate()`? Yes. Call `await storage.init()` before calling `await client.hydrate()`. Calling `hydrate()` before `init()` results in undefined behavior for adapters that open file handles or database connections during initialization. The correct startup sequence: ```dart [main.dart] await storage.init(); client.registerSerializer(userSerializer, name: 'User'); await client.hydrate(); runApp(...); ``` --- ### Why is my serializer `name` important? The `name` string is stored alongside each persisted entry as a type discriminator. During `hydrate()`, Qora uses it to find the correct `fromJson` converter. If `name` is omitted, it defaults to `T.toString()`, which is mangled by Dart obfuscation (`--obfuscate`) and minification in Flutter Web builds. Always pass an explicit `name` for types that will be persisted in release builds. Keep the `name` stable across app versions: changing it invalidates all previously persisted entries for that type. --- ## Mutations ### Should I use `QoraMutationBuilder` or `useMutation`? `QoraMutationBuilder` works in any `StatelessWidget` or `StatefulWidget`. `useMutation` requires `HookWidget`. Both create and dispose a `MutationController` tied to the widget lifecycle. On screens that combine a query and a mutation, `useMutation` alongside `useQuery` avoids nested builder callbacks and reduces indentation depth. On screens with only a mutation or in codebases that do not use `flutter_hooks`, `QoraMutationBuilder` is the correct choice. --- ### When does `onSuccess` run for offline-queued mutations? `onSuccess` runs only when the mutation completes with a real server response (`isOptimistic: false`). It does not run when an optimistic response is applied locally while offline. This ensures that cache invalidation via `invalidate` or `setQueryData` happens only after the server has confirmed the write. --- ## DevTools ### Which DevTools surface should I use? The in-app overlay (`qora_devtools_overlay`) is stable and available today. It renders inside the running app, and renders nothing in release builds. A Flutter DevTools extension is in development and not published. Use the overlay. --- ### Why is the overlay empty? Confirm that the app runs in debug mode (`flutter run`, not `--release` or `--profile`): the overlay builds nothing otherwise. Then confirm that the **same** `OverlayTracker` instance is passed to `QoraClient(tracker: ...)` and to `QoraInspector(tracker: ...)`. A client created without the tracker emits no events. # Troubleshooting ## Enable debug mode first Enable `debugMode` before any investigation. Qora logs cache hits, cache misses, persistence writes, hydration skips, and serializer errors with the `[Qora]` prefix. ```dart [main.dart] final client = QoraClient( // or PersistQoraClient config: const QoraClientConfig(debugMode: true), ); ``` --- ## Persistence & Hydration ### Data is not persisted across restarts **Symptom**: `PersistQoraClient` is configured, but the application always starts with a loading state and no cached data. **Cause**: No `QoraSerializer` is registered for the fetched type. Qora caches the data in memory but does not persist it to disk. ```dart [main.dart] // Good: Register before calling hydrate() client.registerSerializer>( QoraSerializer( toJson: (posts) => posts.map((p) => p.toJson()).toList(), fromJson: (json) => (json as List).map(Post.fromJson).toList(), ), name: 'List', ); await client.hydrate(); ``` With `debugMode: true`, Qora logs the skipped persistence event for unregistered types. --- ### `hydrate()` runs but the cache is still empty **Cause**: `registerSerializer` is called after `hydrate()`. During hydration, Qora resolves serializers from stored `typeName` values. Entries without a matching serializer are skipped. ```dart [main.dart] // Wrong order await client.hydrate(); client.registerSerializer(...); // too late. User entries are skipped // Correct order client.registerSerializer(...); await client.hydrate(); runApp(...); ``` --- ### Cache is warm but widgets still show a spinner on first frame **Cause**: `hydrate()` is not awaited before `runApp`. The widget tree is mounted before hydration completes. ```dart [main.dart] // Bad: hydrate() fires but isn't awaited void main() { client.hydrate(); // no await runApp(QoraScope(client: client, child: const MyApp())); } // Good: Await before runApp Future main() async { WidgetsFlutterBinding.ensureInitialized(); await storage.init(); await client.hydrate(); runApp(QoraScope(client: client, child: const MyApp())); } ``` --- ### Hydration breaks after a Flutter Web or `--obfuscate` build **Cause**: Without an explicit `name`, `registerSerializer` uses `T.toString()` as the type discriminator. Obfuscation changes this value. Persisted entries from one build mode do not match discriminators in another build mode. ```dart [main.dart] // Unsafe. Name becomes 'a' under obfuscation. client.registerSerializer(userSerializer); // Good: Stable across all build modes client.registerSerializer(userSerializer, name: 'User'); client.registerSerializer>(postListSerializer, name: 'List'); ``` The serializer `name` must remain stable across app versions. A changed `name` invalidates previously persisted entries for that type. --- ### `fromJson` crashes on startup after a model change **Cause**: A required field was introduced between app versions. Persisted JSON from older versions does not include the field, and `fromJson` throws. `PersistQoraClient` catches `fromJson` exceptions during hydration, removes the invalid entry, and continues execution. The entry is lost and the next read triggers a network fetch. Implement defensive parsing to reduce data loss: ```dart [models/user.dart] factory User.fromJson(dynamic json) { final map = json as Map; return User( id: map['id'] as int, name: map['name'] as String, // Added in v2. Default for entries persisted by v1. role: map['role'] as String? ?? 'viewer', ); } ``` For breaking changes such as field type changes or field removal without a safe default, bump the serializer `name` from `'User'` to `'User_v2'`. Qora skips old entries and rewrites fresh data after the next successful fetch. --- ## Query State ### Query is stuck in `Initial` and never fetches **Cause A**: `enabled: false` is configured on `QoraBuilder` or `QoraOptions`. The widget observes state but does not trigger fetching. ```dart // Verify this configuration: QoraBuilder( enabled: someVariable, // false keeps state at Initial ... ) ``` **Cause B**: `QoraStateBuilder` is used instead of `QoraBuilder`. `QoraStateBuilder` is observation-only and never calls `fetchQuery`. --- ### Query is stuck in `Loading` indefinitely **Cause A**: `dependsOn` is configured, and the dependency query does not reach `Success`. `watchQuery` waits until dependency resolution before executing the dependent fetch. ```dart // Validate dependency and key declarations. client.watchQuery( key: ['profile'], fetcher: () { final auth = client.getQueryData(['auth'])!; return api.getProfile(auth.userId); }, options: const QoraOptions(dependsOn: ['auth']), ); ``` **Cause B**: `fetchQuery` is used with `dependsOn`. `fetchQuery` throws `StateError` when dependencies are unresolved. Use `watchQuery` for reactive dependency handling. **Cause C**: `NetworkMode.online` is active without a `ConnectivityManager`. In this configuration, fetches always run. With `FlutterConnectivityManager` and no connectivity, queries transition to `Loading(previousData: null)` with `FetchStatus.paused` and resume automatically after reconnection. --- ### Query does not refetch when the app comes back to foreground **Cause**: `FlutterLifecycleManager` is not provided to `QoraScope`. ```dart [main.dart] // Bad: Missing lifecycleManager QoraScope(client: client, child: const MyApp()) // Good QoraScope( client: client, lifecycleManager: FlutterLifecycleManager(qoraClient: client), child: const MyApp(), ) ``` Also verify that `refetchOnWindowFocus: true` remains enabled in `QoraClientConfig.defaultOptions`. --- ### Query does not refetch when connectivity is restored **Cause**: `FlutterConnectivityManager` is not provided to `QoraScope`. ```dart [main.dart] // Good QoraScope( client: client, connectivityManager: FlutterConnectivityManager(), child: const MyApp(), ) ``` `FlutterConnectivityManager` requires `connectivity_plus` in `pubspec.yaml`. Missing the dependency causes a compile-time import error. --- ## Cache & Keys ### Different widgets see different data for the "same" query **Cause**: Query keys are not structurally equivalent across call sites. Qora uses deep structural equality (`KeyEquality`). Different runtime types inside key segments produce distinct cache entries. ```dart // Bad: Inconsistent key types fetchQuery(key: ['users', 42], ...); // int watchQuery(key: ['users', '42'], ...); // String. Different key. // Good: Consistent fetchQuery(key: ['users', 42], ...); watchQuery(key: ['users', 42], ...); ``` --- ### `QoraBuilder` re-subscribes on every rebuild (performance issue) **Cause**: `queryKey` is created inline as a new non-`const` list instance. `QoraBuilder.didUpdateWidget` compares references, so each rebuild appears as a new key and triggers re-subscription. ```dart // Bad: New List instance on every build QoraBuilder>( queryKey: ['posts', page], // new list instance each build ... ) // Good: Stable reference. Changes only when page changes. QoraBuilder>( queryKey: const ['posts'], // for static keys // or queryKey: ['posts', page], // fine IF page is stable (not rebuilt every frame) ... ) ``` If the key includes variables, update those variables only when the requested dataset changes. --- ### `dataOrNull` returns `null` even after a successful fetch **Cause**: The type parameter on `QoraBuilder` does not match the fetched data type. The state stream is strongly typed. A `Success` for key `['users', 1]` is not delivered to `QoraBuilder` on the same key. ```dart // Bad: Type mismatch. State is Success but builder expects Profile. QoraBuilder(queryKey: ['users', userId], ...) // Good QoraBuilder(queryKey: ['users', userId], ...) ``` --- ## Optimistic Updates ### Rollback does not work and UI stays in optimistic state after failure **Cause**: The snapshot was taken after `setQueryData` instead of before. ```dart // Bad: Snapshot is taken after the optimistic write. It captures the wrong state. client.setQueryData(key, optimisticUser); final snapshot = client.getQueryData(key); // already optimistic: wrong! // Good: Always snapshot first final snapshot = client.getQueryData(key); // T?, null if no prior data client.setQueryData(key, optimisticUser); try { await api.updateUser(...); } catch (_) { client.restoreQueryData(key, snapshot); // null → removeQuery; else setQueryData } ``` --- ### Using `setQueryData` for rollback instead of `restoreQueryData` **Cause**: `setQueryData(key, data)` requires a non-null `T`. It always produces a `Success` state. When the pre-mutation state held no data (`Initial`), there is no `T` to pass, and calling `setQueryData` leaves an incorrect `Success` state in cache. `restoreQueryData(key, T? data)` accepts the `T?` value returned by `getQueryData`. When `data` is `null`, it calls `removeQuery(key)`, restoring `Initial`. When `data` is non-null, it calls `setQueryData(key, data)`. This handles both the "had data" and "had no data" cases with one call. ```dart final snapshot = client.getQueryData(key); // T?, null if entry was empty client.setQueryData(key, optimisticUser); try { await api.updateUser(...); } catch (_) { // Bad: Compile error when snapshot is null; wrong when prior state was Initial. // client.setQueryData(key, snapshot!); // Good: Handles null correctly: removes entry when there was no prior data. client.restoreQueryData(key, snapshot); } ``` --- ## Flutter Integration ### `context.qora` throws `FlutterError: QoraScope not found` **Cause**: No `QoraScope` ancestor exists above the widget that calls `context.qora` (or `QoraScope.of(context)`). ```dart [main.dart] // Good: QoraScope must be above the widget tree runApp( QoraScope( client: client, child: const MyApp(), // context.qora works anywhere inside MyApp ), ); ``` Also verify that `context.qora` is not called from callbacks executed after widget disposal. `context.qora` must be resolved inside the subtree owned by `QoraScope`. --- ## Offline Mutation Queue ### Mutations are not replayed after reconnect **Cause A**: `FlutterConnectivityManager` is not passed to `QoraScope`. Qora does not receive reconnect events. **Cause B**: `offlineQueue: true` is not set in `MutationOptions`. Offline queueing is opt-in per mutation. ```dart QoraMutationBuilder( mutator: api.createTodo, options: MutationOptions( offlineQueue: true, // required to enqueue when offline ), ... ) ``` **Cause C**: the mutation comes from `useMutation` with `qora_hooks` 1.3.1, which does not wire the offline queue. Upgrade to 1.3.1 or later. --- ### A queued mutation disappeared after reconnect **Cause**: the server rejected it during replay. A queued mutation is sent once; a failed replay is not re-enqueued, and replay continues with the next one. Resolution: roll back in `onError` (it receives the `onMutate` context, even for queued mutations) and tell the user there, for example with a snackbar and a retry action. --- ## Infinite Queries ### `fetchNextPage()` is called but no new data loads **Cause**: `getNextPageParam` returns `null` on the last page, so `hasNextPage` becomes `false`. Qora does not fetch additional pages when pagination is complete. ```dart // Integer pages. Common issue: returns null too early. getNextPageParam: (lastPage, allPages) => lastPage.items.isEmpty ? null : allPages.length + 1, // Correct behavior: return null only when the dataset is complete. // Cursor-based pagination. Common issue: wrong cursor field. getNextPageParam: (lastPage, _) => lastPage.nextCursor, // If nextCursor is "" instead of null, hasNextPage remains true. // Fix: lastPage.nextCursor?.isNotEmpty == true ? lastPage.nextCursor : null ``` --- ### Feed shows duplicate items after `refetch()` **Cause**: Integer page-number pagination combined with server-side insertions. When new posts are added between `page=1` and `page=2`, re-fetching `page=1` returns different items than the first load, causing overlap. **Fix**: Use cursor-based pagination. Cursor positions remain stable when new records are inserted. ```dart // Bad: Integer pages. Unstable with server-side insertions. InfiniteQueryOptions( initialPageParam: 1, getNextPageParam: (_, pages) => pages.length + 1, ) // Good: Cursor-based paging. Stable across insertions. InfiniteQueryOptions( initialPageParam: null, getNextPageParam: (last, _) => last.nextCursor, ) ```