Best Practice #

Memilih library state management yang tepat hanyalah separuh dari perjalanan. Separuh lainnya adalah bagaimana kamu menerapkannya. Banyak aplikasi yang menggunakan library yang benar tapi dengan cara yang salah — logika bisnis tercampur di UI, state yang bisa dimutasi dari mana saja, atau test yang tidak bisa ditulis. Artikel ini mengumpulkan best practice yang berlaku lintas library.

1. Pisahkan UI dari Logika Bisnis #

Prinsip paling mendasar: widget seharusnya hanya bertanggung jawab untuk rendering. Logika bisnis, kalkulasi, validasi, dan akses data seharusnya ada di layer terpisah.

// ANTI-PATTERN: logika bisnis tercampur di widget
class _CheckoutState extends State<CheckoutScreen> {
  Future<void> _checkout() async {
    // Validasi, kalkulasi, akses API -- semua di widget!
    if (keranjang.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(...);
      return;
    }
    final subtotal = keranjang.fold(0.0, (sum, item) => sum + item.subtotal);
    final pajak = subtotal * 0.11;
    final total = subtotal + pajak;
    if (total > saldoUser) {
      // error handling
      return;
    }
    final pesanan = await pesananApi.buat(keranjang, total);
    await pembayaranApi.proses(pesanan.id);
    Navigator.push(context, MaterialPageRoute(...));
  }
}

// BENAR: widget hanya mengorkestrasi, logika ada di notifier/bloc/store
class CheckoutScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(checkoutProvider);
    return ElevatedButton(
      onPressed: state.isLoading ? null : () => ref.read(checkoutProvider.notifier).checkout(),
      child: state.isLoading
          ? const CircularProgressIndicator()
          : const Text('Bayar Sekarang'),
    );
  }
}

// Logika bisnis ada di Notifier
class CheckoutNotifier extends AsyncNotifier<CheckoutState> {
  @override
  Future<CheckoutState> build() async => const CheckoutState();

  Future<void> checkout() async {
    final keranjang = ref.read(keranjangProvider);
    state = const AsyncLoading();
    try {
      // Validasi
      if (keranjang.isEmpty) throw Exception('Keranjang kosong');

      // Kalkulasi
      final total = _hitungTotal(keranjang);

      // Akses data
      final pesanan = await ref.read(pesananRepoProvider).buat(keranjang, total);
      await ref.read(pembayaranRepoProvider).proses(pesanan.id);

      state = AsyncData(CheckoutState.sukses(pesanan));
    } catch (e) {
      state = AsyncError(e, StackTrace.current);
    }
  }
}

2. Single Source of Truth #

Setiap data seharusnya hanya ada di satu tempat — satu “sumber kebenaran”. Duplikasi state menyebabkan inkonsistensi yang sulit di-debug.

// ANTI-PATTERN: data yang sama disimpan di dua tempat
class _ProfilState extends State<ProfilScreen> {
  String _nama = '';  // duplikat dari state global!

  @override
  void initState() {
    super.initState();
    // Salin dari global state ke local state -- sekarang ada dua sumber
    _nama = context.read<AuthModel>().user!.nama;
  }

  // Saat nama diubah, mana yang jadi acuan? Keduanya bisa berbeda!
}

// BENAR: satu sumber kebenaran, widget hanya membaca
class ProfilScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Selalu baca langsung dari sumber
    final user = ref.watch(authProvider.select((s) => s.user));
    return Text(user?.nama ?? '');
  }
}

3. State Harus Immutable #

State yang immutable memudahkan debugging, mencegah bug akibat mutasi tidak sengaja, dan memungkinkan perbandingan state yang efisien.

// ANTI-PATTERN: state yang mutable
class ProdukState {
  List<Produk> produk;     // mutable list -- bisa diubah dari luar!
  ProdukState({required this.produk});
}

// Di suatu widget yang tidak terduga:
state.produk.add(produkBaru);  // mutasi langsung -- tidak ada yang tahu!

// BENAR: state immutable dengan copyWith
@immutable
class ProdukState {
  final List<Produk> produk;    // final -- tidak bisa di-reassign
  final bool isLoading;
  final String? error;

  const ProdukState({
    this.produk = const [],    // list const -- tidak bisa dimutasi
    this.isLoading = false,
    this.error,
  });

  ProdukState copyWith({
    List<Produk>? produk,
    bool? isLoading,
    String? error,
  }) => ProdukState(
    produk: produk ?? this.produk,
    isLoading: isLoading ?? this.isLoading,
    error: error,
  );
}

// Perubahan state selalu menghasilkan instance baru
state = state.copyWith(isLoading: true);         // instance baru
state = state.copyWith(produk: [...state.produk, baru]);  // instance baru

4. State yang Granular — Jangan Satu State untuk Segalanya #

State yang terlalu besar menyebabkan rebuild yang tidak perlu dan membuat tracking perubahan sulit.

