Best Practice #

Memilih library local storage yang tepat hanyalah langkah pertama. Bagaimana kamu menggunakannya — di mana menyimpan data, kapan mengaksesnya, bagaimana mengujinya — sama pentingnya. Artikel ini merangkum pola-pola yang berlaku lintas library dan menentukan kualitas implementasi local storage di aplikasi produksi.

1. Selalu Abstraksi di Balik Interface #

Jangan akses library storage langsung dari widget atau notifier — buat layer abstraksi yang bisa diganti dan ditest:

// ANTI-PATTERN: akses Hive langsung dari Notifier
class ProdukNotifier extends AsyncNotifier<List<Produk>> {
  @override
  Future<List<Produk>> build() async {
    final box = Hive.box<Produk>('produk');  // bergantung langsung pada Hive!
    return box.values.toList();
  }
}

// BENAR: abstraksi di balik interface
abstract class ProdukLocalDataSource {
  Future<List<Produk>> getAll();
  Future<void> saveAll(List<Produk> produk);
  Future<void> clear();
}

class HiveProdukDataSource implements ProdukLocalDataSource {
  final Box<Produk> _box;
  HiveProdukDataSource(this._box);

  @override
  Future<List<Produk>> getAll() async => _box.values.toList();

  @override
  Future<void> saveAll(List<Produk> produk) async {
    await _box.clear();
    await _box.putAll({for (final p in produk) p.id: p});
  }

  @override
  Future<void> clear() async => _box.clear();
}

// Mock untuk test -- tanpa sentuh Hive sama sekali
class MockProdukDataSource implements ProdukLocalDataSource {
  final List<Produk> _data;
  MockProdukDataSource([this._data = const []]);

  @override
  Future<List<Produk>> getAll() async => _data;

  @override
  Future<void> saveAll(List<Produk> produk) async {}

  @override
  Future<void> clear() async {}
}

2. Jangan Simpan Data Sensitif Sembarangan #

// JANGAN: simpan data sensitif di storage yang tidak terenkripsi
await SharedPreferences.getInstance().then((p) {
  p.setString('access_token', token);       // SharedPrefs tidak terenkripsi!
  p.setString('pin', userPin);              // sangat berbahaya!
  p.setString('kartu_kredit', cardNumber);  // fatal!
});

// JANGAN: simpan data sensitif di Hive tanpa enkripsi
final box = Hive.box('sensitive');
await box.put('pin', userPin);  // tidak aman jika tanpa HiveAesCipher

// BENAR: gunakan flutter_secure_storage untuk data sensitif
const storage = FlutterSecureStorage();
await storage.write(key: 'access_token', value: token);
await storage.write(key: 'refresh_token', value: refreshToken);

// BENAR: Hive dengan enkripsi untuk data sensitif yang kompleks
final encryptedBox = await openEncryptedBox('sensitive_data');
await encryptedBox.put('pin', hashedPin);

// Klasifikasi data:
// flutter_secure_storage:  token auth, PIN, password, kunci enkripsi
// Hive terenkripsi:        data sensitif yang perlu query/filter
// SharedPrefs (TIDAK OK):  JANGAN untuk data sensitif apapun
// ObjectBox/Drift:         data bisnis normal (bukan rahasia)

3. Strategi Cache yang Tepat #

// ANTI-PATTERN: tidak ada TTL -- data cache tidak pernah expired
class ProdukCache {
  final Box<Produk> _box;
  ProdukCache(this._box);

  Future<List<Produk>?> get() async {
    return _box.isEmpty ? null : _box.values.toList();
    // Akan terus mengembalikan data lama selamanya!
  }
}

// BENAR: cache dengan TTL dan invalidasi yang jelas
class ProdukCache {
  static const _ttl = Duration(minutes: 30);

  final Box<Produk> _box;
  final Box _metaBox;

  Future<List<Produk>?> get() async {
    final cachedAt = _metaBox.get('produk_cached_at') as int?;
    if (cachedAt == null) return null;

    final elapsed = DateTime.now().millisecondsSinceEpoch - cachedAt;
    if (elapsed > _ttl.inMilliseconds) {
      await invalidate();
      return null;
    }
    return _box.values.toList();
  }

  Future<void> save(List<Produk> produk) async {
    await _box.clear();
    await _box.putAll({for (final p in produk) p.id: p});
    await _metaBox.put('produk_cached_at', DateTime.now().millisecondsSinceEpoch);
  }

  Future<void> invalidate() async {
    await _box.clear();
    await _metaBox.delete('produk_cached_at');
  }
}

// Kapan invalidate cache?
// ✓ Setelah operasi write (tambah, update, hapus)
// ✓ Saat pengguna melakukan pull-to-refresh
// ✓ Saat TTL habis
// ✓ Saat logout pengguna

4. Inisialisasi Awal yang Benar #

