Getting Started

Quick Start

Build a list and a detail screen backed by Qora, watch the cache work, then take the app offline.

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

Terminal
flutter pub add qora_flutter http
lib/main.dart
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

lib/user_api.dart
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>);
  }
}
A fetcher must throw on failure. If it returns 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.

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

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

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

SituationWhat Qora does
First visitLoading, then Success; the result is cached under its key
Visit within 5 minutesCached data on the first frame, no network call
Visit after 5 minutesCached data on the first frame, refreshed in the background
Pull to refreshinvalidate marks the key stale; the mounted builder refetches
Two widgets, same keyOne request, shared by both
Request failsRetried 3 times with backoff, then Failure with the previous data
Device offlineFetches pause and resume on reconnect; cached data stays on screen
App back from backgroundStale queries on screen refetch

Next steps

Copyright © 2026