Sparround

UI state-in modelləşdirilməsi: AsyncValue, status enum, sealed union

Ekranın state-i domain modeli deyil. Domain Order obyektini verir; ekran isə əlavə olaraq bilməlidir: yüklənir, xəta var, filtr seçilib, submit gedir, siyahı boşdur.

Bu state-i modelləşdirməyin üç yolu var və hər birinin öz yeri var.

1. `AsyncValue<T>` (Riverpod-un hazır tipi). AsyncLoading, AsyncError, AsyncData — üç vəziyyət bir tipdə. Riverpod 3-də sealed olduğu üçün switch kompilyator tərəfindən yoxlanılır. Sadə "yüklə və göstər" ekranları üçün ən qısa yoldur: əlavə sinif yazmaq lazım gəlmir.

2. Status `enum` + sahələr. Bir sinif, bir enum status, sahələr nullable. Bloc sənədləri bu yanaşmayı vəziyyətlər bir-birini tam istisna etmədikdə və çoxlu paylaşılan sahə olduqda tövsiyə edir — məsələn siyahı göstərilir və eyni anda yenilənir.

3. `sealed` union (freezed ilə). Hər vəziyyət ayrı tipdir: Editing, Submitting, Submitted, Failed. Vəziyyətlər bir-birini istisna etdikdə uyğundur və mümkün olmayan state-ləri struktur olaraq qadağan edir.

Seçim meyarı bir sualdır: "bu iki vəziyyət eyni anda ola bilərmi?" Ola bilmirsə — sealed union; ola bilirsə — status enum ya da AsyncValue.

dart
// ══ 1. AsyncValue: sadə "yüklə və göstər" ══
@riverpod
class OrderList extends _$OrderList {
  @override
  Future<List<Order>> build() =>
      ref.watch(orderRepositoryProvider).fetchMine();
}

// View: AsyncValue sealed-dir, switch tam yoxlanılır.
final state = ref.watch(orderListProvider);
return switch (state) {
  AsyncData(:final value) when value.isEmpty => const EmptyOrders(),
  AsyncData(:final value) => OrderListView(orders: value),
  AsyncError(:final error) => ErrorView(error: error),
  _ => const LoadingView(),
};


// ══ 2. Status enum: vəziyyətlər üst-üstə düşür ══
// (siyahı göstərilir VƏ eyni anda yenilənir VƏ filtr seçilib)
enum OrderListStatus { initial, loading, refreshing, success, failure }

@freezed
abstract class OrderListState with _$OrderListState {
  const factory OrderListState({
    @Default(OrderListStatus.initial) OrderListStatus status,
    @Default([]) List<Order> orders,       // yenilənərkən köhnə siyahı qalır
    @Default(OrderFilter.all) OrderFilter filter,
    Failure? failure,
    @Default(false) bool hasMore,          // səhifələmə
  }) = _OrderListState;
}


// ══ 3. Sealed union: vəziyyətlər bir-birini istisna edir ══
// (form: ya redaktə olunur, ya göndərilir, ya bitib)
@freezed
sealed class CheckoutState with _$CheckoutState {
  const factory CheckoutState.editing({
    required CheckoutForm form,
    @Default({}) Map<String, String> fieldErrors,
  }) = CheckoutEditing;

  const factory CheckoutState.submitting({
    required CheckoutForm form,
  }) = CheckoutSubmitting;

  const factory CheckoutState.submitted({
    required Order order,
  }) = CheckoutSubmitted;

  const factory CheckoutState.failed({
    required CheckoutForm form,
    required Failure failure,
  }) = CheckoutFailed;
}

// View: hər vəziyyət ayrı widget, mümkün olmayan hal YOXDUR.
return switch (ref.watch(checkoutNotifierProvider)) {
  CheckoutEditing(:final form, :final fieldErrors) =>
    CheckoutForm(form: form, errors: fieldErrors),
  CheckoutSubmitting() => const CheckoutFormDisabled(),
  CheckoutSubmitted(:final order) => OrderSuccess(order: order),
  CheckoutFailed(:final form, :final failure) =>
    CheckoutForm(form: form, banner: failure),
};

