Using Kotlin Sealed Classes to Build Safe Finite State Machines
When UI logic grows, managing state with flags can lead to illegal combos and bugs. Sealed classes give compile‑time exhaustiveness, making state machines safer and easier to extend. This guide shows how to model UI states, why it works, and the trade‑offs.
06 Apr 2026, 18:07 UTC

Problem: State Explosion in UI Code
Modern UIs often juggle dozens of booleans: isLoading, hasError, isAuthenticated, etc. A single flag mis‑set can produce an impossible state (e.g., loading with an error), and the compiler can’t catch it. Unit tests may miss rare combinations, leaving bugs in production.
Thesis: Sealed Classes Turn State Into a First‑Class Type
Represent each UI state as a distinct subclass of a sealed class. The compiler then forces you to handle every possible state in a when expression, catching omissions at compile time. The result is a finite, well‑defined state machine that is both safe and maintainable.
Why Exhaustiveness Helps
When you write:
when (state) {
is UiState.Loading -> showSpinner()
is UiState.Success -> renderList(state.data)
is UiState.Error -> showError(state.throwable)
}
the compiler verifies that every subclass of UiState is covered. If you add a new state later, the compiler flags the missing branch, forcing you to update the UI logic.
Concrete Example: A Simple Repository UI
We’ll model a repository list screen with three states: loading, success, and error.
sealed class RepoUiState {
object Loading : RepoUiState()
data class Success(val repos: List<Repo>) : RepoUiState()
data class Error(val cause: Throwable) : RepoUiState()
}
Handling the state in an Activity or ViewModel:
fun render(state: RepoUiState) {
when (state) {
is RepoUiState.Loading -> ui.showSpinner()
is RepoUiState.Success -> ui.displayRepos(state.repos)
is RepoUiState.Error -> ui.showError(state.cause)
}
}
Compile‑time safety: If you later add object Empty : RepoUiState(), the compiler will emit an error in the when block, reminding you to handle it.
Trade‑Offs and Limitations
- Same‑file requirement: All subclasses must live in the same file as the sealed class. Splitting state across modules can be inconvenient.
- Boilerplate: Each state needs its own class, which can clutter small projects.
- Else branch silences checks: Adding
elseto thewhendisables exhaustiveness, potentially hiding bugs. - Large hierarchies: With many states, the
whenblock grows verbose. Consider extracting state handlers into extension functions or a dispatcher pattern.
Practical Verification Steps
- Create a Kotlin file
RepoUiState.ktwith the sealed class above. - Add a
renderfunction in a separate file. - Compile using
kotlincor Android Studio. - Introduce a new subclass, e.g.,
object Empty : RepoUiState(), and observe the compile error in thewhenblock. - Run a simple
mainfunction that creates each state and callsrenderto confirm correct branch execution.
Actionable Takeaways
- Adopt sealed classes for UI state when you have more than two or three mutually exclusive states.
- Keep the hierarchy in one file to preserve exhaustiveness checks, or use a wrapper sealed class if you need cross‑module visibility.
- Avoid generic
elsebranches unless you intentionally want to ignore new states. - Use extension functions to keep large
whenblocks readable.
By treating UI states as a sealed type, you shift error detection from runtime to compile time, making your app more robust and your code easier to reason about.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.