MobX #

MobX adalah library state management berbasis Transparent Functional Reactive Programming (TFRP) — sebuah pendekatan di mana state, UI, dan logika bisnis dihubungkan secara otomatis melalui mekanisme observasi. MobX berasal dari ekosistem JavaScript/React dan di-port ke Dart dengan filosofi yang sama: apa yang bisa diturunkan dari state, harus diturunkan secara otomatis.

Tiga Konsep Inti MobX #

OBSERVABLE → Reactive state (data yang bisa berubah dan dipantau)
    ↓
ACTION → Operasi yang mengubah Observable
    ↓
REACTION → Efek samping yang berjalan otomatis ketika Observable berubah
           (termasuk rebuild UI melalui Observer widget)

COMPUTED → State turunan yang dihitung otomatis dari Observable lain

Instalasi #

# pubspec.yaml
dependencies:
  mobx: ^2.5.0
  flutter_mobx: ^2.3.0

dev_dependencies:
  build_runner: ^2.9.0
  mobx_codegen: ^2.7.4

Store — Wadah State MobX #

Store adalah kelas yang mengumpulkan Observable, Action, dan Computed yang saling berkaitan. MobX menggunakan code generation untuk menghilangkan boilerplate.

Struktur Dasar Store #

// counter_store.dart
import 'package:mobx/mobx.dart';

// Wajib: sertakan file generated
part 'counter_store.g.dart';

// Deklarasi: kelas publik = abstract class + mixin generated
class CounterStore = _CounterStore with _$CounterStore;

// Implementasi di abstract class dengan Store mixin
abstract class _CounterStore with Store {
  @observable
  int value = 0;

  @action
  void increment() => value++;

  @action
  void decrement() => value--;

  @action
  void reset() => value = 0;
}

Jalankan Code Generation #

# Jalankan sekali untuk generate file .g.dart
flutter pub run build_runner build --delete-conflicting-outputs

# Atau watch mode -- otomatis regenerate saat file berubah (selama development)
flutter pub run build_runner watch --delete-conflicting-outputs

Perintah ini menghasilkan counter_store.g.dart yang berisi semua boilerplate reaktivitas.


Observable — State yang Dipantau #

@observable menandai field sebagai reactive state — MobX akan melacak siapa yang menggunakannya dan memberitahu mereka saat nilainya berubah:

abstract class _ProdukStore with Store {
  // Observable primitif
  @observable
  bool isLoading = false;

  @observable
  String? errorMessage;

  // Observable list -- gunakan ObservableList, bukan List biasa!
  @observable
  ObservableList<Produk> produk = ObservableList<Produk>();

  // Observable map
  @observable
  ObservableMap<String, int> stok = ObservableMap<String, int>();

  // Observable dari tipe kustom
  @observable
  Produk? produkTerpilih;

  // @readonly: menghasilkan getter publik tapi field privat
  // Observer dari luar tidak bisa mengubah nilai langsung
  @readonly
  String _status = 'idle';
}

Perbedaan ObservableList vs List Biasa #

// SALAH: List biasa -- MobX tidak melacak perubahan isi list
@observable
List<Produk> produk = [];     // push/remove TIDAK di-track!
produk.add(item);             // Observer tidak mendeteksi perubahan ini

// BENAR: ObservableList -- setiap mutasi di-track
@observable
ObservableList<Produk> produk = ObservableList();
produk.add(item);             // Observer otomatis di-notify
produk.remove(item);          // Observer otomatis di-notify
produk.clear();               // Observer otomatis di-notify

Action — Mutasi State #

@action menandai method yang mengubah Observable. Semua perubahan di dalam satu @action di-batch — Observer hanya di-notify sekali setelah action selesai, bukan setiap kali field berubah:

abstract class _ProdukStore with Store {
  @observable
  ObservableList<Produk> produk = ObservableList();

  @observable
  bool isLoading = false;

  @observable
  String? error;

  // Action sinkron
  @action
  void setProdukTerpilih(Produk p) {
    produkTerpilih = p;
  }

  // Action async
  @action
  Future<void> loadProduk() async {
    isLoading = true;     // perubahan 1
    error = null;         // perubahan 2
    // Observer belum di-notify karena masih dalam action

    try {
      final data = await produkApi.getAll();
      produk = ObservableList.of(data);  // perubahan 3
    } catch (e) {
      error = e.toString();  // perubahan 3 (alternatif)
    } finally {
      isLoading = false;  // perubahan 4
    }
    // Setelah action selesai: Observer di-notify sekali
  }

  // Action yang mengubah banyak field -- tetap di-batch
  @action
  void reset() {
    produk.clear();
    isLoading = false;
    error = null;
    produkTerpilih = null;
    // Satu kali notifikasi, meskipun 4 field berubah
  }
}

