1. Structured Data with sqflite
flutter pub add sqflite path
Future<Database> openProductDb() async {
final dbPath = await getDatabasesPath();
return openDatabase(
join(dbPath, 'products.db'),
version: 1,
onCreate: (db, version) => db.execute(
'CREATE TABLE products(id INTEGER PRIMARY KEY, name TEXT, price REAL)',
),
);
}
Future<void> cacheProducts(Database db, List<Product> products) async {
final batch = db.batch();
for (final p in products) {
batch.insert('products', p.toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
Future<List<Product>> loadCachedProducts(Database db) async {
final rows = await db.query('products');
return rows.map((row) => Product.fromJson(row)).toList();
}
A batch groups many inserts into one transaction — meaningfully faster
than inserting each row individually, and the right default whenever caching a
whole list at once.
2. Key-Value & Object Storage with Hive
Hive is a fast, pure-Dart NoSQL store — no native SQL layer underneath, which makes it noticeably quicker to set up for data that doesn't need real relational queries.
flutter pub add hive hive_flutter
await Hive.initFlutter();
final settingsBox = await Hive.openBox('settings');
settingsBox.put('theme_mode', 'dark');
final themeMode = settingsBox.get('theme_mode', defaultValue: 'system');
Hive can also store typed objects directly (via generated TypeAdapters)
rather than just primitives, but for something like a user preference or a cached
auth token, the plain key-value form above is often all that's needed.
3. Choosing Between Them
| Kind of data | Use |
|---|---|
| Structured, queryable records (a product catalog, orders) | sqflite |
| Simple preferences, flags, small cached objects | Hive |
| A single sensitive value (an auth token) | flutter_secure_storage — not covered in depth here, but worth knowing exists for exactly that case |
This mirrors the exact storage-selection framework this site's React Native course teaches for AsyncStorage/SecureStore/SQLite — the specific package names differ per platform, but the underlying decision (does this need real queries, or just a fast key-value read) is the same question every mobile framework's storage layer answers.
4. An Offline-First Screen
final productsProvider = FutureProvider<List<Product>>((ref) async {
final db = await openProductDb();
final cached = await loadCachedProducts(db);
// kick off a network refresh, but don't block on it —
// return cached data immediately if there's any
unawaited(_refreshInBackground(db, ref));
if (cached.isNotEmpty) return cached;
// no cache yet — this genuinely has to wait on the network
final fresh = await fetchProducts();
await cacheProducts(db, fresh);
return fresh;
});
Future<void> _refreshInBackground(Database db, Ref ref) async {
try {
final fresh = await fetchProducts();
await cacheProducts(db, fresh);
ref.invalidateSelf(); // triggers a rebuild with the fresh data once it's ready
} catch (_) {
// offline or the request failed — the cached data already on screen is still valid
}
}
This is the real shape of offline-first: never make the user stare at a spinner for data that's already sitting on the device, and treat a failed background refresh as "stay on the cached data," not as an error the user needs to see.
5. Hands-on Exercise
Make the product list offline-first
Combine sqflite caching with Week 9's networking so the app is usable with no connection.
Requirements:
- A sqflite database caching the product list fetched in Week 9, with a batch insert on every successful network fetch.
- A provider that returns cached data immediately if present, then refreshes from the network in the background and updates the UI when the refresh completes.
- A Hive box storing a simple preference (e.g. sort order or a "last synced at" timestamp), read on app startup.
- Test genuinely offline: turn on airplane mode after the first successful load, force-quit and relaunch the app, and confirm the product list still appears from cache with no crash or infinite spinner.
- A visible (but non-blocking) indicator — a small banner or icon — showing when the app is currently offline or serving cached data.
If the UI doesn't update after a successful background refresh, confirm you're actually invalidating or re-reading the provider once fresh data lands — a background Future that fetches and caches data but never signals the provider system (via ref.invalidateSelf() or an equivalent) leaves the UI correctly showing cached data forever, with no visible bug until you specifically check whether it ever picks up the refresh.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does grouping cache inserts into a single batch matter for a list of any real size, rather than inserting each row individually?
Why does grouping cache inserts into a single batch matter for a list of any real size, rather than inserting each row individually?
Each individual insert against sqflite is its own transaction by default, with real overhead per operation — batching many inserts into one transaction (batch.commit) does all of them as a single unit, which is measurably faster for anything beyond a handful of rows and is the standard pattern for caching a whole fetched list at once.
Q2
Why is Hive a reasonable choice for a user preference like theme mode, where sqflite would be overkill?
Why is Hive a reasonable choice for a user preference like theme mode, where sqflite would be overkill?
A preference is a single simple value with no relational structure and no need for querying — Hive's key-value model reads and writes it directly with minimal setup, where sqflite would require defining a table and writing SQL for what's fundamentally just "get/set one value."
Q3
Why does the offline-first provider return cached data immediately rather than always waiting for the network refresh to finish first?
Why does the offline-first provider return cached data immediately rather than always waiting for the network refresh to finish first?
The entire point of offline-first is that a user with cached data available should never sit on a loading spinner for information the device already has — showing cached data instantly, then quietly updating in the background if a fresher version arrives, is what makes the app feel fast and usable even on a poor or absent connection.
Q4
Why does a failed background refresh in this pattern not surface as a visible error to the user?
Why does a failed background refresh in this pattern not surface as a visible error to the user?
The user is already looking at valid cached data — a failed refresh (offline, a timeout, a server error) doesn't invalidate what's currently on screen, it just means the data might be slightly stale. Treating that as a hard error would interrupt a perfectly functional experience over something that isn't actually broken; the appropriate response is simply to keep showing the cached data and try again later.