// KURANG OPTIMAL: satu state besar untuk seluruh aplikasi
class AppState {
  final User? user;
  final List<Produk> produk;
  final List<CartItem> keranjang;
  final List<Notifikasi> notifikasi;
  final ThemeMode themeMode;
  final String language;
  // ... puluhan field lagi
}
// Perubahan notif menyebabkan seluruh app rebuild!

// LEBIH BAIK: state terpisah per domain/fitur
// Masing-masing punya provider/notifier sendiri
class AuthState { ... }      // hanya auth
class ProdukState { ... }    // hanya produk
class KeranjangState { ... } // hanya keranjang
class TemaState { ... }      // hanya tema

// Setiap widget hanya subscribe ke state yang ia butuhkan
final user = ref.watch(authProvider.select((s) => s.user));
final jumlahKeranjang = ref.watch(keranjangProvider.select((s) => s.jumlah));
// Perubahan keranjang tidak menyebabkan widget auth rebuild

5. Handle Semua State Async — Loading, Error, Data #

State async yang tidak ditangani lengkap adalah sumber bug yang umum:

// ANTI-PATTERN: hanya handle kasus sukses
class _ProdukState extends State<ProdukScreen> {
  List<Produk> _produk = [];

  Future<void> _load() async {
    _produk = await api.getProduk();  // bagaimana jika gagal? bagaimana loading?
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return ProdukList(produk: _produk);  // tampil kosong saat loading/error
  }
}

// BENAR: handle loading, error, dan data secara eksplisit
// Dengan Riverpod AsyncValue:
ref.watch(produkProvider).when(
  loading: () => const Center(child: CircularProgressIndicator()),
  error: (error, stack) => ErrorWidget(
    pesan: error.toString(),
    onRetry: () => ref.invalidate(produkProvider),
  ),
  data: (produk) => produk.isEmpty
      ? const EmptyState(pesan: 'Belum ada produk')
      : ProdukList(produk: produk),
)

// Pattern state yang lengkap (untuk setState/Provider):
@immutable
class AsyncState<T> {
  final T? data;
  final bool isLoading;
  final Object? error;

  const AsyncState({this.data, this.isLoading = false, this.error});

  bool get hasData => data != null;
  bool get hasError => error != null;

  AsyncState<T> loading() => AsyncState(isLoading: true);
  AsyncState<T> success(T data) => AsyncState(data: data);
  AsyncState<T> failure(Object error) => AsyncState(error: error);
}

6. Validasi di Layer yang Tepat #

// ANTI-PATTERN: validasi hanya di UI
ElevatedButton(
  onPressed: () {
    if (_emailController.text.isEmpty) {  // validasi di widget
      // show error
      return;
    }
    authModel.login(_emailController.text, _password);
  },
)

// BENAR: validasi di layer logika bisnis
// UI hanya kirim data mentah
ElevatedButton(
  onPressed: () => ref.read(authProvider.notifier).login(
    _emailController.text,
    _password,
  ),
)

// Validasi ada di Notifier -- bisa ditest secara unit test
class AuthNotifier extends AsyncNotifier<AuthState> {
  Future<void> login(String email, String password) async {
    // Validasi di sini
    if (email.isEmpty) throw ValidationException('Email tidak boleh kosong');
    if (!email.contains('@')) throw ValidationException('Format email tidak valid');
    if (password.length < 8) throw ValidationException('Password minimal 8 karakter');

    state = const AsyncLoading();
    try {
      final user = await ref.read(authRepoProvider).login(email, password);
      state = AsyncData(AuthState.authenticated(user));
    } catch (e) {
      state = AsyncError(e, StackTrace.current);
    }
  }
}

7. Tulis Test untuk Logika State #

Keunggulan terbesar memisahkan logika dari UI adalah: logika bisa diuji tanpa widget:

// Test Riverpod Notifier -- tidak butuh widget sama sekali
void main() {
  group('AuthNotifier', () {
    late ProviderContainer container;

    setUp(() {
      container = ProviderContainer(
        overrides: [
          authRepoProvider.overrideWith((_) => MockAuthRepo()),
        ],
      );
    });

    tearDown(() => container.dispose());

    test('login berhasil menghasilkan state Authenticated', () async {
      final notifier = container.read(authProvider.notifier);
      await notifier.login('[email protected]', 'password123');

      final state = container.read(authProvider);
      expect(state.value?.isAuthenticated, isTrue);
    });

    test('login dengan email kosong melempar ValidationException', () async {
      final notifier = container.read(authProvider.notifier);

      expect(
        () => notifier.login('', 'password123'),
        throwsA(isA<ValidationException>()),
      );
    });
  });

  // Test Bloc -- dengan bloc_test
  group('AuthBloc', () {
    late AuthBloc bloc;

    setUp(() => bloc = AuthBloc(MockAuthRepo()));
    tearDown(() => bloc.close());

    blocTest<AuthBloc, AuthState>(
      'mengemit [AuthLoading, AuthAuthenticated] saat LoginSubmitted berhasil',
      build: () => bloc,
      act: (bloc) => bloc.add(
        const LoginSubmitted(email: '[email protected]', password: 'password123'),
      ),
      expect: () => [AuthLoading(), isA<AuthAuthenticated>()],
    );
  });
}

