Riverpod #

Pustaka Riverpod adalah evolusi arsitektural modern dari sistem Provider yang dirancang khusus untuk memecahkan berbagai keterbatasan mendasar dari pendahulunya. Dengan menghilangkan ketergantungan mutlak pada BuildContext, Riverpod mampu menawarkan tingkat keamanan waktu kompilasi (compile-time safety) yang sangat tinggi, memungkinkan instansiasi ganda dari tipe provider yang sama secara independen, serta menyediakan penanganan status asinkron yang sangat deklaratif melalui konsep AsyncValue. Fleksibilitas ini menjadikan Riverpod sebagai salah satu standar utama manajemen status yang paling direkomendasikan untuk pengembangan aplikasi Flutter komersial skala menengah hingga besar saat ini.


Instalasi #

Untuk menggunakan Riverpod di aplikasi Flutter kita, tambahkan paket dependensi berikut ke dalam berkas pubspec.yaml proyek kita:

dependencies:
  flutter:
    sdk: flutter
  # Paket integrasi Riverpod untuk Flutter
  flutter_riverpod: ^2.6.1

dev_dependencies:
  # Digunakan untuk menjalankan generator kode (opsional, namun sangat disarankan)
  build_runner: ^2.4.13

Setup ProviderScope #

Agar seluruh status dari provider yang kita buat dapat disimpan dan diakses dengan aman di memori, kita wajib membungkus root widget aplikasi menggunakan ProviderScope. Di balik layar, ProviderScope bertindak sebagai penyimpan objek ProviderContainer global.

void main() {
  runApp(
    // ProviderScope wajib diletakkan di bagian teratas pohon widget
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: LayarDashboard(),
    );
  }
}

Arsitektur Penyimpanan Status Riverpod #

Salah satu keunggulan terbesar Riverpod adalah arsitekturnya yang memisahkan penyimpanan data status sepenuhnya dari struktur antarmuka UI (widget tree).

flowchart TD
    ProviderScope["ProviderScope (Root Widget)"] --> Container["ProviderContainer (Global Memory Store)"]
    Container --> ProviderA["ProviderA: ApiService\n(State: Instantiated)"]
    Container --> ProviderB["ProviderB: AuthNotifier\n(State: UserLoggedIn)"]
    Container --> ProviderC["ProviderC: CartNotifier\n(State: 3 items)"]
    WidgetTree["Widget Tree (Render Layer)"] -.-|Bebas Context / Ambil Langsung via Ref| Container

Dengan memisahkan memori penyimpanan status data dari rendering pohon widget, kita dapat dengan mudah membaca, memanipulasi, dan menguji status aplikasi kita dari file Dart murni tanpa perlu mensimulasikan lingkungan render UI.


Jenis-jenis Provider di Riverpod #

Riverpod menyediakan berbagai jenis provider khusus yang disesuaikan dengan skenario data yang kita hadapi:

1. Provider (Nilai Imut / Read-Only) #

Cocok digunakan untuk menampung instansiasi layanan atau repositori statis yang nilainya tidak akan berubah sepanjang daur hidup aplikasi berjalan.

// Mendefinisikan ApiService statis
final apiServiceProvider = Provider<ApiService>((ref) {
  return ApiService(baseUrl: 'https://api.example.com');
});

// Menghubungkan dependensi antar-provider secara instan tanpa ProxyProvider
final produkRepositoryProvider = Provider<ProdukRepository>((ref) {
  // ref.watch melacak perubahan apiServiceProvider secara dinamis
  final apiService = ref.watch(apiServiceProvider);
  return ProdukRepository(api: apiService);
});

2. Notifier (State Sinkron yang Dapat Diubah) #

Notifier adalah kelas reaktif modern di Riverpod untuk mengelola status data sinkron yang membutuhkan logika mutasi status terstruktur melalui metode khusus.

@immutable
class StatusKoleksi {
  final List<String> daftarNama;
  const StatusKoleksi({this.daftarNama = const []});

  StatusKoleksi copyWith({List<String>? daftarNama}) {
    return StatusKoleksi(daftarNama: daftarNama ?? this.daftarNama);
  }
}

