Mert Özen Her Satırda Daha İleri
Frontend Flutter ile Mobil — #11

Flutter'da State Management: setState'ten ChangeNotifier'a, provider ile DI ve Resmî Mimari Rehberi

Mert Özen 15 Kas 2026 10 dk 5 görüntülenme
Flutter'da State Management: setState'ten ChangeNotifier'a, provider ile DI ve Resmî Mimari Rehberi

Flutter'ın resmî mimari rehberi app state için View, ViewModel, Repository ve Service katmanlarını, tek yönlü veri akışını ve provider ile dependency injection'ı öneriyor. Bookstore'un sepetini ChangeNotifier tabanlı CartViewModel, Command ve sealed Result ile kuruyor, Selector'ın fiyat değişikliğinde gereksiz rebuild'i kestiğini gerçek test çıktılarıyla gösteriyor ve Riverpod, Bloc ve signals ile karşılaştırıyoruz.

Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7), material_ui 1.11.0 ve provider 6.1.5+1 ile denenmiştir. Bütün çıktılar flutter test koşularından; emulator ve cihazda çalıştırma bu yazı için yapılmadı. Karşılaştırmadaki sürümler pub.dev'den (2026-11-15): flutter_riverpod 3.5.0, flutter_bloc 9.1.1, signals_flutter 7.1.0; bu paketlerle kod çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) katalog ekranında sepetteki kitap sayısını gösteren bir rozet var. Sepetin state'i CartViewModel'de; rozeti iki şekilde yazdık. Biri context.watch<CartViewModel>() ile ViewModel'in tamamını dinliyor, diğeri provider'ın Selector'ıyla yalnız itemCount'u seçiyor:

Selector<CartViewModel, int>(
  selector: (_, cart) => cart.itemCount,
  builder: (context, count, _) {
    selectorBuilds++;
    return Text('selector: $count');
  },
)

Kullanıcı sepete Dune'u ekliyor. Sonra sunucu Dune'un fiyatını düşürüyor ve sepet yeniden yükleniyor: toplam tutar değişiyor, kitap sayısı aynı kalıyor. Fiyat değişikliğinde hangi rozet yeniden build edilir: ikisi de mi, yalnız biri mi, hiçbiri mi? Cevap, bu yazıda kuracağımız yapının neden katmanlara ayrıldığını da gösteriyor.

Ephemeral State ve App State

