Sparround

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.

dart
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.

dart
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.

CriterionStatus enumSealed classes
States are mutually exclusiveA weak fit — nullable fields pile upA natural fit
Many shared fields (filter, page, query)Convenient — one `copyWith`Leads to duplication
Showing old data while loadingEasy: `status: loading` with the existing `todos`Extra work: the previous value must be carried into the state
Compiler checksNone — a `default` branch survivesYes — every case is mandatory
Incremental adoption in existing codeEasyRequires 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