Recipes

Network Aware

Handle offline mode, paused queries, offline mutation queuing, and reconnect replay with Qora's network-aware layer.

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:

PrimitiveWhat it controls
NetworkModeHow a query or mutation behaves when offline
FetchStatusWhether a fetch is actively running, paused, or idle
OfflineMutationQueueFIFO 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:

lib/core/qora.dart
enum NetworkMode {
  /// Default. Pause the fetch while offline and replay it on reconnect.
  /// The state becomes Loading(previousData: <cached data or null>) 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

lib/data/user_repository.dart
final user = await client.fetchQuery<User>(
  key: ['users', userId],
  fetcher: () => api.getUser(userId),
  options: const QoraOptions(networkMode: NetworkMode.online), // default
);

Setting networkMode globally

lib/core/qora.dart
final client = QoraClient(
  config: const QoraClientConfig(
    defaultOptions: QoraOptions(
      networkMode: NetworkMode.online,
    ),
  ),
);

Behaviour by mode

ModeOffline behaviourOn reconnect
online (default)Fetch is paused, FetchStatus.paused emittedReplayed automatically
alwaysFetch runs: fails if no connectivityNot retried on reconnect
offlineFirstCached data returned immediatelyBackground revalidation queued
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<T> describes what you have (initial / loading / success / failure). FetchStatus describes what Qora is currently doing to get it:

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:

QoraStateFetchStatusMeaning
LoadingfetchingFirst fetch running
Loading (no previousData)pausedOffline, nothing cached: the fetch waits for the network
Loading (with previousData)pausedOffline, online mode: refetch waiting, cached data still available
SuccessfetchingBackground revalidation running
SuccesspausedOffline, offlineFirst mode: cached data served, refetch queued
SuccessidleData is fresh: nothing running
FailureidleLast fetch failed, not retrying

Observing FetchStatus in 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:

lib/ui/screens/user_screen.dart
QoraBuilder<User>(
  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:

lib/ui/todo_screen.dart
QoraMutationBuilder<void, Todo, void>(
  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:

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:

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:

lib/ui/todo_list.dart
QoraMutationBuilder<void, Todo, void>(
  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

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:

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

// 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

pubspec.yaml
dependencies:
  connectivity_plus: ^6.0.0

2. Wire up QoraScope

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:

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

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:

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

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<List<Todo>>(
        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<void, String, void>(
        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

Copyright © 2026