Computed — State Turunan #

@computed mendefinisikan state yang diturunkan dari Observable lain — nilainya dihitung ulang secara otomatis ketika Observable yang ia gunakan berubah. MobX mengikuti prinsip: apa yang bisa diturunkan, harus diturunkan secara otomatis:

abstract class _KeranjangStore with Store {
  @observable
  ObservableList<CartItem> items = ObservableList();

  // Computed: otomatis update saat items berubah
  @computed
  double get totalHarga => items.fold(0, (sum, item) => sum + item.subtotal);

  @computed
  int get jumlahItem => items.fold(0, (sum, item) => sum + item.jumlah);

  @computed
  bool get isEmpty => items.isEmpty;

  @computed
  bool get isPremiumEligible => totalHarga >= 500000;

  // Computed bisa bergantung pada computed lain
  @computed
  double get totalSetelahDiskon =>
      isPremiumEligible ? totalHarga * 0.9 : totalHarga;

  @computed
  String get ringkasanKeranjang =>
      '$jumlahItem item • Rp ${totalHarga.toStringAsFixed(0)}';
}

Berbeda dengan helper function biasa, @computed di-memoize — nilainya hanya dihitung ulang jika Observable yang digunakan benar-benar berubah.


Reaction — Side Effect Otomatis #

Reaction adalah cara MobX menjalankan kode secara otomatis ketika Observable berubah — di luar rebuild widget:

abstract class _AuthStore with Store {
  @observable
  User? currentUser;

  // Simpan disposer untuk cleanup
  late ReactionDisposer _persistenceDisposer;
  late ReactionDisposer _logDisposer;

  void setupReactions() {
    // autorun: langsung berjalan dan setiap kali Observable yang digunakan berubah
    _persistenceDisposer = autorun((_) {
      if (currentUser != null) {
        SharedPreferences.getInstance().then((prefs) {
          prefs.setString('userId', currentUser!.id);
        });
      }
    });

    // reaction: track apa yang di-return, jalankan effect saat berubah
    // Tidak langsung berjalan -- hanya setelah perubahan pertama
    _logDisposer = reaction(
      (_) => currentUser?.id,  // apa yang di-track
      (String? userId) {        // effect saat berubah
        print('User changed: $userId');
        analyticsService.setUserId(userId);
      },
    );

    // when: berjalan sekali saat kondisi terpenuhi, lalu auto-dispose
    when(
      (_) => currentUser != null,
      () => print('User logged in: ${currentUser!.nama}'),
    );
  }

  void dispose() {
    _persistenceDisposer();   // panggil disposer untuk berhenti tracking
    _logDisposer();
  }
}

Observer Widget — Rebuild Otomatis #

Observer dari flutter_mobx adalah widget yang secara otomatis rebuild ketika Observable yang digunakan di dalam builder-nya berubah:

import 'package:flutter_mobx/flutter_mobx.dart';

class KonterWidget extends StatelessWidget {
  final CounterStore store;
  const KonterWidget({super.key, required this.store});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Observer hanya wrap bagian yang perlu reaktif
        Observer(
          builder: (_) => Text(
            '${store.value}',
            style: const TextStyle(fontSize: 48),
          ),
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: store.decrement,  // langsung assign action
              child: const Icon(Icons.remove),
            ),
            const SizedBox(width: 16),
            ElevatedButton(
              onPressed: store.increment,
              child: const Icon(Icons.add),
            ),
          ],
        ),
      ],
    );
  }
}

Observer yang Granular #

Isolasi Observer sekecil mungkin untuk performa optimal:

// KURANG OPTIMAL: satu Observer besar
Observer(
  builder: (_) => Column(
    children: [
      Text(store.nama),         // rebuild jika nama ATAU status berubah
      Text(store.status),
      Text('${store.total}'),
    ],
  ),
)

// LEBIH BAIK: Observer terpisah untuk setiap bagian
Column(
  children: [
    Observer(builder: (_) => Text(store.nama)),    // hanya rebuild saat nama berubah
    Observer(builder: (_) => Text(store.status)),  // hanya rebuild saat status berubah
    Observer(builder: (_) => Text('${store.total}')), // hanya rebuild saat total berubah
  ],
)

Contoh Lengkap — Toko Produk #

// produk_store.dart
import 'package:mobx/mobx.dart';
part 'produk_store.g.dart';

class ProdukStore = _ProdukStore with _$ProdukStore;

abstract class _ProdukStore with Store {
  final ProdukRepository _repository;
  _ProdukStore(this._repository);

  @observable
  ObservableList<Produk> semuaProduk = ObservableList();

  @observable
  String _query = '';

