Perbandingan & Kapan Memilih #
Setelah mempelajari lima pendekatan state management — setState/ValueNotifier, Provider, Riverpod, Bloc, dan MobX — pertanyaan yang wajar adalah: mana yang harus saya pilih? Jawabannya selalu bergantung pada konteks. Artikel ini memberikan kerangka yang jelas untuk membuat keputusan yang tepat.
Perbandingan Fitur #
| Fitur | setState | Provider | Riverpod | Bloc/Cubit | MobX |
|---|---|---|---|---|---|
| Kurva belajar | Rendah | Rendah | Sedang | Tinggi | Sedang |
| Boilerplate | Minimal | Rendah | Rendah-Sedang | Tinggi | Rendah (codegen) |
| Compile-time safety | — | Rendah | Tinggi | Sedang | Sedang |
| Testability | Sulit | Sedang | Tinggi | Tinggi | Sedang |
| Performa rebuild | Manual | Manual | Otomatis | Manual | Otomatis |
| Async state | Manual | FutureProvider | AsyncValue | Emit state | @action async |
| Code generation | ✗ | ✗ | Opsional | ✗ | Wajib |
| Bergantung Context | ✓ | ✓ | ✗ | ✓ | ✗ |
| Multiple instance | ✓ | Sulit | Mudah (family) | Sulit | ✓ |
| DevTools | Basic | — | Riverpod Dev | Bloc Observer | MobX DevTools |
| Dukungan Flutter | Official | Recommended | Recommended | Community | Community |
| Cocok untuk | Micro | Small-Med | Med-Large | Large/Ent | Med |
Perbandingan Kode — Fitur yang Sama #
Untuk melihat perbedaan nyata, berikut implementasi fitur yang sama (load dan tampilkan daftar produk) dengan masing-masing pendekatan:
setState #
class _ProdukState extends State<ProdukScreen> {
List<Produk> _produk = [];
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
try {
_produk = await produkApi.getAll();
if (mounted) setState(() => _isLoading = false);
} catch (e) {
if (mounted) setState(() { _isLoading = false; _error = e.toString(); });
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) return const CircularProgressIndicator();
if (_error != null) return Text(_error!);
return ProdukList(produk: _produk);
}
}
// Baris kode: ~30 | Testability: Rendah | State scope: Lokal
Provider #
// model
class ProdukModel extends ChangeNotifier {
List<Produk> produk = [];
bool isLoading = false;
String? error;
Future<void> load() async {
isLoading = true; notifyListeners();
try {
produk = await produkApi.getAll();
} catch (e) { error = e.toString(); }
isLoading = false; notifyListeners();
}
}
// widget
Consumer<ProdukModel>(
builder: (context, model, _) {
if (model.isLoading) return const CircularProgressIndicator();
if (model.error != null) return Text(model.error!);
return ProdukList(produk: model.produk);
},
)
// Baris kode: ~25 | Testability: Sedang | State scope: Global/Shared
Riverpod #
// provider
class ProdukNotifier extends AsyncNotifier<List<Produk>> {
@override
Future<List<Produk>> build() => ref.watch(produkRepoProvider).getAll();
}
final produkProvider = AsyncNotifierProvider<ProdukNotifier, List<Produk>>(ProdukNotifier.new);
// widget
ref.watch(produkProvider).when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('$e'),
data: (produk) => ProdukList(produk: produk),
)
// Baris kode: ~12 | Testability: Tinggi | State scope: Global/Scoped
Bloc #
// event + state + bloc (3 file terpisah)
// Event: LoadProduk
// State: ProdukLoading, ProdukLoaded, ProdukError
// Bloc: on<LoadProduk> → emit(Loading) → load → emit(Loaded/Error)
BlocBuilder<ProdukBloc, ProdukState>(
builder: (context, state) => switch(state) {
ProdukLoading() => const CircularProgressIndicator(),
ProdukError(:final pesan) => Text(pesan),
ProdukLoaded(:final produk) => ProdukList(produk: produk),
_ => const SizedBox(),
},
)
// Baris kode: ~60 (3 file) | Testability: Tinggi | State scope: Global/Scoped
MobX #
// store
abstract class _ProdukStore with Store {
@observable ObservableList<Produk> produk = ObservableList();
@observable bool isLoading = false;
@observable String? error;
@action Future<void> load() async {
isLoading = true;
try { produk = ObservableList.of(await produkApi.getAll()); }
catch (e) { error = e.toString(); }
finally { isLoading = false; }
}
}
// widget
Observer(builder: (_) {
if (store.isLoading) return const CircularProgressIndicator();
if (store.error != null) return Text(store.error!);
return ProdukList(produk: store.produk);
})
// Baris kode: ~25 + codegen | Testability: Sedang | State scope: Global/Local
Decision Tree — Langkah demi Langkah #
MULAI
│
▼
Apakah state hanya digunakan dalam SATU widget?
│
├─ YA → Gunakan setState atau ValueNotifier
│ Tidak perlu library eksternal
│
└─ TIDAK → State perlu dibagikan ke banyak widget
│
▼
Berapa besar tim dan proyek?
│
├─ KECIL (1-3 orang, <10 screen)
│ │
│ ├─ Familiar dengan React/MobX? → MobX
│ └─ Tidak → Provider
│
├─ MENENGAH (3-8 orang, 10-30 screen)
│ │
│ ├─ Butuh async state yang elegan? → Riverpod
│ ├─ Tim sudah pakai Provider? → Tetap Provider
│ └─ Butuh traceability penuh? → Bloc/Cubit
│
└─ BESAR (8+ orang, 30+ screen, enterprise)
│
├─ Butuh audit trail event? → Bloc
├─ Butuh fleksibilitas + testability? → Riverpod
└─ Tim biasa dengan reactive programming? → MobX
Faktor-faktor Keputusan #
1. Pengalaman Tim #
Tim baru Flutter:
→ setState → Provider → Riverpod (bertahap)
→ Jangan langsung pakai Bloc atau MobX
Tim berpengalaman React/Redux/MobX:
→ MobX akan terasa sangat familiar
→ Atau Riverpod dengan paradigma functional
Tim enterprise dengan background Java/C#:
→ Bloc sangat cocok -- pola event-driven mirip Command pattern
2. Kebutuhan Testability #
Unit test yang mudah adalah keunggulan Riverpod dan Bloc:
Riverpod:
final container = ProviderContainer(overrides: [
produkRepoProvider.overrideWith((_) => MockProdukRepo()),
]);
final state = await container.read(produkProvider.future);
expect(state, isA<List<Produk>>());
Bloc:
blocTest<ProdukBloc, ProdukState>(
'emits [Loading, Loaded] ketika LoadProduk dikirim',
build: () => ProdukBloc(mockRepo),
act: (bloc) => bloc.add(LoadProduk()),
expect: () => [ProdukLoading(), ProdukLoaded(mockProduk)],
);
3. Penanganan State Async #
Async state adalah salah satu yang paling sering menyebabkan bug.
Setiap library menanganinya berbeda:
setState: Manual (isLoading, data, error terpisah -- rawan tidak konsisten)
Provider: FutureProvider bagus, tapi kurang ergonomis untuk mutasi
Riverpod: AsyncValue.when() paling elegan -- loading/error/data ter-handle
Bloc: Perlu emit state terpisah (Loading, Loaded, Error) -- verbose tapi jelas
MobX: @action async cukup intuitif, tapi perlu hati-hati dengan state
4. Skala dan Kompleksitas #
Fitur sederhana (toggle, counter, form local):
→ setState / ValueNotifier selalu cukup
Aplikasi dengan 5-15 screen, shared state terbatas:
→ Provider atau Riverpod
Aplikasi besar dengan banyak fitur paralel:
→ Riverpod (provider per-feature yang isolated)
→ atau Bloc (satu Bloc per feature)
Aplikasi enterprise dengan kebutuhan audit:
→ Bloc -- setiap perubahan state punya Event yang tercatat
5. Apakah Ada Library Lain yang Sudah Digunakan? #
Menggunakan Firebase?
→ Riverpod StreamProvider sangat cocok untuk authStateChanges
→ atau Provider StreamProvider
Menggunakan Freezed untuk immutable data class?
→ Riverpod sangat sinergis dengan Freezed
→ Bloc juga cocok (state class dengan Freezed)
Menggunakan GetIt sebagai service locator?
→ Pertimbangkan Riverpod yang menggantikan GetIt + state management sekaligus
Rekomendasi Berdasarkan Skenario #
Proyek Personal / Side Project #
Pilihan: Riverpod Sedikit boilerplate, penanganan async yang elegan, compile-time safe. Tidak perlu khawatir Provider tidak tersedia di luar widget tree.
Startup / Tim Kecil yang Bergerak Cepat #
Pilihan: Riverpod atau Provider Provider lebih mudah untuk onboarding anggota baru. Riverpod untuk tim yang sudah siap belajar sedikit lebih banyak demi manfaat jangka panjang.
Aplikasi Enterprise / Tim Besar #
Pilihan: Bloc Strict architecture, traceability penuh, ekosistem testing yang matang. Overhead boilerplate terbayar dengan maintainability di skala besar.
Developer dengan Background React #
Pilihan: MobX Filosofi yang sangat mirip — observable, action, computed adalah konsep yang langsung familiar. Produktivitas awal paling tinggi.
Migrasi dari setState ke State Management #
Jalur yang direkomendasikan:
setState
↓ (state mulai dibagikan ke 2-3 widget)
ValueNotifier / ChangeNotifier
↓ (state dibagikan ke banyak halaman)
Provider
↓ (butuh async state yang lebih baik, compile-time safety)
Riverpod
Bisa Menggunakan Lebih dari Satu? #
Ya — dan ini umum di aplikasi nyata:
// setState untuk UI state lokal (expand/collapse, hover, focus)
// Provider/Riverpod untuk shared business state
// MobX untuk fitur yang sangat reactive
// Contoh: aplikasi e-commerce
- setState: tab yang aktif di detail produk (lokal)
- Riverpod: daftar produk, auth state, keranjang (shared)
- Provider: tema, lokalisasi (app-wide config)
Yang penting: konsisten di dalam satu fitur. Jangan campur Riverpod dan Bloc untuk fitur yang sama.
Ringkasan #
- Tidak ada pilihan yang “terbaik” secara universal — pilih berdasarkan konteks: ukuran tim, pengalaman, skala proyek, dan kebutuhan spesifik.
setState/ValueNotifieruntuk state lokal — jangan diremehkan, ia sangat capable untuk banyak skenario.- Provider untuk tim baru yang butuh shared state tanpa kurva belajar yang curam.
- Riverpod untuk proyek menengah-besar yang butuh compile-time safety, async state yang elegan, dan testability tinggi.
- Bloc untuk enterprise dengan tim besar — strict architecture, traceability penuh, dan ekosistem testing yang matang.
- MobX untuk developer dengan background React/JavaScript yang menginginkan reactive programming dengan boilerplate minimal.
- Konsistensi lebih penting dari pilihan — sebuah proyek yang konsisten dengan Provider lebih baik dari proyek yang setengah Riverpod setengah Bloc.
- Boleh menggunakan lebih dari satu pendekatan — tapi pastikan konsisten dalam satu fitur yang sama.