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, a free fake REST API.
1. Install and wrap your app
flutter pub add qora_flutter http
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
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<String, dynamic> 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<List<User>> 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<dynamic>;
return list.map((j) => User.fromJson(j as Map<String, dynamic>)).toList();
}
static Future<User> 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<String, dynamic>);
}
}
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.
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<List<User>>(
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<User> 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.
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<User>(
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:
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: write data and invalidate what changed
- Persistence: keep the cache across app restarts
- Query Keys: how to structure keys so invalidation stays easy
- Offline-First case study: a full app that queues writes while offline