8. Hindari “God Notifier” — Satu Notifier untuk Segalanya #

// ANTI-PATTERN: satu notifier besar yang mengurus semua hal
class AppNotifier extends ChangeNotifier {
  // Auth
  User? currentUser;
  Future<void> login(String email, String password) { ... }
  Future<void> logout() { ... }

  // Produk
  List<Produk> produk = [];
  Future<void> loadProduk() { ... }
  Future<void> tambahProduk(Produk p) { ... }

  // Keranjang
  List<CartItem> keranjang = [];
  void tambahKeKeranjang(Produk p) { ... }

  // Notifikasi
  List<Notifikasi> notifikasi = [];
  Future<void> loadNotifikasi() { ... }
  // ...40 method lagi
}
// Perubahan notifikasi menyebabkan seluruh app rebuild!

// BENAR: notifier per domain
class AuthNotifier extends AsyncNotifier<AuthState> { ... }
class ProdukNotifier extends AsyncNotifier<List<Produk>> { ... }
class KeranjangNotifier extends Notifier<KeranjangState> { ... }
class NotifikasiNotifier extends AsyncNotifier<List<Notifikasi>> { ... }

9. Dispose Resource dengan Benar #

// Provider (ChangeNotifier) -- otomatis dispose oleh Provider
ChangeNotifierProvider(
  create: (_) => MyModel(),
  // dispose() dipanggil otomatis saat provider dilepas dari tree
)

// Riverpod -- autoDispose
final myProvider = AsyncNotifierProvider.autoDispose<MyNotifier, Data>(
  MyNotifier.new,
  // provider otomatis di-dispose saat tidak ada listener
);

// Reaction MobX -- harus dispose manual
class _MyState extends State<MyWidget> {
  late ReactionDisposer _disposer;

  @override
  void initState() {
    super.initState();
    _disposer = reaction((_) => store.someValue, (value) { ... });
  }

  @override
  void dispose() {
    _disposer();  // WAJIB!
    super.dispose();
  }
}

// Bloc -- otomatis dispose oleh BlocProvider
// Tapi jika dibuat manual, harus close() secara eksplisit
final bloc = MyBloc();
// gunakan bloc...
await bloc.close();  // WAJIB jika dibuat secara manual

Checklist Review State Management #

DESAIN:
  □ State terpisah per domain/fitur (tidak satu state besar)
  □ Tidak ada duplikasi state (single source of truth)
  □ State class bersifat @immutable dengan copyWith
  □ Logika bisnis ada di notifier/bloc/store, bukan di widget

ASYNC:
  □ Semua state async menangani loading, error, DAN data
  □ Error ditangani dengan state spesifik (bukan hanya print)
  □ Tidak ada setState setelah widget di-dispose (cek mounted)
  □ Resource yang menggunakan stream/subscription di-dispose

PERFORMA:
  □ Widget hanya subscribe ke state yang ia butuhkan (select)
  □ Tidak ada rebuild yang tidak perlu (gunakan Equatable/==)
  □ State granular -- perubahan di satu domain tidak mempengaruhi domain lain

TESTING:
  □ Ada unit test untuk logika bisnis utama
  □ Test berjalan tanpa Flutter widget tree
  □ Mock digunakan untuk dependency eksternal (API, database)
  □ Edge case (loading, error, kosong) di-test

MAINTAINABILITY:
  □ Nama state, event, dan method deskriptif dan konsisten
  □ Satu notifier/bloc per domain, tidak "god object"
  □ Validasi ada di layer logika bisnis, bukan di widget
  □ Tidak ada logika bisnis tersebar di banyak tempat

Ringkasan #

  • Pisahkan UI dari logika bisnis — widget hanya render, notifier/bloc/store yang mengurus kalkulasi, validasi, dan akses data. Ini membuat code mudah diuji dan dipelihara.
  • Single source of truth — setiap data hanya ada di satu tempat. Duplikasi state menyebabkan inkonsistensi yang sulit di-debug.
  • State harus immutable — gunakan @immutable, final fields, dan copyWith(). Mutasi langsung membuat perubahan tidak terlacak.
  • Buat state granular — pisahkan state per domain/fitur. Perubahan di satu domain tidak seharusnya menyebabkan rebuild di domain lain.
  • Handle semua state async — loading, error, AND data. Melewatkan salah satu menyebabkan tampilan yang salah atau crash.
  • Validasi di layer logika bisnis — bukan di widget. Ini memungkinkan validasi ditest secara unit test tanpa widget.
  • Tulis unit test untuk logika state — keunggulan terbesar memisahkan logika dari UI adalah logika bisa diuji tanpa widget tree.
  • Hindari “God Notifier” — satu notifier per domain. Notifier besar menyebabkan rebuild yang tidak perlu dan sulit dipelihara.
  • Dispose resource dengan benar — reaction MobX, stream subscription, dan bloc yang dibuat manual harus di-dispose/close secara eksplisit.

← Sebelumnya: Perbandingan & Kapan Memilih   Berikutnya: Networking Overview →

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