Memilih State Management Flutter untuk Project Jangka Panjang
Mengapa State Management Krusial di Flutter?
Saya pernah membangun app Flutter dengan 30+ screens dan InheritedWidget manual. Hasilnya: widget tree yang dalam, rebuild yang tidak terkontrol, dan performa rendering yang menurun setiap update. State management bukan opsional untuk project jangka panjang — itu pondasi arsitektur.
Perbandingan 3 Besar: Provider, Riverpod, BLoC
Provider: Untuk Memulai
Provider sudah lama menjadi pilihan default. Mudah dipelajari, ekosistem matang.
class UserProvider extends ChangeNotifier {
User? _user;
User? get user => _user;
Future<void> login(String email, String password) async {
_user = await authService.login(email, password);
notifyListeners(); // Trigger rebuild widget yang listen
}
}
// main.dart
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => UserProvider()),
ChangeNotifierProvider(create: (_) => CartProvider()),
],
child: MyApp(),
)
// Widget consumer
Consumer<UserProvider>(
builder: (context, provider, child) {
return Text(provider.user?.name ?? 'Guest');
},
)
Kelebihan: API sederhana, dokumentasi melimpas, official dari Flutter team.
Kekurangan:
- Tidak type-safe (runtime error di
Provider.of<T>()) - Sulit di unit testing karena dependensi Context
- Global state yang sulit dibatasi scope
Riverpod: Modern Type-Safe
Riverpod adalah evolusi Provider dengan compile-time safety.
// State immutable (freezed)
@freezed
class UserState with _$UserState {
const factory UserState.initial() = _Initial;
const factory UserState.loading() = _Loading;
const factory UserState.loaded(User user) = _Loaded;
const factory UserState.error(String msg) = _Error;
}
class UserNotifier extends Notifier<UserState> {
final authService = ref.read(authServiceProvider);
@override
UserState build() => const UserState.initial();
Future<void> login(String email, String password) async {
state = const UserState.loading();
try {
final user = await authService.login(email, password);
state = UserState.loaded(user);
} catch (e) {
state = UserState.error(e.toString());
}
}
}
// Provider definition (global, type-safe)
final userProvider = NotifierProvider<UserNotifier, UserState>(() => UserNotifier());
// Widget consumer
final userState = ref.watch(userProvider);
return userState.when(
initial: () => Text('Welcome'),
loading: () => CircularProgressIndicator(),
loaded: (user) => Text('Hello, ${user.name}'),
error: (msg) => Text('Error: $msg'),
);
Kelebihan:
- ✅ Compile-time type safety
- ✅ Tidak perlu BuildContext — bisa diakses di mana saja
- ✅ Testable ( Providers override di unit test)
- ✅ Auto dispose untuk memory management
- ✅ AsyncValue yang elegan untuk loading/error patterns
Kekurangan:
- Curva belajar lebih curam
- Boilerplate dari freezed union types
BLoC: Untuk Enterprise
BLoC (Business Logic Component) menggunakan streams dan events.
abstract class UserEvent {}
class UserLoginRequested extends UserEvent {
final String email;
final String password;
UserLoginRequested({required this.email, required this.password});
}
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc({required this.authService}) : super(const UserState.initial()) {
on<UserLoginRequested>(_onLogin);
}
final AuthService authService;
Future<void> _onLogin(UserLoginRequested event, Emitter<UserState> emit) async {
emit(const UserState.loading());
try {
final user = await authService.login(event.email, event.password);
emit(UserState.loaded(user));
} catch (e) {
emit(UserState.error(e.toString()));
}
}
}
// Widget
BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
return state.when(
initial: () => Text('Welcome'),
loading: () => CircularProgressIndicator(),
loaded: (user) => Text('Hello, ${user.name}'),
error: (msg) => Text('Error: $msg'),
);
},
)
// Trigger event
context.read<UserBloc>().add(UserLoginRequested(email: 'a@b.com', password: 'pass'));
Kelebihan:
- ✅ Separation of concerns paling jelas (UI vs Business)
- ✅ Traceable:Buffer untuk history event
- ✅ Cocok untuk aplikasi dengan banyak async streams
- ✅ Testable dengan bloc_test package
Kekurangan:
- Boilerplate paling banyak
- Curva belajar paling curam
- Overkill untuk app sederhana
Tabel Perbandingan
| Metric | Provider | Riverpod | BLoC |
|---|---|---|---|
| Lines of code | ~50 | ~80 | ~120 |
| Type safety | Runtime | Compile-time | Compile-time |
| Testability | Sedang | Cukup baik | Terbaik |
| Learning curve | Rendah | Sedang | Tinggi |
| Best for | MVP, simple app | Production ready | Enterprise apps |
| Async handling | Manual | AsyncValue + when() | StreamSubscription |
Arsitektur Rekomendasi per Scale
MVP / Prototype → Provider
- 5-15 screens, 1-2 features
- Tim kecil (1-2 developer)
- Timeline cepat (1-2 bulan)
- Frequent UI changes
Production App → Riverpod
- 15-50 screens, banyak async features
- Tim sedang (3-6 developer)
- Long-term maintenance
- Pa trade-off dengan type safety
Enterprise → BLoC Pair + Clean Architecture
- 50+ screens, kompleks business logic
- Tim besar (10+ developer)
- Strict separation between UI dan business logic
- Banyak integrasi backend & services pihak ketiga
Tips Implementasi
Hindari rebuild yang tidak perlu
// 🚫 Watch everything (rebuild yang berat)
final user = ref.watch(userProvider);
final cart = ref.watch(cartProvider);
// ✅ Select hanya field yang dibutuhkan
final userName = ref.watch(userProvider.select((s) => s.user?.name));
final cartCount = ref.watch(cartProvider.select((s) => s.items.length));
Dependency Injection terorganisir
final dioProvider = Provider((ref) => Dio());
final apiClientProvider = Provider((ref) => ApiClient(ref.read(dioProvider)));
final userRepoProvider = Provider((ref) => UserRepository(ref.read(apiClientProvider)));
final userProvider = NotifierProvider<UserNotifier, UserState>(() => UserNotifier());
Test isolate state
testWidgets('Login updates state', (tester) async {
final container = ProviderContainer(
overrides: [userRepoProvider.overrideWithValue(MockUserRepo())],
);
await container.read(userProvider.notifier).login('a@b.com', 'pass');
expect(container.read(userProvider), isA<UserStateLoaded>());
container.dispose();
});
Kesimpulan
Untuk project yang sudah commit jangka panjang, saya merekomendasikan Riverpod sebagai default. Type safety, testability, dan DX (Developer Experience) yang lebih baik mengalahkan boilerplate tambahan. Bila skala project mendekati enterprise dengan banyak tim paralel, BLoC memberikan struktur yang lebih disiplin.
Pilihan akhir tergantung tim dan kebutuhan — jangan pilih berdasarkan hype, pilih berdasarkan problem Anda.