Sealed Classes for State

Medium PriorityAsked in ~55% of mid-level interviews (rising)

3 min read

State Management

Without sealed statesWith sealed states
if (state is Loading) chains, easy to miss a caseswitch over all cases, compiler-enforced
default: branches hide forgotten statesNo default needed — compiler checks
Adding a state silently breaks UIsAdding a state breaks the build
State has booleans like isLoading, isErrorEach state is its own type with the right fields

A sealed state class is the right tool for "this thing is in exactly one of N modes, each carrying different data."


Code in action

sealed class NetworkState<T> {}
class Initial<T>  extends NetworkState<T> {}
class Loading<T>  extends NetworkState<T> {}
class Success<T>  extends NetworkState<T> { final T data;        Success(this.data); }
class Failure<T>  extends NetworkState<T> { final String error;  Failure(this.error); }
 
// Exhaustive — no `default`, compiler proves all cases handled
Widget render(NetworkState<User> s) => switch (s) {
  Initial()                => const Text('Press load'),
  Loading()                => const CircularProgressIndicator(),
  Success(:final data)     => UserCard(data),
  Failure(:final error)    => ErrorBanner(error),
};
 
// Add a new state…
class Refreshing<T> extends NetworkState<T> { final T staleData; Refreshing(this.staleData); }
 
// Every `switch` over NetworkState fails to compile until you handle Refreshing.
// Refactoring becomes safe.

When sealed states pay off most

SituationWhy
Async data with init / loading / success / errorClassic ADT — sealed is perfect
Auth flow (out / loading / in / failed)Different payloads per state
Pagination (initial / loading next / has more / done)Easy to forget edge states without exhaustiveness
Multi-step forms / wizardsOne sealed step-state per phase
State machines in generalEvery transition is a new state subtype

Keep enums for labels without payloads (e.g. enum SortOrder { asc, desc }). Reach for sealed when each variant carries different data.


Common mistakes to avoid

// ❌ Adding `default:` and undoing exhaustiveness
switch (state) {
  case Loading(): return ...;
  default: throw 'unreachable';
}
// ✅ Drop the default — the compiler now tells you when you've missed a case
 
// ❌ Booleans masquerading as states
class UiState { bool isLoading; bool isError; User? data; String? error; }
// Mutually exclusive states modelled as parallel booleans → invalid combinations possible
// ✅ Use sealed states, one per mode
 
// ❌ Sealed without exhaustive switch — defeats the purpose
if (state is Success) ... else if (state is Failure) ... else ...
// ✅ switch (state) { Success() => ..., Failure() => ..., ... }
 
// ❌ Subclasses in different libraries
// lib/auth/state.dart:  sealed class AuthState {}
// lib/auth/extra.dart:  class ExtraState extends AuthState {}  ❌
// Sealed subtypes must live in the same library
 
// ❌ Sealing classes that don't need exhaustive handling
// → adds friction with no benefit. Seal when you actually switch on it.

Interview follow-ups

  1. Why does Dart require sealed subtypes to live in the same library? So the compiler can prove the set of subtypes is closed. If external packages could extend a sealed class, the compiler couldn't enumerate cases — the exhaustiveness guarantee would silently break downstream.

  2. Sealed class vs enhanced enum — when to use which? Enums for finite labels with no payload (Status.idle). Sealed classes when each case carries its own data (Loading() vs Success(user) vs Failure(error)). Dart 3 enhanced enums can hold fields, blurring the line, but the rule of thumb: payload variability → sealed.

  3. How do sealed states interact with == and hashCode? Same as any class — by default they use identity. For state classes you usually want value equality so BlocBuilder and Provider's select can de-dupe correctly. Pair sealed states with Equatable or freezed for automatic value equality.


How helpful was this content?

Please sign in to rate this article.