Eyni ekran üç modelləşdirmə ilə. Diqqət et: birinci variantda öz state sinifin yoxdur — bu, əksər oxuma ekranları üçün kifayətdir.

Meyar`AsyncValue<T>`Status enum + sahələr`sealed` union
Öz state sinfin lazımdır?XeyrBəli (1 sinif)Bəli (1 sinif + N variant)
Mümkün olmayan stateStruktur olaraq qadağandırMümkündür — nullable sahələrStruktur olaraq qadağandır
Yenilənərkən köhnə məlumatVar (`AsyncValue` dəyəri saxlayır)Var — təbii şəkildəƏlavə variant lazımdır (`refreshing(data)`)
Bir neçə əlavə sahə (filtr, səhifə)Çətin — `AsyncValue<(List, Filter)>` oxunmurRahatHər variantda təkrarlanır
Ən uyğun yerOxuma ekranları (siyahı, detal)Filtr/səhifələmə/axtarış olan siyahılarFormalar, çox addımlı axınlar, wizard

Mümkün olmayan state nədir. Ən çox rast gəlinən nümunə budur:

  • bool isLoading + Failure? failure + List<Order> orders

Bu üç sahə 8 kombinasiya yaradır, halbuki onların yalnız 4-ü mənalıdır. isLoading == true && failure != null nə deməkdir? Cavab yoxdur — lakin kod bu vəziyyəti yaratmağa icazə verir və nə vaxtsa yaradır. Nəticə: ekranda spinner və xəta mesajı üst-üstə düşür, ya da heç nə görünmür.

AsyncValuesealed union bu problemi tipin özündə həll edir: belə kombinasiya yaratmaq mümkün deyil.

Vacib istisna. Bəzi "qeyri-mümkün" görünən kombinasiyalar əslində lazımdır: məlumat var və eyni anda yenilənir (pull-to-refresh). AsyncValue bunu dəstəkləyir — yenilənmə zamanı əvvəlki dəyər saxlanılır, ona görə ekran boşalmır. Sealed union-da isə bunun üçün ayrıca variant (refreshing(data)) yazmaq lazımdır.

Ona görə "hər şeyi sealed union et" qaydası səhvdir: siyahı ekranları üçün status enum ya da AsyncValue daha az kod və daha az variant deməkdir.

State sinfi nə saxlamır. Üç şey ekranın state-inə düşməməlidir:

  • Widget-ə aid obyektlər: TextEditingController, ScrollController, AnimationController, GlobalKey. Onların yeri StatefulWidget-dir.
  • Göstərmə formatı: String formattedTotal, Color statusColor. State double totalOrderStatus saxlayır; format və rəng build-də hesablanır.
  • `BuildContext`. Heç bir formada.

Praktika. Layihəndən bir ekran seç və onun state-ini yaz: əvvəlcə bütün mümkün vəziyyətləri kağızda sadala (loading, boş, məlumat, yenilənir, xəta, submit gedir...), sonra "bunlar eyni anda ola bilərmi?" sualına görə modelləşdirmə üsulunu seç.

Hazır sayılır: switch bütün halları əhatə edir, default yoxdur, və state sinfində nə Controller, nə Color, nə BuildContext var.

📚 Mənbələr və sənədlər

  • Case study: UI qatırəsmidocs.flutter.dev

    View model-in ekran üçün state saxlaması və view-un ona necə abunə olması.

  • Riverpod: AsyncValue (API sənədi)rəsmipub.dev

    `AsyncData`, `AsyncError`, `AsyncLoading` alt tipləri və `value`, `hasError`, `isRefreshing` üzvləri — sealed tipin rəsmi API-si.

  • freezed: union tiplərirəsmipub.dev

    `@freezed sealed class ... with _$X` sintaksisi və Dart `switch`-i ilə pattern matching.

  • Dart: pattern-lərrəsmidart.dev

    `AsyncData(:final value) when value.isEmpty` kimi guard-lı pattern-lər.