// Membuat kelas Notifier kustom
class KoleksiNotifier extends Notifier<StatusKoleksi> {
  // build() wajib di-override untuk menetapkan nilai status awal (initial state)
  @override
  StatusKoleksi build() {
    return const StatusKoleksi();
  }

  void tambahItem(String nama) {
    // state merepresentasikan nilai status saat ini
    state = state.copyWith(daftarNama: [...state.daftarNama, nama]);
  }

  void hapusItem(String nama) {
    state = state.copyWith(
      daftarNama: state.daftarNama.where((item) => item != nama).toList(),
    );
  }
}

// Mendeklarasikan NotifierProvider secara global
final koleksiProvider = NotifierProvider<KoleksiNotifier, StatusKoleksi>(
  KoleksiNotifier.new,
);

3. AsyncNotifier (State Asinkron dengan Kontrol Mutasi) #

AsyncNotifier adalah solusi terbaik untuk mengelola data status yang diperoleh melalui proses asinkron (seperti HTTP Request ke API server) namun tetap membutuhkan penulisan aksi bisnis logik pembaruan data.

class ProdukNotifier extends AsyncNotifier<List<Produk>> {
  @override
  Future<List<Produk>> build() async {
    // Membaca repositori secara asinkron
    final repo = ref.watch(produkRepositoryProvider);
    return repo.ambilSemuaProduk();
  }

  Future<void> tambahProduk(Produk produkBaru) async {
    // 1. Dapatkan referensi repositori
    final repo = ref.read(produkRepositoryProvider);
    
    // 2. Set status sementara ke loading atau lakukan optimistic update
    state = const AsyncLoading();
    
    // 3. Jalankan aksi dan perbarui status berdasarkan hasil
    state = await AsyncValue.guard(() async {
      await repo.simpanProduk(produkBaru);
      // Ambil data terbaru untuk diselaraskan
      return repo.ambilSemuaProduk();
    });
  }
}

final produkProvider = AsyncNotifierProvider<ProdukNotifier, List<Produk>>(
  ProdukNotifier.new,
);

4. FutureProvider (Pemuatan Asinkron Sederhana) #

Digunakan ketika kita hanya perlu memuat data dari Future sekali saja secara pasif tanpa perlu melakukan mutasi manual setelahnya.

final detailBeritaProvider = FutureProvider.autoDispose.family<Berita, String>(
  (ref, idBerita) async {
    final repo = ref.watch(produkRepositoryProvider);
    return repo.ambilBeritaDetail(idBerita);
  },
);

Interaksi Menggunakan Objek Ref #

Untuk membaca status di Riverpod, kita menggunakan objek WidgetRef di dalam widget, atau Ref di dalam kelas provider lainnya. Ada tiga metode utama interaksi:

1. ref.watch() (Mendengarkan Perubahan & Rebuild) #

Metode ref.watch() bertugas mengamati nilai status dari provider tertentu dan memicu rekonstruksi (rebuild) pada widget pemanggil setiap kali status data tersebut mengalami pembaruan.

  • Aturan: Selalu gunakan ref.watch() di dalam fungsi build widget kita.
class WidgetDaftarKoleksi extends ConsumerWidget {
  const WidgetDaftarKoleksi({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Berlangganan perubahan data koleksi
    final koleksi = ref.watch(koleksiProvider);

    return ListView(
      children: koleksi.daftarNama.map((nama) => Text(nama)).toList(),
    );
  }
}

2. ref.read() (Membaca Sekali Tanpa Rebuild) #

Metode ref.read() bertugas mengambil nilai status saat ini secara instan tanpa mendaftarkan widget sebagai pendengar aktif.