  @observable
  bool isLoading = false;

  @observable
  String? error;

  @computed
  ObservableList<Produk> get produkTerfilter {
    if (_query.isEmpty) return semuaProduk;
    return ObservableList.of(
      semuaProduk.where(
        (p) => p.nama.toLowerCase().contains(_query.toLowerCase()),
      ),
    );
  }

  @computed
  bool get adaProduk => semuaProduk.isNotEmpty;

  @action
  void setQuery(String query) => _query = query;

  @action
  Future<void> loadProduk() async {
    isLoading = true;
    error = null;
    try {
      final data = await _repository.getAll();
      semuaProduk = ObservableList.of(data);
    } catch (e) {
      error = e.toString();
    } finally {
      isLoading = false;
    }
  }

  @action
  Future<void> hapusProduk(String id) async {
    final index = semuaProduk.indexWhere((p) => p.id == id);
    if (index < 0) return;

    // Optimistic update
    final removed = semuaProduk.removeAt(index);
    try {
      await _repository.hapus(id);
    } catch (e) {
      // Rollback
      semuaProduk.insert(index, removed);
      error = 'Gagal menghapus: $e';
    }
  }
}

// produk_screen.dart
class ProdukScreen extends StatefulWidget {
  const ProdukScreen({super.key});
  @override
  State<ProdukScreen> createState() => _ProdukScreenState();
}

class _ProdukScreenState extends State<ProdukScreen> {
  // Store bisa dibuat di widget atau diinject via Provider
  late final ProdukStore _store;

  @override
  void initState() {
    super.initState();
    _store = ProdukStore(context.read<ProdukRepository>());
    _store.loadProduk();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Produk'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(56),
          child: Padding(
            padding: const EdgeInsets.all(8),
            child: TextField(
              onChanged: _store.setQuery,  // langsung sambungkan ke action
              decoration: const InputDecoration(
                hintText: 'Cari produk...',
                prefixIcon: Icon(Icons.search),
              ),
            ),
          ),
        ),
      ),
      body: Observer(
        builder: (_) {
          if (_store.isLoading) return const Center(child: CircularProgressIndicator());
          if (_store.error != null) return Center(child: Text(_store.error!));
          if (!_store.adaProduk) return const Center(child: Text('Belum ada produk'));
          return ListView.builder(
            itemCount: _store.produkTerfilter.length,
            itemBuilder: (context, index) {
              final produk = _store.produkTerfilter[index];
              return ListTile(
                title: Text(produk.nama),
                subtitle: Text('Rp ${produk.harga}'),
                trailing: IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () => _store.hapusProduk(produk.id),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Integrasi MobX dengan Provider #

Untuk membagikan Store ke banyak widget, gabungkan MobX dengan Provider:

// Inject Store via Provider
void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider<ProdukRepository>(create: (_) => ProdukRepositoryImpl()),
        // Store disediakan via Provider
        ProxyProvider<ProdukRepository, ProdukStore>(
          create: (context) => ProdukStore(context.read()),
          update: (_, repo, store) => store!..updateRepository(repo),
        ),
      ],
      child: const MyApp(),
    ),
  );
}

// Akses di widget
class ProdukList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final store = context.read<ProdukStore>();
    return Observer(
      builder: (_) => ListView.builder(
        itemCount: store.produkTerfilter.length,
        itemBuilder: (_, i) => ProdukTile(produk: store.produkTerfilter[i]),
      ),
    );
  }
}

Ringkasan #

  • MobX menggunakan TFRP — koneksi antara state dan UI terjadi secara otomatis melalui mekanisme observasi, bukan secara eksplisit.
  • @observable menandai state yang dipantau. Gunakan ObservableList dan ObservableMap alih-alih collection biasa agar mutasi isi ter-track.
  • @action menandai method yang mengubah state. Semua perubahan di dalam satu action di-batch — Observer hanya di-notify sekali setelah action selesai.
  • @computed adalah state turunan yang di-memoize — hanya dihitung ulang saat Observable yang digunakan berubah. Ikuti prinsip “apa yang bisa diturunkan, harus diturunkan”.
  • Reaction (autorun, reaction, when) untuk side effect otomatis — selalu simpan disposer dan panggil saat widget dihancurkan.
  • Observer widget otomatis rebuild ketika Observable yang digunakan di builder-nya berubah — isolasi Observer sekecil mungkin untuk performa optimal.
  • Jalankan build_runner setelah mengubah Store untuk meregenerasi file .g.dart. Gunakan watch mode selama development.
  • Gabungkan MobX dengan Provider untuk dependency injection Store ke seluruh widget tree.

← Sebelumnya: Bloc & Cubit   Berikutnya: Perbandingan & Kapan Memilih →

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