Examples

Case Study: Offline-First Todo App

A todo app that keeps working without a network: saved data on start, queries that pause offline, and writes queued and replayed in order on reconnect.

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.

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.

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
    )
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.

Serializers live in one function, which doubles as the list of every type written to storage:

lib/core/persistence/serializers.dart
void registerAllSerializers(PersistQoraClient client) {
  client.registerSerializer<Todo>(
    QoraSerializer(toJson: (t) => t.toJson(), fromJson: Todo.fromJson),
    name: 'Todo', // explicit name: stable with --obfuscate and on the web
  );
  client.registerSerializer<List<Todo>>(
    QoraSerializer(
      toJson: (list) => list.map((t) => t.toJson()).toList(),
      fromJson: (json) => (json as List).map(Todo.fromJson).toList(),
    ),
    name: 'List<Todo>',
  );
}

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.

lib/features/todos/ui/todo_screen.dart
QoraMutationBuilder<Todo, CreateTodoInput, List<Todo>?>(
  queryKey: const ['todos'],
  mutator: api.createTodo,
  options: MutationOptions(
    offlineQueue: true,
    onMutate: (input) async {
      final prev = context.qora.getQueryData<List<Todo>>(const ['todos']) ?? [];
      context.qora.setQueryData<List<Todo>>(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:

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

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:

lib/features/todos/ui/todo_screen.dart
QoraBuilder<List<Todo>>(
  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

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


Copyright © 2026