Examples

Case Study: User List & Detail

A complete list-and-detail feature: SWR caching, FetchStatus two-axis state, per-item query keys, pull-to-refresh, and graceful degradation. Zero boilerplate state management.

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

  • QoraScope setup: QoraClient injected at the root with FlutterLifecycleManager and FlutterConnectivityManager
  • QoraBuilder<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
  • FetchStatus as a second UI axis: distinguishes active fetch (fetching), offline pause (paused), and idle without any custom boolean flags
  • previousData graceful 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
  • invalidateWhere predicate: one call invalidates both the list and all open detail screens simultaneously
  • Pull-to-refresh: RefreshIndicator wired to invalidateWhere, 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:

lib/main.dart
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, Failure
  • FetchStatus: 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:

lib/screens/user_list_screen.dart
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.

After pull-to-refresh, both ['users'] (the list) and every ['users', userId] (open detail screens) need fresh data. A predicate covers them all:

lib/screens/user_list_screen.dart
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:

lib/screens/user_detail_screen.dart
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:

  1. Open User List → wait 2 s for initial load
  2. Go back and reopen → instant from cache, no network call
  3. Pull to refresh → stale list stays visible; blue "Updating…" banner appears simultaneously
  4. Open a user → note the "Last fetched" timestamp
  5. Go back and reopen within 5 min → instant from cache, timestamp unchanged
  6. 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

View full source code


Copyright © 2026