  • Aturan: Gunakan hanya di dalam fungsi callback kejadian seperti tombol klik atau inisialisasi awal, jangan pernah memanggilnya langsung di dalam build method.
ElevatedButton(
  onPressed: () {
    // Memanggil method Notifier tanpa memicu rebuild pada tombol ini
    ref.read(koleksiProvider.notifier).tambahItem('Item Baru');
  },
  child: const Text('Tambah Data'),
)

3. ref.listen() (Memicu Efek Samping / Side Effects) #

Metode ref.listen() digunakan untuk mendengarkan perubahan status dan mengeksekusi aksi prosedural tertentu (seperti menampilkan SnackBar, membuka halaman baru via Navigator, atau memicu dialog peringatan) tanpa memicu rebuild UI.

class WidgetDashboard extends ConsumerWidget {
  const WidgetDashboard({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Mendengarkan status error untuk memicu SnackBar secara reaktif
    ref.listen<StatusKoleksi>(koleksiProvider, (StatusKoleksi? prev, StatusKoleksi next) {
      if (next.daftarNama.length > 5) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Koleksi Anda sudah penuh!')),
        );
      }
    });

    return const Center(child: Text('Konten Aktif'));
  }
}

AsyncValue — Menangani Tiga Status Asinkron Secara Deklaratif #

Setiap kali kita membaca asinkron provider (seperti FutureProvider atau AsyncNotifierProvider), Riverpod mengembalikan objek AsyncValue<T>. Konsep ini adalah kelas tertutup (sealed class) yang membungkus tiga kemungkinan status data secara terstruktur:

  • AsyncLoading: Proses asinkron sedang berjalan.
  • AsyncData: Data berhasil diambil dengan tipe data T.
  • AsyncError: Terjadi kesalahan asinkron saat proses pemuatan.

Konsep ini memaksa kita menangani ketiga skenario tersebut di tingkat UI secara aman menggunakan fungsi .when():

class LayarDaftarProduk extends ConsumerWidget {
  const LayarDaftarProduk({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Membaca status asinkron produk
    final produkAsync = ref.watch(produkProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Katalog Produk')),
      body: produkAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (Object error, StackTrace stackTrace) => Center(
          child: Text('Terjadi kesalahan: $error'),
        ),
        data: (List<Produk> daftarProduk) {
          return ListView.builder(
            itemCount: daftarProduk.length,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(daftarProduk[index].nama),
              );
            },
          );
        },
      ),
    );
  }
}

family — Mengirim Parameter Dinamis ke Provider #

Modifikasi .family digunakan ketika kita membutuhkan provider yang nilainya dinamis berdasarkan parameter input tertentu (seperti ID entitas).

// Mendefinisikan provider dengan parameter String ID
final detailKatalogProvider = FutureProvider.family<Produk, String>(
  (ref, idKatalog) async {
    final repo = ref.watch(produkRepositoryProvider);
    return repo.ambilDetail(idKatalog);
  },
);

// Cara Mengaksesnya di UI:
class DetailWidget extends ConsumerWidget {
  final String idProduk;
  const DetailWidget({super.key, required this.idProduk});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Riverpod otomatis mengidentifikasi dan meng-cache instance terpisah untuk idProduk ini
    final produkAsync = ref.watch(detailKatalogProvider(idProduk));
    
    return produkAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (e, s) => Text('Error: $e'),
      data: (produk) => Text(produk.nama),
    );
  }
}

autoDispose — Manajemen Memori Pintar #

Di aplikasi berskala produksi, penting bagi kita untuk menjaga beban penggunaan RAM agar tidak membengkak. Modifikasi .autoDispose menginstruksikan Riverpod untuk secara otomatis menghancurkan status (state) dari provider tersebut dan membebaskan memorinya segera setelah tidak ada lagi widget aktif di layar yang mendengarkannya.

flowchart TD
    Start["Widget Mulai Mendengarkan Provider"] --> Create["Memicu build() Provider"]
    Create --> InUse["Provider Berada di Memori"]
    InUse --> WidgetDestroyed["Widget Dihancurkan / Tidak Ada Listener"]
    WidgetDestroyed --> CheckAutoDispose{"Apakah menggunakan\n.autoDispose?"}
    CheckAutoDispose -->|Ya| KeepAliveActive{"Apakah ref.keepAlive()\ndipanggil?"}
    CheckAutoDispose -->|Tidak| KeepInMemory["Tetap Disimpan di Memori"]
    KeepAliveActive -->|Ya| KeepInMemory
    KeepAliveActive -->|Tidak| Destroy["Hancurkan State & Bebaskan Memori"]

