Tampilan
Bab 18 — Riverpod
Riverpod adalah solusi state management yang paling sering direkomendasikan untuk proyek Flutter baru. Ia menyimpan state di luar widget tree, memberi akses dari mana saja tanpa BuildContext, dan menangani data asinkron dengan sangat rapi.
Kita akan membangunnya lewat kasus nyata: aplikasi resep makanan dengan favorit dan filter.
Memasang
yaml
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.6.1Langkah wajib pertama: bungkus seluruh aplikasi dengan ProviderScope.
dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
const ProviderScope( // ← wadah tempat semua provider hidup
child: AplikasiSaya(),
),
);
}
class AplikasiSaya extends StatelessWidget {
const AplikasiSaya({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: TemaAplikasi.terang(),
home: const LayarTab(),
);
}
}Kalau kamu lupa ProviderScope, setiap pemanggilan provider akan melempar error — dan itu error pertama yang hampir semua orang temui.
Cara kerja Riverpod
Model mentalnya sederhana:
┌──────────────────────────────────────────────────────┐
│ ProviderScope │
│ (wadah, hidup di luar widget tree) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌───────────────┐ │
│ │ mealProv │ │ favoritProv│ │ filterProv │ │
│ └─────┬──────┘ └─────┬──────┘ └──────┬────────┘ │
└─────────┼───────────────┼────────────────┼───────────┘
│ │ │
ref.watch() ref.watch() ref.watch()
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ Widget │ │ Widget │ │ Widget │
└─────────┘ └─────────┘ └─────────┘Ada dua konsep yang perlu kamu kuasai:
Provider adalah wadah yang menyimpan sesuatu — bisa nilai tetap, bisa state yang berubah, bisa hasil operasi asinkron. Ia dideklarasikan sebagai variabel global.
ref adalah pegangan untuk berinteraksi dengan provider. Kamu memakainya untuk membaca, mengawasi, atau memanggil metodenya.
Provider paling sederhana
Untuk nilai yang tidak pernah berubah:
dart
// lib/providers/meal_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/data_contoh.dart';
import '../models/meal.dart';
final mealProvider = Provider<List<Meal>>((ref) {
return daftarMealTersedia;
});Deklarasinya sebagai variabel global mungkin terasa aneh, tetapi ini aman — provider hanyalah deskripsi, bukan penyimpan nilai. Nilai sesungguhnya disimpan di dalam ProviderScope, sehingga tetap terisolasi antar pengujian.
Membaca provider dari widget
Widget harus mewarisi ConsumerWidget alih-alih StatelessWidget:
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LayarKategori extends ConsumerWidget {
const LayarKategori({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) { // ← ref ditambahkan
final daftarMeal = ref.watch(mealProvider);
return Scaffold(
appBar: AppBar(title: const Text('Kategori')),
body: Text('Ada ${daftarMeal.length} menu tersedia'),
);
}
}Perhatikan tiga perubahan dari StatelessWidget:
extends ConsumerWidgetbuildmenerima parameter kedua:WidgetRef refref.watch(...)untuk membaca
Untuk StatefulWidget, padanannya adalah ConsumerStatefulWidget:
dart
class LayarMeal extends ConsumerStatefulWidget {
const LayarMeal({super.key});
@override
ConsumerState<LayarMeal> createState() => _LayarMealState();
}
class _LayarMealState extends ConsumerState<LayarMeal> {
@override
Widget build(BuildContext context) {
// `ref` tersedia langsung sebagai properti, tanpa parameter
final daftarMeal = ref.watch(mealProvider);
return Text('${daftarMeal.length}');
}
}watch, read, dan listen
Tiga cara mengakses provider, dan memilih yang salah adalah kesalahan paling umum di Riverpod.
| Metode | Kapan dipakai | Efek |
|---|---|---|
ref.watch() | Di dalam build() | Berlangganan — widget dibangun ulang saat berubah |
ref.read() | Di dalam callback | Baca sekali, tidak berlangganan |
ref.listen() | Di dalam build() | Jalankan efek samping saat berubah, tanpa rebuild |
dart
@override
Widget build(BuildContext context, WidgetRef ref) {
// ✅ watch di dalam build — berlangganan perubahan
final favorit = ref.watch(favoritProvider);
// ✅ listen untuk efek samping (snackbar, navigasi, dialog)
ref.listen<List<Meal>>(favoritProvider, (sebelum, sesudah) {
if (sesudah.length > (sebelum?.length ?? 0)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ditambahkan ke favorit')),
);
}
});
return ElevatedButton(
onPressed: () {
// ✅ read di dalam callback — kita hanya ingin memanggil metodenya
ref.read(favoritProvider.notifier).toggle(meal);
},
child: Text('${favorit.length} favorit'),
);
}Jangan pakai read di dalam build
dart
// ❌ Widget tidak akan pernah diperbarui saat state berubah
final favorit = ref.read(favoritProvider);
// ❌ Rebuild tak berujung — watch memicu build, build memicu watch
onPressed: () => ref.watch(favoritProvider.notifier).toggle(meal),Aturannya: watch di build, read di callback.
StateNotifierProvider: state yang berubah
Untuk state yang bisa dimodifikasi, kamu butuh sebuah kelas yang mengelolanya.
dart
// lib/providers/favorit_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/meal.dart';
class FavoritMealNotifier extends StateNotifier<List<Meal>> {
// Nilai awal dioper ke konstruktor super
FavoritMealNotifier() : super(const []);
/// Menambah atau menghapus dari favorit.
/// Mengembalikan true kalau baru ditambahkan.
bool toggleStatusFavorit(Meal meal) {
final sudahFavorit = state.contains(meal);
if (sudahFavorit) {
// Buat list BARU tanpa meal ini
state = state.where((m) => m.id != meal.id).toList();
return false;
} else {
// Buat list BARU dengan meal ini di dalamnya
state = [...state, meal];
return true;
}
}
bool apakahFavorit(Meal meal) => state.any((m) => m.id == meal.id);
}
final favoritMealProvider =
StateNotifierProvider<FavoritMealNotifier, List<Meal>>((ref) {
return FavoritMealNotifier();
});Beberapa hal penting:
StateNotifier<T> — T adalah tipe state-nya. Di sini List<Meal>.
super(const []) — nilai awal.
state = ... — satu-satunya cara mengubah state. Setiap penugasan otomatis memberi tahu semua pendengar.
Membuat objek baru, bukan mengubah yang lama. Perhatikan:
dart
// ❌ TIDAK BEKERJA — objek yang sama, Riverpod tidak melihat perubahan
state.add(meal);
state.remove(meal);
// ✅ BEKERJA — objek baru
state = [...state, meal];
state = state.where((m) => m.id != meal.id).toList();Ini penerapan langsung dari prinsip immutability di Bab 17. Riverpod membandingkan referensi objek untuk memutuskan apakah perlu memberi tahu pendengar; kalau alamatnya sama, ia menyimpulkan tidak ada perubahan.
Memakainya di widget
dart
// lib/screens/layar_detail_meal.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/meal.dart';
import '../providers/favorit_provider.dart';
class LayarDetailMeal extends ConsumerWidget {
const LayarDetailMeal({super.key, required this.meal});
final Meal meal;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Berlangganan daftar favorit
final daftarFavorit = ref.watch(favoritMealProvider);
final adalahFavorit = daftarFavorit.contains(meal);
return Scaffold(
appBar: AppBar(
title: Text(meal.judul),
actions: [
IconButton(
onPressed: () {
// read, karena kita hanya memanggil metodenya
final ditambahkan = ref
.read(favoritMealProvider.notifier)
.toggleStatusFavorit(meal);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ditambahkan
? 'Ditambahkan ke favorit.'
: 'Dihapus dari favorit.',
),
),
);
},
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => RotationTransition(
turns: Tween<double>(begin: 0.7, end: 1).animate(animation),
child: child,
),
child: Icon(
adalahFavorit ? Icons.star : Icons.star_border,
key: ValueKey(adalahFavorit), // key agar transisi terdeteksi
),
),
),
],
),
body: SingleChildScrollView(
child: Column(
children: [
Hero(
tag: meal.id,
child: Image.network(
meal.urlGambar,
height: 300,
width: double.infinity,
fit: BoxFit.cover,
),
),
const SizedBox(height: 14),
Text(
'Bahan-bahan',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
for (final bahan in meal.bahan)
Text(bahan, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
);
}
}Perhatikan bahwa LayarDetailMeal mengakses favorit secara langsung — tanpa ada fungsi yang dioper dari LayarTab melalui tiga layar perantara. Inilah masalah prop drilling yang diselesaikan.
key: ValueKey(adalahFavorit) pada Icon diperlukan agar AnimatedSwitcher tahu widgetnya berganti — pelajaran dari Bab 12 yang muncul lagi di sini.
Provider yang bergantung pada provider lain
Ini salah satu kekuatan terbesar Riverpod: sebuah provider bisa mengawasi provider lain, dan otomatis menghitung ulang ketika yang diawasi berubah.
Mari tambahkan filter — dan buat daftar menu yang ditampilkan bergantung padanya.
dart
// lib/providers/filter_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/meal.dart';
import 'meal_provider.dart';
enum Filter { bebasGluten, bebasLaktosa, vegetarian, vegan }
class FilterNotifier extends StateNotifier<Map<Filter, bool>> {
FilterNotifier()
: super(const {
Filter.bebasGluten: false,
Filter.bebasLaktosa: false,
Filter.vegetarian: false,
Filter.vegan: false,
});
void setFilter(Filter filter, bool aktif) {
// Salin map lama, lalu timpa satu kunci — objek BARU
state = {...state, filter: aktif};
}
void setSemuaFilter(Map<Filter, bool> semua) {
state = semua;
}
}
final filterProvider =
StateNotifierProvider<FilterNotifier, Map<Filter, bool>>((ref) {
return FilterNotifier();
});
/// Provider turunan: daftar menu yang sudah disaring.
/// Ia mengawasi DUA provider lain dan menghitung ulang otomatis.
final mealTersaringProvider = Provider<List<Meal>>((ref) {
final semuaMeal = ref.watch(mealProvider);
final filterAktif = ref.watch(filterProvider);
return semuaMeal.where((meal) {
if (filterAktif[Filter.bebasGluten]! && !meal.bebasGluten) return false;
if (filterAktif[Filter.bebasLaktosa]! && !meal.bebasLaktosa) return false;
if (filterAktif[Filter.vegetarian]! && !meal.vegetarian) return false;
if (filterAktif[Filter.vegan]! && !meal.vegan) return false;
return true;
}).toList();
});Alurnya:
Ketika pengguna mengubah satu filter, rantainya berjalan otomatis: filterProvider berubah → mealTersaringProvider menghitung ulang → semua widget yang mengawasinya dibangun ulang. Kamu tidak menulis satu baris pun untuk menghubungkan itu.
Layar filternya:
dart
// lib/screens/layar_filter.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/filter_provider.dart';
class LayarFilter extends ConsumerWidget {
const LayarFilter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filterAktif = ref.watch(filterProvider);
return Scaffold(
appBar: AppBar(title: const Text('Filter Menu')),
body: Column(
children: [
_Saklar(
judul: 'Bebas Gluten',
keterangan: 'Hanya tampilkan menu bebas gluten.',
nilai: filterAktif[Filter.bebasGluten]!,
saatBerubah: (aktif) => ref
.read(filterProvider.notifier)
.setFilter(Filter.bebasGluten, aktif),
),
_Saklar(
judul: 'Bebas Laktosa',
keterangan: 'Hanya tampilkan menu bebas laktosa.',
nilai: filterAktif[Filter.bebasLaktosa]!,
saatBerubah: (aktif) => ref
.read(filterProvider.notifier)
.setFilter(Filter.bebasLaktosa, aktif),
),
_Saklar(
judul: 'Vegetarian',
keterangan: 'Hanya tampilkan menu vegetarian.',
nilai: filterAktif[Filter.vegetarian]!,
saatBerubah: (aktif) => ref
.read(filterProvider.notifier)
.setFilter(Filter.vegetarian, aktif),
),
_Saklar(
judul: 'Vegan',
keterangan: 'Hanya tampilkan menu vegan.',
nilai: filterAktif[Filter.vegan]!,
saatBerubah: (aktif) => ref
.read(filterProvider.notifier)
.setFilter(Filter.vegan, aktif),
),
],
),
);
}
}
class _Saklar extends StatelessWidget {
const _Saklar({
required this.judul,
required this.keterangan,
required this.nilai,
required this.saatBerubah,
});
final String judul;
final String keterangan;
final bool nilai;
final void Function(bool) saatBerubah;
@override
Widget build(BuildContext context) {
return SwitchListTile(
value: nilai,
onChanged: saatBerubah,
title: Text(judul, style: Theme.of(context).textTheme.titleLarge),
subtitle: Text(keterangan, style: Theme.of(context).textTheme.labelMedium),
contentPadding: const EdgeInsets.only(left: 34, right: 22),
);
}
}Perhatikan bahwa layar ini tidak perlu mengembalikan apa pun lewat Navigator.pop. Ia langsung mengubah provider, dan seluruh aplikasi menyesuaikan. Bandingkan dengan pendekatan di Bab 7 yang harus mengoper Map<Filter, bool> bolak-balik.
FutureProvider: data asinkron
Di sinilah Riverpod benar-benar bersinar. Mengambil data dari jaringan biasanya butuh penanganan tiga keadaan — Riverpod menyediakannya secara bawaan.
dart
// lib/providers/berita_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/berita.dart';
import '../services/layanan_berita.dart';
final layananBeritaProvider = Provider<LayananBerita>((ref) {
return LayananBerita();
});
final beritaProvider = FutureProvider<List<Berita>>((ref) async {
final layanan = ref.watch(layananBeritaProvider);
return layanan.ambilSemua();
});Memakainya:
dart
class LayarBerita extends ConsumerWidget {
const LayarBerita({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncBerita = ref.watch(beritaProvider);
return Scaffold(
appBar: AppBar(title: const Text('Berita')),
body: asyncBerita.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (kesalahan, jejak) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 12),
Text('$kesalahan', textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(
// invalidate memaksa provider menghitung ulang
onPressed: () => ref.invalidate(beritaProvider),
child: const Text('Coba Lagi'),
),
],
),
),
data: (daftar) {
if (daftar.isEmpty) {
return const Center(child: Text('Belum ada berita.'));
}
return RefreshIndicator(
onRefresh: () async => ref.refresh(beritaProvider.future),
child: ListView.builder(
itemCount: daftar.length,
itemBuilder: (ctx, i) => ListTile(
title: Text(daftar[i].judul),
subtitle: Text(daftar[i].ringkasan),
),
),
);
},
),
);
}
}Metode .when() memaksamu menangani ketiga keadaan — compiler tidak akan mengizinkanmu lupa salah satunya. Bandingkan dengan FutureBuilder di Bab 14 yang harus kamu periksa manual.
Varian yang berguna:
dart
// Pertahankan data lama saat memuat ulang, agar tidak berkedip
asyncBerita.when(
skipLoadingOnRefresh: true,
loading: () => const CircularProgressIndicator(),
error: (e, s) => Text('$e'),
data: (d) => DaftarBerita(daftar: d),
)
// Akses langsung tanpa when
if (asyncBerita.isLoading) { }
if (asyncBerita.hasError) { }
final data = asyncBerita.valueOrNull;StreamProvider
Untuk data yang mengalir terus — obrolan, notifikasi, pembaruan Firestore:
dart
final pesanProvider = StreamProvider<List<Pesan>>((ref) {
return FirebaseFirestore.instance
.collection('chat')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) =>
snapshot.docs.map((d) => Pesan.dariJson(d.data())).toList());
});Pemakaiannya identik dengan FutureProvider:
dart
ref.watch(pesanProvider).when(
loading: () => const CircularProgressIndicator(),
error: (e, s) => Text('$e'),
data: (daftar) => DaftarPesan(pesan: daftar),
)Provider dengan parameter: family
Ketika kamu butuh provider yang berbeda per argumen:
dart
final detailMealProvider =
FutureProvider.family<Meal, String>((ref, idMeal) async {
final layanan = ref.watch(layananMealProvider);
return layanan.ambilSatu(idMeal);
});Memakainya:
dart
final asyncMeal = ref.watch(detailMealProvider('meal-123'));Riverpod menyimpan cache terpisah untuk setiap argumen — jadi detailMealProvider('a') dan detailMealProvider('b') adalah dua instance yang berbeda.
autoDispose: membersihkan otomatis
Secara bawaan provider hidup selama aplikasi berjalan. Untuk data sementara, tambahkan autoDispose agar dibersihkan ketika tidak ada lagi yang mengawasinya:
dart
final pencarianProvider =
FutureProvider.autoDispose.family<List<Meal>, String>((ref, kueri) async {
// Batalkan permintaan lama kalau pengguna terus mengetik
final pembatal = CancelToken();
ref.onDispose(pembatal.cancel);
final layanan = ref.watch(layananMealProvider);
return layanan.cari(kueri, pembatal: pembatal);
});Kalau kamu ingin data tetap disimpan meskipun sementara tidak diawasi:
dart
final dataProvider = FutureProvider.autoDispose<Data>((ref) async {
// Pertahankan cache selama 5 menit setelah pendengar terakhir pergi
final tautan = ref.keepAlive();
Timer(const Duration(minutes: 5), tautan.close);
return ambilData();
});select: berlangganan sesempit mungkin
Ini optimasi yang layak dibiasakan.
dart
// ❌ Dibangun ulang setiap kali APA PUN di daftar favorit berubah
final favorit = ref.watch(favoritMealProvider);
return Text('${favorit.length}');
// ✅ Dibangun ulang hanya kalau JUMLAHNYA berubah
final jumlah = ref.watch(
favoritMealProvider.select((daftar) => daftar.length),
);
return Text('$jumlah');Untuk memeriksa satu item saja:
dart
final adalahFavorit = ref.watch(
favoritMealProvider.select((daftar) => daftar.any((m) => m.id == meal.id)),
);Sekarang widget ini hanya dibangun ulang ketika status favorit menu ini berubah — bukan ketika menu lain ditambahkan.
Consumer: membatasi cakupan rebuild
Kalau hanya sebagian kecil halaman yang butuh berlangganan, jangan jadikan seluruh halaman ConsumerWidget. Bungkus bagian itu saja dengan Consumer:
dart
class Beranda extends StatelessWidget {
const Beranda({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Beranda'),
actions: [
// Hanya lencana ini yang dibangun ulang
Consumer(
builder: (context, ref, child) {
final jumlah = ref.watch(
keranjangProvider.select((k) => k.items.length),
);
return Badge(
label: Text('$jumlah'),
child: child, // ikon dibuat sekali, dipakai ulang
);
},
child: const Icon(Icons.shopping_cart),
),
],
),
// Seluruh body ini tidak pernah dibangun ulang
body: const KontenBerat(),
);
}
}Parameter child bekerja seperti pada AnimatedBuilder di Bab 11 — ia dibuat sekali dan diteruskan tanpa dibangun ulang.
Struktur folder
lib/
├── main.dart ← ProviderScope di sini
├── models/
│ ├── meal.dart
│ └── kategori.dart
├── data/
│ └── data_contoh.dart
├── providers/
│ ├── meal_provider.dart ← data mentah
│ ├── favorit_provider.dart ← StateNotifier
│ └── filter_provider.dart ← StateNotifier + provider turunan
├── services/
│ └── layanan_meal.dart
├── screens/
│ ├── layar_tab.dart
│ ├── layar_kategori.dart
│ ├── layar_meal.dart
│ ├── layar_detail_meal.dart
│ └── layar_filter.dart
└── widgets/
├── item_meal.dart
└── menu_samping.dartRingkasan jenis provider
| Jenis | Untuk | Contoh |
|---|---|---|
Provider | Nilai tetap atau turunan | Daftar konstan, layanan, hasil perhitungan |
StateProvider | Satu nilai sederhana | Indeks tab, kata kunci pencarian |
StateNotifierProvider | State kompleks dengan metode | Favorit, keranjang, filter |
FutureProvider | Operasi asinkron sekali | Ambil daftar dari API |
StreamProvider | Aliran data berkelanjutan | Firestore, WebSocket |
NotifierProvider | Sintaks generasi baru | Pengganti modern StateNotifier |
StateProvider untuk kasus yang sangat sederhana:
dart
final indeksTabProvider = StateProvider<int>((ref) => 0);
// Membaca
final indeks = ref.watch(indeksTabProvider);
// Mengubah
ref.read(indeksTabProvider.notifier).state = 2;Riverpod generasi baru
Riverpod 2.x memperkenalkan Notifier dan AsyncNotifier sebagai pengganti StateNotifier, serta pembuatan kode dengan anotasi @riverpod lewat paket riverpod_generator. Sintaksnya lebih ringkas:
dart
@riverpod
class FavoritMeal extends _$FavoritMeal {
@override
List<Meal> build() => [];
void toggle(Meal meal) {
state = state.contains(meal)
? state.where((m) => m.id != meal.id).toList()
: [...state, meal];
}
}StateNotifier masih sepenuhnya didukung dan sangat banyak dipakai, jadi memahaminya tetap penting — terutama kalau kamu bergabung ke proyek yang sudah berjalan.
Kesalahan yang sering terjadi
Lupa ProviderScope. Error pertama yang ditemui semua orang.
Mengubah state alih-alih menggantinya. state.add(x) tidak memicu apa pun.
read di dalam build. Widget tidak akan pernah diperbarui.
watch di dalam callback. Menyebabkan rebuild tak berujung.
Provider terlalu besar. Satu provider yang menyimpan seluruh state aplikasi berarti setiap perubahan kecil membangun ulang semuanya. Pecah menjadi beberapa provider kecil.
Lupa select. Berlangganan seluruh objek padahal hanya butuh satu propertinya.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Daftar Belanja dengan Provider Turunan — ⭐⭐ · 45–60 menit
Tantangan: Buat daftar belanja dengan Riverpod: item bisa ditambah, dicentang, dan dihapus. Lalu tambahkan dua provider turunan — satu berisi daftar yang tersaring menurut pilihan pengguna (semua / belum dibeli / sudah dibeli), dan satu lagi berisi ringkasan "sekian dari sekian sudah dibeli".
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ [Semua] [Belum Dibeli] [Sudah] │
│ ┌─────────────────────────────┐ │
│ │ ☐ Susu │ │
│ │ ☑ Roti │ │
│ │ ☐ Telur │ │
│ └─────────────────────────────┘ │
│ 2 dari 3 sudah dibeli │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Daftar dikelola
StateNotifier, dan setiap perubahan menugaskan objek baru —state = [...state, item], tidak pernahstate.add(item). - Penyaring aktif adalah provider tersendiri, dan daftar tersaring adalah provider yang mengawasi keduanya. Tidak ada penyaringan yang dilakukan di dalam
build()widget. - Ringkasan memakai
selectsehingga mengubah teks sebuah item tidak membangun ulang widget ringkasan. - Menekan tombol hapus menampilkan snackbar lewat
ref.listen, bukan lewat pemanggilan di dalambuild.
Petunjuk: Kalau ada perubahan yang tidak muncul di layar, hampir pasti penyebabnya adalah list yang diubah di tempat. state.add(item) mengubah isi objek yang sama, sehingga Riverpod membandingkan referensi lama dengan referensi lama dan menyimpulkan tidak ada yang berubah — tanpa error apa pun. Untuk provider turunan, ingat bahwa di dalam sebuah provider kamu tetap punya ref, jadi ia bisa watch provider lain persis seperti widget. Dan soal ref.listen: ia ada justru karena build bisa dipanggil berkali-kali untuk state yang sama — menampilkan snackbar di sana berarti snackbar yang muncul berlipat.
Variasi 2: Papan Kedatangan Bandara — ⭐⭐ · 40–60 menit
Tantangan: Bangun papan kedatangan penerbangan yang datanya mengalir dari sebuah Stream tiruan yang mengeluarkan pembaruan status setiap beberapa detik. Tampilkan daftarnya, plus satu penghitung "berapa penerbangan yang tertunda" yang ikut berubah sendiri.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ 3 penerbangan tertunda │
│ ┌─────────────────────────────┐ │
│ │ GA-102 Tepat Waktu 10:00 │ │
│ │ QZ-556 Tertunda ⚡ 10:15 │ │
│ │ JT-210 Mendarat 09:45 │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Data berasal dari
StreamProvider, dan ketiga keadaannya ditangani lewat.when(). - Penghitung penerbangan tertunda adalah provider turunan, bukan hasil perhitungan di dalam widget.
- Menutup layar menghentikan langganan stream-nya.
- Baris yang statusnya berubah menampilkan penanda visual sesaat.
Petunjuk: .autoDispose adalah cara paling langsung memenuhi kriteria ketiga: provider yang tidak lagi diawasi siapa pun akan membersihkan dirinya sendiri, termasuk membatalkan langganan stream-nya. Tanpa itu, stream tetap hidup selama aplikasi berjalan meski layarnya sudah lama ditutup — kebocoran yang tidak menghasilkan error dan hanya terlihat sebagai pemakaian memori yang naik perlahan.
Variasi 3: Konverter Mata Uang — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun konverter mata uang yang mengambil kurs dari API, menampilkan hasil konversi seketika saat pengguna mengetik jumlah, dan menyediakan tombol segarkan yang mengambil kurs terbaru tanpa mengosongkan jumlah yang sudah diketik.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Kurs: 1 USD = Rp 15.750 [⟳] │
│ Jumlah (USD) [100________] │
│ Hasil (IDR) Rp 1.575.000 │
│ ▬▬▬▬ │
╰───────────────────────────────────╯...kalau pengambilan kurs gagal:
text
┌─────────────────────────────┐
│ ⚠ Gagal ambil kurs [Coba Lagi]│
└─────────────────────────────┘Kriteria selesai:
- Kurs diambil lewat
FutureProvider, dan ketiga keadaannya ditangani lewat.when(). - Jumlah yang diketik adalah state terpisah, dan hasil konversi adalah provider turunan dari keduanya.
- Tombol segarkan memicu pengambilan ulang tanpa mengubah jumlah yang sudah diketik.
- Kegagalan jaringan menampilkan pesan yang bisa ditindaklanjuti plus tombol coba lagi.
Petunjuk: Memaksa FutureProvider mengambil ulang bukan soal memanggil fungsinya kembali — ia menyimpan hasil dan tidak akan berjalan lagi selama ketergantungannya tidak berubah. ref.invalidate menyatakan bahwa hasil yang tersimpan sudah tidak berlaku, dan pengambilan berikutnya terjadi otomatis. Perhatikan bahwa selama pengambilan ulang berjalan, .when() akan kembali ke keadaan memuat — kalau kamu ingin hasil lama tetap terlihat sementara data baru datang, ada properti pada AsyncValue yang menyimpan nilai sebelumnya.
Variasi 4: Detail Kos dengan Parameter — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun daftar kamar kos, dan layar detail yang mengambil datanya sendiri berdasarkan ID kamar. Beberapa layar detail bisa terbuka bertumpuk, dan masing-masing harus memuat datanya sendiri tanpa saling mengganggu.
Bayangan alur layar:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ ┌─────────────────────────────┐ │
│ │ Kos Melati Rp 800rb/bl │ │
│ │ Kos Anggrek Rp 950rb/bl │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯tap "Kos Melati" → Detail#Melati terbuka → tap "Kos Anggrek" di belakangnya → Detail#Anggrek terbuka bertumpuk, keduanya memuat datanya sendiri. Tutup Detail#Anggrek → providernya langsung dibuang (.autoDispose), bukan menumpuk selamanya di memori.
Kriteria selesai:
- Provider detail memakai
.familydengan ID sebagai parameternya. - Provider detail memakai
.autoDispose, sehingga menutup layar membersihkan datanya. - Membuka layar detail untuk ID yang sama dua kali tidak memicu dua pengambilan data.
- Kamu sudah memeriksa dengan
ProviderObserverbahwa provider benar-benar dibuang saat layarnya ditutup.
Petunjuk: .family dan .autoDispose sering dipakai bersamaan justru karena alasan yang berlawanan: .family membuat satu provider per parameter, yang berarti membuka lima puluh detail berbeda akan meninggalkan lima puluh provider hidup selamanya kalau tidak dibuang. Untuk kriteria ketiga, perhatikan bahwa Riverpod menyimpan hasil per parameter — dua permintaan untuk ID yang sama akan memakai instance yang sama selama masih ada yang mengawasinya.
Variasi 5: Pencarian Berlapis — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Bangun pencarian properti sewa dengan empat penyaring yang bisa aktif bersamaan: kata kunci, rentang harga, jumlah kamar, dan fasilitas berganda. Seluruh hasil harus dihitung sebagai rantai provider turunan, bukan satu fungsi raksasa.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ 🔍 [dekat kampus] │
│ Harga: [1-3jt▾] Kamar: [2▾] │
│ Fasilitas: [AC][WiFi][Parkir] │
│ [ Hapus Semua Filter ] │
│ │
│ 12 properti ditemukan │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Widget "12 properti ditemukan" tidak dibangun ulang kalau daftar hasil berganti isi tapi jumlahnya tetap 12 (berkat select pada panjang daftar, bukan isinya).
Kriteria selesai:
- Setiap penyaring adalah provider tersendiri, dan hasil akhirnya adalah provider yang mengawasi seluruhnya.
- Mengubah satu penyaring tidak menghitung ulang penyaring lain yang tidak berubah.
- Ada satu widget yang hanya menampilkan jumlah hasil, dan ia tidak dibangun ulang ketika isi daftarnya berubah tetapi jumlahnya tetap sama.
- Tombol "hapus semua penyaring" mengembalikan seluruh penyaring ke keadaan awal dalam satu aksi.
Petunjuk: Kriteria ketiga adalah demonstrasi paling jelas dari select: mengawasi seluruh daftar berarti setiap perubahan isi memicu rebuild, sementara mengawasi daftar.length saja hanya memicu rebuild ketika angkanya berubah. Untuk kriteria kedua, pecah rantainya menjadi beberapa tahap — saring kata kunci dulu di satu provider, lalu harga di provider berikutnya yang mengawasi hasil tahap pertama. Riverpod hanya menghitung ulang tahap yang ketergantungannya berubah.
Variasi 6: Pindah dari setState — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Ambil satu aplikasi yang sudah kamu tulis di Bagian I dengan setState dan fungsi yang dioper ke bawah, lalu pindahkan seluruh state-nya ke Riverpod. Jangan ubah satu pun perilaku yang terlihat pengguna.
Kriteria selesai:
- Tidak ada satu pun fungsi callback yang dioper ke bawah lebih dari satu tingkat hanya untuk mengubah state.
setStateyang tersisa hanya untuk hal murni lokal seperti membuka-tutup panel atau posisi gulir.- Widget yang tidak butuh state sama sekali kembali menjadi
StatelessWidget. - Kamu bisa menyebutkan satu bagian yang menjadi lebih rumit setelah dipindahkan, dan menjelaskan kenapa.
Petunjuk: Kriteria terakhir sengaja ada supaya kamu tidak menyimpulkan bahwa state management selalu menang. Untuk state yang benar-benar milik satu widget dan tidak pernah dibaca siapa pun — posisi gulir, apakah kata sandi sedang ditampilkan, animasi lokal — memindahkannya ke provider menambah berkas dan lapisan tanpa memberi apa pun. Menemukan batas itu sendiri jauh lebih berharga daripada menghafal aturan.
Ikhtisar
- Bungkus aplikasi dengan
ProviderScope— tanpa ini semuanya gagal. - Widget memakai
ConsumerWidget(parameterrefdibuild) atauConsumerStatefulWidget(refsebagai properti). watchdibuild(berlangganan),readdi callback (baca sekali),listenuntuk efek samping seperti snackbar dan navigasi.StateNotifiermengelola state yang berubah. Selalu tugaskan objek baru —state = [...state, item], bukanstate.add(item).- Provider bisa mengawasi provider lain dan menghitung ulang otomatis. Ini cara membangun data turunan seperti daftar tersaring.
FutureProvider+.when()menangani loading, error, dan data sekaligus — jauh lebih rapi daripadaFutureBuilder.StreamProvideruntuk aliran berkelanjutan seperti Firestore..familymembuat provider berparameter;.autoDisposemembersihkannya ketika tidak lagi diawasi.selectmembatasi langganan ke satu properti saja — optimasi termurah yang tersedia.Consumermembatasi cakupan rebuild ke sebagian kecil pohon, dengan parameterchilduntuk bagian yang tidak berubah.- Pecah state menjadi beberapa provider kecil, bukan satu provider raksasa.
Berikutnya: Bab 19 — Provider, pendahulu Riverpod yang masih sangat banyak dipakai.
Transkrip asli
Disintesis dari 1_flutter_core/8_meals-app_managing-app-wide-state.md (16 video: Installing Riverpod, Creating a Provider, StateNotifier, Dependent Providers) dan 4_flutter_ai-chatbot-n-firebase/14_riverpod-clean-architecture-and-http-networking.md. Lihat PDF Flutter Core.