// ANTI-PATTERN: inisialisasi di mana saja, bisa race condition
class SomeWidget extends StatefulWidget {
  @override
  State<SomeWidget> createState() => _SomeWidgetState();
}

class _SomeWidgetState extends State<SomeWidget> {
  @override
  void initState() {
    super.initState();
    Hive.initFlutter();  // async tapi tidak di-await -- berbahaya!
  }
}

// BENAR: semua inisialisasi selesai sebelum runApp()
void main() async {
  WidgetsFlutterBinding.ensureInitialized();  // wajib sebelum async call

  // Inisialisasi berurutan
  await Hive.initFlutter();
  Hive.registerAdapter(ProdukAdapter());
  await Hive.openBox<Produk>('produk');
  await Hive.openBox('settings');

  final prefs = await SharedPreferences.getInstance();
  final obxStore = await ObjectBoxStore.create();
  final db = AppDatabase();

  runApp(
    ProviderScope(
      overrides: [
        prefsProvider.overrideWithValue(AppPreferences(prefs)),
        objectBoxProvider.overrideWithValue(obxStore),
        databaseProvider.overrideWithValue(db),
      ],
      child: const MyApp(),
    ),
  );
}

5. Bersihkan Data Saat Logout #

class LogoutService {
  final AppPreferences _prefs;
  final Box _hiveCache;
  final ObjectBoxStore _obx;
  final AppDatabase _db;

  Future<void> logout() async {
    // 1. Hapus token dari secure storage
    const storage = FlutterSecureStorage();
    await storage.deleteAll();

    // 2. Bersihkan preferensi pengguna (tapi tidak semua!)
    await _prefs.clearUserData();  // hapus hanya data yang user-specific
    // Jangan hapus: isDarkMode, bahasa -- itu preferensi device, bukan user

    // 3. Bersihkan cache data
    await _hiveCache.clear();

    // 4. Hapus data user dari ObjectBox/Drift jika ada
    _obx.produkBox.removeAll();
    await (_db.delete(_db.produks)).go();

    // 5. Arahkan ke halaman login
  }
}

6. Migrasi yang Aman #

// Drift -- migrasi schema yang aman
@override
MigrationStrategy get migration {
  return MigrationStrategy(
    onUpgrade: (m, from, to) async {
      // SELALU gunakan transaksi untuk migrasi
      await transaction(() async {
        if (from < 2) {
          // AMAN: addColumn dengan default value
          await m.addColumn(produks, produks.stok);

          // AMAN: buat tabel baru
          await m.createTable(pesananProduks);
        }
        if (from < 3) {
          // HATI-HATI: rename kolom (buat baru, copy, hapus lama)
          await customStatement(
            'ALTER TABLE produks RENAME COLUMN harga_lama TO harga',
          );
        }
        if (from < 4) {
          // AMAN: tambah index untuk performa query
          await customStatement(
            'CREATE INDEX idx_produks_kategori ON produks(kategori_id)',
          );
        }
      });
    },
  );
}

// JANGAN lakukan ini saat migrasi:
// ✗ Hapus kolom yang masih berisi data penting tanpa backup
// ✗ Ubah tipe kolom yang tidak kompatibel
// ✗ Rename tabel tanpa update semua referensi

7. Hindari Query di Build Method #

// ANTI-PATTERN: query database di build() -- dipanggil setiap rebuild!
class ProdukScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final produk = database.produkDao.getAll();  // Future dipanggil ulang!
    return FutureBuilder(future: produk, builder: ...);
  }
}

// BENAR: query di Notifier, build() hanya render
class ProdukNotifier extends AsyncNotifier<List<Produk>> {
  @override
  Future<List<Produk>> build() {
    return ref.watch(produkDaoProvider).getAll();  // sekali saja
  }
}

class ProdukScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final produkAsync = ref.watch(produkProvider);
    return produkAsync.when(
      data: (produk) => ProdukList(produk: produk),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => ErrorView(pesan: e.toString()),
    );
  }
}

8. Testing Storage Layer #

// Test Drift dengan in-memory database
AppDatabase createTestDatabase() {
  return AppDatabase(NativeDatabase.memory());
}

