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
QoraScopesetup:QoraClientinjected at the root withFlutterLifecycleManagerandFlutterConnectivityManagerQoraBuilder<T>: fetches on mount, subscribes to state changes, disposes cleanly- Stale-While-Revalidate: cached data shown instantly on re-navigation; background refetch only when stale
FetchStatusas a second UI axis: distinguishes active fetch (fetching), offline pause (paused), and idle without any custom boolean flagspreviousDatagraceful 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 invalidateWherepredicate: one call invalidates both the list and all open detail screens simultaneously- Pull-to-refresh:
RefreshIndicatorwired toinvalidateWhere, 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:
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<List<User>>, key: ['users']
└── user_detail_screen.dart ← QoraBuilder<User>, 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:
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<T>: what you have:Initial,Loading,Success,FailureFetchStatus: 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:
QoraBuilder<List<User>>(
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<List<User>>)
_ErrorBanner(error: state.error), // soft error: list still visible
Expanded(
child: RefreshIndicator(
onRefresh: () async {
context.qora.invalidateWhere(
(key) => key.firstOrNull == 'users',
);
await Future<void>.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:
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:
QoraBuilder<User>(
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<User>)
_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<User>,
),
),
],
),
};
},
)
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:
- Open User List → wait 2 s for initial load
- Go back and reopen → instant from cache, no network call
- Pull to refresh → stale list stays visible; blue "Updating…" banner appears simultaneously
- Open a user → note the "Last fetched" timestamp
- Go back and reopen within 5 min → instant from cache, timestamp unchanged
- Toggle airplane mode → orange "Offline" banner; list remains fully usable
Source Code
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
Related
- Quick Start: step-by-step conceptual walkthrough of the same feature
- QoraBuilder: full widget API
- Stale-While-Revalidate: how the cache freshness model works
- Query States:
QoraStatesealed class reference
With GraphQL
Wrap graphql or ferry GraphQL clients in Qora fetchers for seamless query management with stale-while-revalidate, cache invalidation, and pagination.
Case Study: Optimistic Mutations & Rollback
A complete optimistic update implementation: setQueryData for immediate UI feedback, restoreQueryData for automatic rollback on failure, and invalidateWhere for server reconciliation.