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.
// ══ 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? | No | Yes (one class) | Yes (one class plus N variants) |
| Impossible states | Structurally forbidden | Possible — nullable fields | Structurally forbidden |
| Old data while refreshing | Yes (`AsyncValue` retains the value) | Yes — naturally | Needs an extra variant (`refreshing(data)`) |
| Several extra fields (filter, page) | Awkward — `AsyncValue<(List, Filter)>` reads badly | Comfortable | Repeats in every variant |
| Best fit | Read screens (lists, details) | Lists with filters, pagination, search | Forms, multi-step flows, wizards |
What an impossible state is. The most common example:
bool isLoadingplusFailure? failureplusList<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 aStatefulWidget. - Display formatting:
String formattedTotal,Color statusColor. The state holdsdouble totaland anOrderStatus; format and colour are computed inbuild. - `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`.