Modelling state: status enum vs sealed classes
The official bloc docs devote a section to modelling state and present two approaches; both are recommendations, not rules.
1. A single concrete class plus a status enum. One class for all states; an enum carries the current status; fields are nullable and interpreted according to the status. The docs note this works best when the states are not strictly exclusive and share many properties.
2. A sealed class plus subclasses. Each state is its own type. It suits mutually exclusive states, and with Dart 3 pattern matching the compiler verifies every case is handled.
For both, the docs also list practical additions: extending Equatable from package:equatable, the @immutable annotation, a copyWith method and const constructors where possible.
enum TodoStatus { initial, loading, success, failure }
final class TodoState extends Equatable {
const TodoState({
this.status = TodoStatus.initial,
this.todos = const [],
this.exception,
});
final TodoStatus status;
final List<Todo> todos;
final Exception? exception;
TodoState copyWith({
TodoStatus? status,
List<Todo>? todos,
Exception? exception,
}) {
return TodoState(
status: status ?? this.status,
todos: todos ?? this.todos,
exception: exception ?? this.exception,
);
}
// Yeni sahə əlavə edildikdə props-a da əlavə etmək YADDAN ÇIXMAMALIDIR:
// əks halda == iki fərqli state-i bərabər sayır və UI yenilənmir.
@override
List<Object?> get props => [status, todos, exception];
}Approach 1: a status enum — fields are shared.
sealed class TodoState {
const TodoState();
}
final class TodoInitial extends TodoState {
const TodoInitial();
}
final class TodoLoadInProgress extends TodoState {
const TodoLoadInProgress();
}
final class TodoLoadSuccess extends TodoState {
const TodoLoadSuccess(this.todos);
final List<Todo> todos;
}
final class TodoLoadFailure extends TodoState {
const TodoLoadFailure(this.message);
final String message;
}
// UI: kompilyator bütün halların əhatə olunduğunu yoxlayır.
// Yeni state sinfi əlavə edildikdə bu switch kompilyasiya olunmur —
// səhv runtime yerinə compile-time-da görünür.
Widget build(BuildContext context) {
return BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) => switch (state) {
TodoInitial() => const SizedBox.shrink(),
TodoLoadInProgress() => const TodoSkeleton(),
TodoLoadSuccess(:final todos) => TodoListView(todos: todos),
TodoLoadFailure(:final message) => ErrorView(message: message),
},
);
}Approach 2: sealed classes — the states are mutually exclusive.
| Criterion | Status enum | Sealed classes |
|---|---|---|
| States are mutually exclusive | A weak fit — nullable fields pile up | A natural fit |
| Many shared fields (filter, page, query) | Convenient — one `copyWith` | Leads to duplication |
| Showing old data while loading | Easy: `status: loading` with the existing `todos` | Extra work: the previous value must be carried into the state |
| Compiler checks | None — a `default` branch survives | Yes — every case is mandatory |
| Incremental adoption in existing code | Easy | Requires refactoring |
The most common Equatable bug: a new field is added to the state class but never added to props. Two different states then compare equal, the bloc drops the change under the duplicate-state rule, and the UI never updates. To prevent it, either use code generation such as freezed (where props are not hand-written) or cover the state classes with a test.
📚 Sources and documentation
- Modeling stateofficialbloclibrary.dev
The official explanation of both approaches with their pros and cons.
- package:equatableofficialpub.dev
The props mechanism and how == comparison is built.
- package:freezedofficialpub.dev
Generating copyWith, == and sealed unions with code generation.
- Naming conventions (states)officialbloclibrary.dev