Jika dalam skenario tertentu kita membutuhkan data asinkron dari provider yang menggunakan .autoDispose tetap tersimpan sementara di memori (misalnya agar pengguna tidak perlu memuat ulang data saat kembali ke halaman sebelumnya), kita dapat memanfaatkan ref.keepAlive() untuk mempertahankan data tersebut secara terkontrol.

final dataBeritaProvider = FutureProvider.autoDispose<List<Berita>>((ref) async {
  final repo = ref.watch(produkRepositoryProvider);
  final data = await repo.ambilBeritaTerkini();
  
  // Mempertahankan status cache secara dinamis
  final link = ref.keepAlive();
  
  // Kita dapat menutup keepAlive berdasarkan timer atau aksi kustom jika diperlukan
  ref.onDispose(() {
    link.close();
  });
  
  return data;
});

ConsumerWidget vs ConsumerStatefulWidget #

Untuk mengintegrasikan reaktivitas Riverpod ke antarmuka, kita mengganti basis kelas widget standar Flutter:

  • ConsumerWidget: Pengganti dari StatelessWidget. Parameter WidgetRef ref disuntikkan secara langsung di dalam tanda tangan metode build().
  • ConsumerStatefulWidget: Pengganti dari StatefulWidget. Objek ref tersedia secara global sebagai properti kelas di dalam kelas ConsumerState pendampingnya (sehingga kita dapat mengakses ref dari metode daur hidup seperti initState atau didChangeDependencies).
class LayarPencarian extends ConsumerStatefulWidget {
  const LayarPencarian({super.key});

  @override
  ConsumerState<LayarPencarian> createState() => _LayarPencarianState();
}

class _SearchScreenState extends ConsumerState<LayarPencarian> {
  final TextEditingController _queryController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Kita dapat menggunakan ref secara langsung di metode daur hidup!
    ref.read(koleksiProvider.notifier).tambahItem('Pencarian Dimulai');
  }

  @override
  void dispose() {
    _queryController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // ref diakses sebagai field anggota kelas, tidak perlu dilewatkan sebagai parameter build
    final status = ref.watch(koleksiProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pencarian')),
      body: Column(
        children: [
          TextField(controller: _queryController),
          Expanded(
            child: ListView(
              children: status.daftarNama.map((name) => Text(name)).toList(),
            ),
          ),
        ],
      ),
    );
  }
}

Struktur Folder Berbasis Fitur yang Direkomendasikan #

Dalam aplikasi berskala besar, disarankan bagi kita untuk mengatur file kode menggunakan pendekatan berorientasi fitur (Feature-First Architecture):

lib/
  ├── main.dart
  ├── core/                           # Logika global & utilitas bersama
  │   └── network/
  │       └── api_provider.dart
  └── features/                       # Modul fitur mandiri
      ├── katalog/
      │   ├── data/
      │   │   └── katalog_repository.dart
      │   ├── domain/
      │   │   └── produk_model.dart
      │   └── presentation/
      │       ├── layar_katalog.dart
      │       └── katalog_provider.dart # Mendefinisikan Notifiers & Providers katalog
      └── transaksi/
          ├── data/
          ├── domain/
          └── presentation/

Ringkasan #

  • Bebas Context: Arsitektur Riverpod menyimpan status di memori global (ProviderContainer), membebaskan logika bisnis dari ketergantungan pada BuildContext.
  • AsyncValue: Konsep yang menjamin penanganan yang aman terhadap status loading, data, dan error pada seluruh transaksi asinkron di tingkat UI secara deklaratif.
  • Pilihan Metode: Selalu panggil ref.watch() di build method untuk reaktivitas, ref.read() di fungsi callback kejadian untuk aksi sekali jalan, dan ref.listen() untuk mengeksekusi efek samping.
  • Optimasi RAM: Manfaatkan properti .autoDispose dikombinasikan dengan ref.keepAlive() untuk mengotomatisasi pembersihan status memori yang sudah tidak digunakan.
  • Widget Pendukung: Gunakan ConsumerWidget untuk widget statis cepat, dan ConsumerStatefulWidget jika membutuhkan penanganan metode daur hidup (lifecycle methods).

← Sebelumnya: Provider   Berikutnya: Bloc & Cubit →

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