# 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](https://jsonplaceholder.typicode.com), a free fake REST API.

## 1. Install and wrap your app

```bash [Terminal]
flutter pub add qora_flutter http
```

```dart [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

```dart [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>);
  }
}
```

<warning>

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.

</warning>

## 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.

```dart [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.

```dart [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:

```dart [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

<table>
<thead>
  <tr>
    <th>
      Situation
    </th>
    
    <th>
      What Qora does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      First visit
    </td>
    
    <td>
      <code>
        Loading
      </code>
      
      , then <code>
        Success
      </code>
      
      ; the result is cached under its key
    </td>
  </tr>
  
  <tr>
    <td>
      Visit within 5 minutes
    </td>
    
    <td>
      Cached data on the first frame, no network call
    </td>
  </tr>
  
  <tr>
    <td>
      Visit after 5 minutes
    </td>
    
    <td>
      Cached data on the first frame, refreshed in the background
    </td>
  </tr>
  
  <tr>
    <td>
      Pull to refresh
    </td>
    
    <td>
      <code>
        invalidate
      </code>
      
       marks the key stale; the mounted builder refetches
    </td>
  </tr>
  
  <tr>
    <td>
      Two widgets, same key
    </td>
    
    <td>
      One request, shared by both
    </td>
  </tr>
  
  <tr>
    <td>
      Request fails
    </td>
    
    <td>
      Retried 3 times with backoff, then <code>
        Failure
      </code>
      
       with the previous data
    </td>
  </tr>
  
  <tr>
    <td>
      Device offline
    </td>
    
    <td>
      Fetches pause and resume on reconnect; cached data stays on screen
    </td>
  </tr>
  
  <tr>
    <td>
      App back from background
    </td>
    
    <td>
      Stale queries on screen refetch
    </td>
  </tr>
</tbody>
</table>

## Next steps

- [Mutations](../recipes/mutations): write data and invalidate what changed
- [Persistence](../recipes/persistence): keep the cache across app restarts
- [Query Keys](../core-concepts/query-keys): how to structure keys so invalidation stays easy
- [Offline-First case study](../examples/offline-first): a full app that queues writes while offline