Şimdiye kadar state'i widget'larda tuttuk: favori butonu kendi _isFavorite'ini, katalog ekranı sayfaları, HomeScreen sepet sayacını. Flutter dokümanı state'i ikiye ayırıyor. Ephemeral state (geçici, tek bir widget'a ait state) bir widget'ın dışında kimseyi ilgilendirmez: bir sekmenin seçili olması, bir animasyonun ilerlemesi, şifrenin görünür olup olmaması. Bunun için StatefulWidget ve setState doğru araç. App state ise birden çok ekranın paylaştığı ve ekran kapanınca kaybolmaması gereken state: sepet, giriş yapan kullanıcı, katalog. Sepet sayısı katalogda, kitap detayında ve sepet ekranında görünüyor; bir widget'ın State'inde kalırsa diğer ekranlar ona callback zincirleriyle ulaşmak zorunda kalır.

Resmî Mimari Rehberi: Katmanlar ve Tek Yönlü Akış

Flutter'ın mimari rehberi (docs.flutter.dev/app-architecture) app state için bir yapı öneriyor ve öneriyi güç derecesiyle etiketliyor. "Strongly recommend" olanlar: UI ile data katmanını ayırmak, data katmanında repository kullanmak, UI katmanında MVVM (Model-View-ViewModel; ekranı, ekranın state'ini tutan ViewModel'den ayıran pattern), tek yönlü veri akışı, immutable modeller, provider ile dependency injection ve testte mock yerine fake. Command pattern "Recommend", ChangeNotifier ise "Conditional", yani seçeneklerden biri. Bookstore'un 11. yazı sonundaki hâli:

11-mvvm-layers-tr.png

View bir widget: gösterir ve kullanıcının yaptığını ViewModel'e iletir, iş mantığı içermez. ViewModel ekranın state'ini tutar, kullanıcı event'lerini Command olarak sunar ve değişince dinleyicilerine haber verir. Repository verinin tek kaynağıdır: cache, hata yönetimi ve dönüşüm orada. Service ise dış dünyayla konuşan ince katman: bugün geçici SampleCatalog, API entegrasyonu yazısında Bookstore API'si, local storage yazısında cihazdaki depolama. Event'ler aşağı, state yukarı akıyor; ViewModel state'i yalnız repository'den gelen sonuçla değiştiriyor. UI Mimari Pattern'leri serisinin "MVVM ve Presentation Model: Data Binding, Command ve ViewModel" ile "Flux ve Unidirectional Data Flow: Two-Way Binding Neden Ölçeklenmedi?" yazılarındaki fikir bu; burada Flutter'daki karşılığını kuruyoruz.

Result ve Command

Repository'ler exception fırlatmak yerine bir Result dönüyor. Dördüncü yazıdaki sealed class'ın aynısı:

/// Başarısız olabilen bir işlemin sonucu; exception fırlatmak yerine döner.
/// dart:core'daki Error'u gölgelememek için adları Ok/Failure.
sealed class Result<T> {
  const Result();

  const factory Result.ok(T value) = Ok<T>;
  const factory Result.failure(Object error) = Failure<T>;
}

final class Ok<T> extends Result<T> {
  const Ok(this.value);
  final T value;
}

final class Failure<T> extends Result<T> {
  const Failure(this.error);
  final Object error;
}

Resmî rehberin örnek uygulaması alt tipleri Ok ve Error diye adlandırıyor; biz Failure dedik, çünkü Error adı dart:core'daki class'ı gölgeliyor ve aynı dosyada on Error catch yazıldığında kafa karıştırıyor. Sealed olduğu için switch'te iki durumu da ele almayan kod derlenmiyor; C# 15'te gelen union type'ların yaptığı işin Dart'taki karşılığı.

Command, ViewModel'in tek bir kullanıcı aksiyonunu sarıyor: çalışıyor mu, nasıl bitti. Kendi yazdığımız sürüm:

/// ViewModel'in tek bir kullanıcı aksiyonunu sarar: çalışıyor mu, nasıl bitti.
/// View tüm ViewModel'i değil, command'ı dinler.
abstract class Command<T> extends ChangeNotifier {
  bool _running = false;
  Result<T>? _result;

  bool get running => _running;
  Result<T>? get result => _result;
  bool get completed => _result is Ok<T>;
  bool get failed => _result is Failure<T>;

  /// View hatayı gösterdikten sonra çağrılır, aynı hata iki kez gösterilmesin.
  void clearResult() {
    _result = null;
    notifyListeners();
  }

  Future<void> _run(Future<Result<T>> Function() action) async {
    if (_running) return; // çalışırken gelen ikinci dokunuş yok sayılır
    _running = true;
    _result = null;
    notifyListeners();
    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}

Command0 parametresiz, Command1<T, A> tek parametreli aksiyonlar için bu class'ı genişletiyor. Sekizinci yazıdaki _isLoading bayrağı ve "çift istek gitmesin" kontrolü artık her aksiyonda ayrı ayrı yazılmıyor, command'ın içinde. Sepete ekleme command'ının yaşam döngüsünü dinledik:

running=true completed=false failed=false
running=false completed=true failed=false

ViewModel ve Repository: Sepet

CartViewModel sepetin satırlarını tutuyor ve üç command sunuyor:

class CartViewModel extends ChangeNotifier {
  CartViewModel({required CartRepository cartRepository})
    : _repository = cartRepository {
    load = Command0(_load);
    add = Command1(_add);
    setQuantity = Command1(_setQuantity);
    // Constructor await edemez; sonucu command'ın kendisi bildirir
    unawaited(load.execute());
  }

  final CartRepository _repository;
  List<CartLine> _lines = const [];

  late final Command0<void> load;
  late final Command1<void, Book> add;
  late final Command1<void, CartLine> setQuantity;

  List<CartLine> get lines => _lines;
  int get itemCount => summarizeCart(_lines).itemCount;
  Money get total => summarizeCart(_lines).total;

  int quantityOf(Book book) =>
      _lines.where((line) => line.book == book).firstOrNull?.quantity ?? 0;

  Future<Result<void>> _load() => _apply(_repository.getLines());

  Future<Result<void>> _add(Book book) =>
      _apply(_repository.setQuantity(book, quantityOf(book) + 1));

  Future<Result<void>> _setQuantity(CartLine line) =>
      _apply(_repository.setQuantity(line.book, line.quantity));

  /// State'in değiştiği tek yer: tek yön, repository -> state -> View.
  Future<Result<void>> _apply(Future<Result<List<CartLine>>> change) async {
    switch (await change) {
      case Ok(:final value):
        _lines = List.unmodifiable(value);
        notifyListeners();
        return const Result.ok(null);
      case Failure(:final error):
        return Result.failure(error);
    }
  }
}

Satırlar üçüncü yazıdaki CartLine record'u, toplam da oradaki summarizeCart; ViewModel hesap yapmıyor, var olan domain kodunu kullanıyor. Dışarıya verilen liste List.unmodifiable; View listeyi değiştiremiyor, değişiklik isteğini command'la iletiyor. Constructor'daki unawaited'ı beşinci yazıda açtığımız discarded_futures lint'i istedi: ilk hâlinde load.execute() yazmıştık ve flutter analyze bunu "'Future'-returning calls in a non-'async' function" diye işaretledi. Unit testte aynı kitabı iki kez ekledik:

lines 1, quantity 2, items 2, total 978.00 TRY, ViewModel notifications 3

Üç bildirim: ilk yükleme ve iki ekleme. Repository tarafında CartRepository bir abstract class; şimdilik bellekte tutan InMemoryCartRepository var, local storage yazısında cihazda saklayan bir implementation gelecek ve ViewModel'e dokunmayacağız. Kuralı da repository uyguluyor: bir siparişte bir kitaptan en fazla 10 adet. On birinci ekleme:

failed=true, error: At most 10 copies of a book per order, quantity 10

Hata exception olarak uçmadı, Failure olarak command'a geldi ve state değişmedi. Katalog ekranı add command'ını dinleyip failed olduğunda bir SnackBar gösteriyor ve clearResult() çağırıyor. Sekizinci yazıdaki sayfa yükleme mantığı da aynı şekilde CatalogViewModel'e taşındı: loadMore ve refresh iki command, ekran yalnız onları çağırıyor.

View: ListenableBuilder

View, ViewModel'i constructor'dan alıyor ve ListenableBuilder ile dinliyor. Sepet ekranının iskeleti:

class CartScreen extends StatelessWidget {
  const CartScreen({super.key, required this.viewModel, this.onCheckout});

  final CartViewModel viewModel;
  final VoidCallback? onCheckout;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cart')),
      body: ListenableBuilder(
        listenable: viewModel,
        builder: (context, _) {
          if (viewModel.lines.isEmpty) {
            return const EmptyView(
              message: 'Your cart is empty.',
              icon: Icons.shopping_cart_outlined,
            );
          }

ListenableBuilder, verilen Listenable bildirim yaptığında yalnız kendi builder'ını yeniden çalıştırıyor; Scaffold ve AppBar yeniden build edilmiyor. CartScreen artık bir StatelessWidget: ekranın state'i ViewModel'de. Katalog ekranı iki şeyi birden dinliyor: Listenable.merge([viewModel, viewModel.loadMore]), yani kitap listesi ve yükleme durumu. Angular'daki signal'lara benzer bir fikir: bir değer değişince ona bağlı olan bölüm güncelleniyor, ama bağımlılığı Flutter otomatik izlemiyor, hangi Listenable'ı dinlediğini sen söylüyorsun.

provider ile Dependency Injection

ViewModel'ler repository'yi constructor'dan alıyor; repository'leri kim oluşturup kime veriyor? provider paketi, nesneleri widget ağacına koyup alt ağaçtaki her widget'ın onları bulmasını sağlıyor. Altıncı yazıda Theme.of(context)'in ağaçta yukarı doğru aradığını söylemiştik; provider aynı mekanizmanın üzerine kurulu. Bookstore'un nesne grafiği tek bir dosyada:

/// Uygulamanın nesne grafiği. Testler kendi repository'lerini verir.
List<SingleChildWidget> appProviders({
  BookRepository? bookRepository,
  CartRepository? cartRepository,
}) => [
  Provider<BookRepository>(
    create: (_) => bookRepository ?? SampleBookRepository(SampleCatalog()),
  ),
  Provider<CartRepository>(
    create: (_) => cartRepository ?? InMemoryCartRepository(),
  ),
  ChangeNotifierProvider(
    create: (context) => CartViewModel(cartRepository: context.read()),
  ),
];

main.dart'ta uygulama MultiProvider(providers: appProviders(), child: const BookstoreApp()) ile başlıyor. Angular serisindeki "Angular Service ve Dependency Injection: providedIn, Injector Tree ve Provider'lar" yazısını okuduysan eşleme kolay: Provider bir provider tanımı, context.read<T>() bir inject(T), ağaçtaki konum injector hiyerarşisi. Farklar: provider tip güvenliğini derleme anında değil çalışma anında kontrol ediyor (ağaçta olmayan bir tipi istersen ProviderNotFoundException alırsın) ve lifetime'ı ağaçtaki yer belirliyor. CartViewModel en üstte, çünkü sepet bütün uygulamanın; CatalogViewModel ise katalog ekranının State'inde, initState'te oluşturulup dispose'ta kapatılıyor.

Okumanın üç yolu var. context.read<T>() nesneyi bir kez alır ve dinlemez; callback'lerde ve initState'te kullanılır. context.watch<T>() nesneyi dinler, her bildirimde widget'ı yeniden build eder. Selector nesnenin bir parçasını seçer ve yalnız o parça değişince build eder. "Önce Tahmin Et"teki testin çıktısı:

after add: watch 2 builds, Selector 2 builds
after price change: watch 3 builds, Selector 2 builds, total 399.00 TRY

Fiyat değişikliğinde ViewModel bildirim yaptı, watch kullanan rozet yeniden build edildi; Selector ise itemCount'un hâlâ 1 olduğunu görüp build etmedi. Katalog ekranının app bar'ındaki rozet bu yüzden Selector ile yazıldı. Testteki fiyat değişikliğini, gerçek repository'den türetilmiş bir fake repository üretiyor; resmî rehberin "mock yerine fake" önerisi test yazısında ayrıntılı.

Alternatifler: Riverpod, Bloc ve signals

Flutter'da state management için tek bir doğru cevap yok; resmî rehber de provider ve ChangeNotifier'ı bir seçenek olarak sunuyor. Sık karşılaşacağın üç alternatif:

YaklaşımModelGüçlü yanıBedeli
provider + ChangeNotifierAğaçtaki nesneler, elle notifyListenersAz kavram, resmî rehberin örnekleriEksik tip çalışma anında yakalanır; bildirimi unutmak kolay
Riverpod 3Global tanımlı provider'lar, ref.watchEksik bağımlılık derleme anında yakalanır, widget ağacından bağımsız, auto-disposeYeni kavramlar (ref, family, notifier); isteğe bağlı kod üretimi ek bir build adımı getirir
Bloc 9Event gelir, yeni state çıkar (stream)Katı ve öngörülebilir akış, büyük ekipte tutarlılıkHer aksiyon için event ve state class'ları; daha çok kod
signalsSignal, computed, effectBağımlılık otomatik izlenir; Angular signal'larına çok yakınFlutter ekosisteminde daha az yaygın

Seçim kriterleri: ekibin büyüklüğü ve deneyimi (Bloc'un katılığı büyük ekipte işe yarar), test stratejisi (hepsi test edilebilir, ama Riverpod ve Bloc'un kendi test araçları var), ve uygulamanın ömrü boyunca kodu okuyacak kişilerin neyi bildiği. Bu seride provider ve ChangeNotifier ile ilerliyoruz: resmî rehberle birebir örtüşüyor, ek kavram az ve ViewModel'ler düz Dart class'ları olarak kaldığı için başka bir çözüme geçmek gerekirse View'lar ve repository'ler değişmiyor.

Üretimde Dikkat

ViewModel'i dispose et. ChangeNotifierProvider(create: ...) ile oluşturulan ViewModel'i provider kendisi dispose ediyor; bir State'te elle oluşturduğun ViewModel'i ise sen kapatmalısın, yoksa dinleyicileri ve süren istekleri ekrandan sonra da yaşar. ChangeNotifierProvider.value ise verilen nesneyi dispose etmiyor; nesneyi kim oluşturduysa o kapatır.

Bildirimleri ölç. Altıncı yazıdaki DevTools rebuild sayacıyla bir ekranda hangi widget'ın ne sıklıkla build edildiğine bak; bir ViewModel'in her küçük değişikliği bütün ekranı build ediyorsa Selector ya da daha küçük bir ListenableBuilder kullan.

ViewModel'e BuildContext sokma. Navigation ve SnackBar gibi UI işleri View'da kalır; ViewModel yalnız state ve command sonucu üretir. Böylece ViewModel'ler widget'sız, hızlı unit testlerle test edilir.

Sık Düşülen Tuzaklar

Birinci tuzak, state'i değiştirip notifyListeners()'ı çağırmamak. Değer değişir, ekran değişmez. Bütün değişiklikleri _apply gibi tek bir method'dan geçirmek bunu engelliyor.

İkinci tuzak, build içinde context.read ile değer okumak. Değer okunur ama widget dinlemediği için sonraki değişikliklerde güncellenmez ve hiçbir uyarı da çıkmaz. Tersini, yani bir buton callback'inde dinleyerek okumayı, provider debug modda bir assertion ile yakalıyor. Kural: build'de watch ya da Selector, callback'te read.

Üçüncü tuzak, ViewModel'i build içinde oluşturmak. Her rebuild'de yeni bir ViewModel ve yeni bir istek demek. ViewModel bir kez oluşturulur: ya State'in initState'inde ya da route'un builder'ında (bir sonraki yazı).

Dördüncü tuzak, mutable bir listeyi dışarı vermek. View viewModel.lines.add(...) yazabiliyorsa tek yönlü akış biter; List.unmodifiable ya da UnmodifiableListView ile dışarıya salt okunur bir görünüm ver.

Küçük Bir Deneme

★☆☆ Katalog ekranındaki Selector'ı context.watch<CartViewModel>().itemCount ile değiştir ve DevTools ya da bir sayaçla fiyat değişikliğinde app bar'ın kaç kez build edildiğini ölç.

★★☆ CartViewModel'e clear adında bir Command0 ekle; sepet ekranına "Empty the cart" butonu koy ve command çalışırken butonu devre dışı bırak. Command'ın running değerini bir widget testinde doğrula.

★★★ Dokuzuncu yazıdaki themeMode'u bir SettingsViewModel'e taşı: light/dark/system seçimi Command1<void, ThemeMode> ile değişsin, MaterialApp yalnız themeMode'u Selector ile okusun.

Bir sonraki yazıda ekranlar arası geçişi düzene sokuyoruz: Navigator ile push/pop ve sonuç döndürmek, go_router ile declarative routing, alt menü için StatefulShellRoute, deep link ve giriş yapmamış kullanıcıyı redirect ile giriş ekranına göndermek.