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.
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 aStorageAdapterand restored byhydrate()before the first frameNetworkMode.offlineFirst: cached data is served immediately; fetches resume when the device reconnects- Offline mutation queue:
offlineQueue: truequeues 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 itFetchStatus.paused: tells the UI "no network" apart from "loading"NetworkStatusIndicator: app-wide offline bannerReconnectStrategy: 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
)
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:
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.
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:
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:
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
Related Guides
- Persistence:
PersistQoraClient, serializers, storage adapters, model migrations - Network Aware:
NetworkMode,FetchStatus, offline mutation queue,ReconnectStrategy - Optimistic Updates:
onMutate,setQueryData,restoreQueryData - PersistQoraClient API: full method reference
Case Study: Infinite Scroll Feed
Build a high-performance social media-style feed with cursor-based pagination, windowed memory management, and instant optimistic post creation.
Case Study: Network Awareness
Queries pause automatically when offline, mutations queue for later, and everything replays on reconnect: with thundering-herd prevention built in.