Skill: Local Persistence
Overview
Pick the store by shape of data, not by habit. shared_preferences holds a handful of small, non-sensitive key/values. drift is the default for anything relational or reactive — it gives compile-checked queries, real migrations, and watch() streams that feed the UI directly. sqflite is the lower-level option when you want raw SQL and no codegen. Secrets never go in any of these unencrypted — see ../../security/flutter/secure_storage.md.
Use Cases
- Offline-first caches backing an infinite feed or a detail screen.
- User preferences, feature flags, last-selected tab (
shared_preferences). - Sync outboxes and any table queried with relations or ordering (
drift). - Reactive UI: a
watch()query as the single source of truth for a list.
Best Practices
- Choose deliberately:
shared_preferences— small, flat, non-sensitive, no querying.drift— relational data, migrations, reactive queries, type-safe DAOs. The default.sqflite— raw SQL, no codegen, when you are porting existing SQL or want minimal deps.- Object databases (
objectbox) — object-graph-heavy workloads; check maintenance status before adopting any of them, and prefer a store your team can support for the app's lifetime.
- Every schema change ships a migration and a migration test.
drift'sMigrationStrategyplus a bumpedschemaVersion; verify with generated schema snapshots. An un-migrated release is a crash-on-launch for every existing user. - Feed the UI from
watch()queries, not from a one-shot read plus manual invalidation. That gives one source of truth and removes the "list didn't refresh" class of bug. - Run database work off the UI isolate for bulk writes and heavy queries.
- Index the columns you filter and sort on. A cache that scans is slower than the network it replaced.
- Keep persistence in the Data layer behind a repository. Domain never sees a row class.
- Set an explicit eviction/TTL policy for caches — unbounded caches become the app's disk footprint.
Anti-Patterns
- ❌ Tokens, refresh tokens, or PII in
shared_preferencesor an unencrypted table. - ❌ A JSON file used as an ad-hoc database.
- ❌ Schema changes with no migration ("just reinstall" is not a migration).
- ❌ Opening a new database connection per screen.
- ❌ Reading once in
initStateand hoping the list stays fresh. - ❌ Row/DAO types leaking into Domain or Presentation.
- ❌ Bulk inserts on the UI isolate.
Checklist
- [ ] Store chosen deliberately and justified; secrets are not in it.
- [ ]
schemaVersionbumped and aMigrationStrategywritten for every schema change. - [ ] A migration test covers the upgrade path from the previous version.
- [ ] UI reads come from a reactive
watch()query where a list can change. - [ ] Filter/sort columns are indexed.
- [ ] Bulk work runs off the UI isolate.
- [ ] Row types are mapped to entities in Data; Domain is clean.
- [ ] Cache size/TTL policy is explicit.
Dart Examples
dart
// data/local/database.dart
class Articles extends Table {
TextColumn get id => text()();
TextColumn get title => text()();
DateTimeColumn get publishedAt => dateTime()();
BoolColumn get isBookmarked => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [Articles])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.executor);
@override
int get schemaVersion => 2;
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
// Every version step is explicit. Never "drop and recreate" on a user's device.
if (from < 2) await m.addColumn(articles, articles.isBookmarked);
},
);
// Reactive read: the UI updates whenever the table changes, from any writer.
Stream<List<Article>> watchArticles() => (select(articles)
..orderBy([(a) => OrderingTerm.desc(a.publishedAt)]))
.watch()
.map((rows) => rows.map((r) => r.toEntity()).toList());
}dart
// presentation — one source of truth, no manual invalidation
final articlesProvider = StreamProvider.autoDispose<List<Article>>(
(ref) => ref.watch(articleRepositoryProvider).watchArticles(),
);dart
// ❌ never ✅ instead
await prefs.setString('refresh_token', token); // await secureStorage.write(
// key: 'refresh_token', value: token);Common Interview Questions
- How do you choose between
shared_preferences,drift, andsqflite? - What happens on a user's device if you change a schema without a migration?
- Why drive the UI from a
watch()query rather than a one-shot read? - Which data must never be stored in
shared_preferences, and where does it go instead? - How do you keep a large cache from becoming the app's disk-usage problem?
AI Implementation Notes
- Default to
driftfor anything relational or reactive;shared_preferencesonly for small flat values that are not sensitive. - Any generated schema change must come with a
MigrationStrategybranch and a migration test — never emit a schema edit alone. - Never generate token or PII writes into
shared_preferencesor a plain table. - Expose reads as
Streamfrom the repository so the UI has one source of truth. - iOS counterparts:
../ios/coredata.md,../ios/sqlite.md. - Related:
offline_sync.md,../../security/flutter/secure_storage.md.