A catalog screen modeled as data class(val isLoading: Boolean, val books: List<Book>?, val error: String?) allows impossible combinations, such as loading and failed. A sealed type allows exactly three states, each carrying only the data valid in it. BookNest's catalog uses this generic LoadState.
sealed interface LoadState<out T> {
data object Loading : LoadState<Nothing>
data class Success<T>(val data: T) : LoadState<T>
data class Error(val message: String) : LoadState<Nothing>
}
fun render(state: LoadState<List<String>>): String = when (state) {
LoadState.Loading -> "[spinner]"
is LoadState.Success if state.data.isEmpty() -> "No books yet"
is LoadState.Success -> state.data.joinToString()
is LoadState.Error -> "${state.message} [Retry]"
}
fun main() {
listOf(LoadState.Loading, LoadState.Error("No connection"), LoadState.Success(emptyList()),
LoadState.Success(listOf("The Quiet Harbor", "Salt and Saffron")))
.forEach { println(render(it)) }
}Output
[spinner] No connection [Retry] No books yet The Quiet Harbor, Salt and Saffron
Loading and Error hold no book data, so they are LoadState<Nothing>; the out modifier (Variance) makes that a subtype of every LoadState<T>, so one Loading object serves every screen. In ViewModel and UDF a ViewModel exposes StateFlow<LoadState<List<Book>>>, and the composable runs this same when to draw a progress indicator, the book list or an error with Retry.