void main() {
  group('ProdukDao', () {
    late AppDatabase db;

    setUp(() => db = createTestDatabase());
    tearDown(() => db.close());

    test('insert dan get produk', () async {
      final id = await db.produkDao.insert(
        ProduksCompanion.insert(
          nama: 'Test Produk',
          harga: 10000,
          kategoriId: 1,
        ),
      );
      final produk = await db.produkDao.getById(id);
      expect(produk?.nama, 'Test Produk');
    });

    test('watchAll emit data saat ada insert', () async {
      final stream = db.produkDao.watchAll();

      // Expect: pertama emit [], lalu emit [produk] setelah insert
      expectLater(
        stream,
        emitsInOrder([
          isEmpty,
          hasLength(1),
        ]),
      );

      await db.produkDao.insert(
        ProduksCompanion.insert(nama: 'Produk A', harga: 5000, kategoriId: 1),
      );
    });
  });

  // Test Hive dengan path sementara
  group('ProdukCache', () {
    late Box<Produk> box;
    late Directory tempDir;

    setUp(() async {
      tempDir = await Directory.systemTemp.createTemp('hive_test');
      Hive.init(tempDir.path);
      Hive.registerAdapter(ProdukAdapter());
      box = await Hive.openBox<Produk>('test_produk');
    });

    tearDown(() async {
      await box.close();
      await Hive.deleteBoxFromDisk('test_produk');
      await tempDir.delete(recursive: true);
    });

    test('save dan load produk', () async {
      final cache = ProdukCache(box: box);
      final produk = [Produk(id: '1', nama: 'A', harga: 1000)];

      await cache.save(produk);
      final loaded = await cache.get();

      expect(loaded?.length, 1);
      expect(loaded?.first.nama, 'A');
    });
  });
}

Anti-Pattern yang Harus Dihindari #

// ✗ Buka box berulang kali -- mahal!
Future<void> simpan(Produk p) async {
  final box = await Hive.openBox<Produk>('produk');  // buka setiap kali!
  await box.put(p.id, p);
}
// ✓ Buka sekali di startup, akses via Hive.box<T>('nama')

// ✗ Simpan List besar di SharedPreferences
await prefs.setString('semua_produk', jsonEncode(daftarPanjang));
// ✓ Gunakan Hive atau Drift untuk list yang panjang

// ✗ Tidak close query ObjectBox
final query = box.query().build();
final data = query.find();
// query.close(); // LUPA! memory leak
// ✓ Selalu close query setelah selesai, atau gunakan try-finally

// ✗ Tulis ke storage di setiap keystroke
TextField(
  onChanged: (v) async {
    await prefs.setString('draft', v);  // tulis setiap karakter!
  },
)
// ✓ Debounce atau simpan saat onEditingComplete / onSubmitted

// ✗ Akses storage di main isolate tanpa async
final data = Hive.box('data').get('key');  // OK karena Hive sync
final result = database.select(database.produks).get();  // ERROR: missing await!
// ✓ Selalu await operasi async

Checklist Review Local Storage #

SETUP:
  □ Semua storage diinisialisasi di main() sebelum runApp()
  □ Satu instance database/store per app (tidak dibuat ulang)
  □ Storage layer diakses via interface / abstraksi, bukan langsung

KEAMANAN:
  □ Token dan data sensitif menggunakan flutter_secure_storage
  □ Data yang di-enkripsi menggunakan key yang disimpan di secure storage
  □ Data user dihapus saat logout

CACHING:
  □ Cache punya TTL yang jelas
  □ Cache diinvalidasi setelah operasi write
  □ Ada fallback jika cache kosong atau expired

PERFORMA:
  □ Box Hive dibuka sekali saat startup, diakses tanpa await
  □ Query ObjectBox selalu di-close setelah selesai
  □ Tidak ada query di build() method
  □ Operasi batch menggunakan putMany/putAll bukan loop put()

TESTING:
  □ Storage layer di-abstraksi di balik interface
  □ Ada mock implementation untuk unit test
  □ Test Drift menggunakan NativeDatabase.memory()
  □ Test Hive menggunakan direktori sementara

MIGRASI (jika pakai Drift):
  □ schemaVersion di-increment setiap ada perubahan schema
  □ onUpgrade menangani semua versi lama
  □ Migrasi ditest sebelum rilis

Ringkasan #

  • Selalu abstraksi storage di balik interface — jangan akses Hive/Drift/ObjectBox langsung dari widget atau notifier. Ini membuat code lebih mudah ditest dan diganti.
  • Jangan simpan data sensitif di SharedPreferences atau Hive tanpa enkripsi. Gunakan flutter_secure_storage untuk token, PIN, dan credential.
  • Cache butuh TTL (Time To Live) yang jelas dan mekanisme invalidasi setelah operasi write — cache yang tidak pernah expired adalah bug yang tertunda.
  • Inisialisasi semua storage sebelum runApp() — bukan di initState() widget, bukan di lazy initialization yang tidak ter-await.
  • Bersihkan data pengguna saat logout — token di secure storage, cache di Hive, data user di ObjectBox/Drift.
  • Jangan query di build() method — lakukan di Notifier/Repository, biarkan widget hanya merender hasilnya.
  • Test storage layer menggunakan in-memory database (Drift) atau direktori sementara (Hive) — jangan sentuh storage device yang nyata saat testing.
  • Gunakan operasi batch (putMany, putAll) alih-alih loop put() tunggal — jauh lebih efisien untuk data dalam jumlah besar.

← Sebelumnya: Drift   Berikutnya: Testing Overview →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact