Sparround

Modelling UI state: AsyncValue, a status enum, a sealed union

A screen's state is not a domain model. The domain hands you an Order; the screen additionally needs to know: it is loading, there is an error, a filter is selected, a submit is in flight, the list is empty.

There are three ways to model that state, and each has its place.

1. `AsyncValue<T>` (Riverpod's built-in type). AsyncLoading, AsyncError, AsyncData — three conditions in one type. Since it is sealed in Riverpod 3, a switch over it is compiler-checked. For simple "load and display" screens it is the shortest route: no extra class to write.

2. A status `enum` plus fields. One class, one enum status, nullable fields. The bloc docs recommend this approach when the states are not strictly exclusive and share many properties — a list being displayed while it refreshes, for instance.

3. A `sealed` union (with freezed). Each condition is its own type: Editing, Submitting, Submitted, Failed. It suits mutually exclusive states and structurally forbids impossible ones.

The criterion is one question: "can these two conditions hold at the same time?" If not — a sealed union; if yes — a status enum or 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),
};

The same screen with all three models. Note that the first variant has no state class of your own — enough for most read-only screens.

Criterion`AsyncValue<T>`Status enum plus fields`sealed` union
Need your own state class?NoYes (one class)Yes (one class plus N variants)
Impossible statesStructurally forbiddenPossible — nullable fieldsStructurally forbidden
Old data while refreshingYes (`AsyncValue` retains the value)Yes — naturallyNeeds an extra variant (`refreshing(data)`)
Several extra fields (filter, page)Awkward — `AsyncValue<(List, Filter)>` reads badlyComfortableRepeats in every variant
Best fitRead screens (lists, details)Lists with filters, pagination, searchForms, multi-step flows, wizards

What an impossible state is. The most common example:

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

Those three fields produce eight combinations, of which only four are meaningful. What does isLoading == true && failure != null mean? There is no answer — yet the code allows that state, and eventually produces it. The result: a spinner and an error message overlapping on screen, or nothing rendering at all.

AsyncValue and a sealed union solve this in the type itself: such a combination cannot be constructed.

An important exception. Some seemingly "impossible" combinations are actually required: data present while refreshing (pull-to-refresh). AsyncValue supports it — the previous value is retained during a refresh, so the screen does not blank out. In a sealed union you need a dedicated variant (refreshing(data)) for the same thing.

So "make everything a sealed union" is the wrong rule: for list screens a status enum or AsyncValue means less code and fewer variants.

What a state class does not hold. Three things should never land in a screen's state:

  • Widget-owned objects: TextEditingController, ScrollController, AnimationController, GlobalKey. Their home is a StatefulWidget.
  • Display formatting: String formattedTotal, Color statusColor. The state holds double total and an OrderStatus; format and colour are computed in build.
  • `BuildContext`. In any form.

Practice. Pick a screen in your project and write its state: first list every possible condition on paper (loading, empty, data, refreshing, error, submitting…), then choose the modelling approach by asking "can these hold at the same time?"

Done means: the switch covers every case with no default, and the state class contains no Controller, no Color and no BuildContext.

📚 Sources and documentation

  • Case study: the UI layerofficialdocs.flutter.dev

    How a view model holds state for a screen and how the view subscribes to it.

  • Riverpod: AsyncValue (API reference)officialpub.dev

    The `AsyncData`, `AsyncError` and `AsyncLoading` subtypes plus `value`, `hasError` and `isRefreshing` — the sealed type's official API.

  • freezed: union typesofficialpub.dev

    The `@freezed sealed class … with _$X` syntax and pattern matching with Dart's `switch`.

  • Dart: patternsofficialdart.dev

    Guarded patterns such as `AsyncData(:final value) when value.isEmpty`.