Memilih State Management Flutter untuk Project Jangka Panjang

I Nyoman Donostia 843 kata
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.

I Nyoman Donostia

I Nyoman Donostia

Fullstack Developer - Bali, Indonesia

Artikel Terkait