Retrofit & Data Loading Patterns
The previous lesson's Room Flow updates instantly because the data is already sitting
on the device. A network call is a different animal entirely: it can take a second or two to
respond, and it can fail — no signal, a timeout, a server error. RecipeBox's "Community Recipes"
feed, fetched from a backend over Retrofit, needs a screen that correctly shows a loading spinner
while the request is in flight, the recipes once they arrive, or a retryable error message if
something goes wrong. This lesson builds that pattern properly.
@GET interface, suspend fun, and building a Retrofit instance — from Retrofit + Coroutines. This lesson is entirely about what a composable does with that data while it's loading and if it fails, which a plain List<Recipe> return type can't represent on its own.Why a Plain List Isn't Enough
If a ViewModel only ever exposes recipes: List<Recipe>, there's no way to tell
apart three completely different situations: the request hasn't finished yet, the request finished
and returned zero recipes, and the request failed outright. All three would show up as the same
empty list, and the UI would have no way to show the right thing for each. What's needed is a type
that can represent all three states explicitly, so the compiler forces every possibility to be
handled.
Modeling It: A Sealed Class UiState
A sealed class (or sealed interface) with one subtype per possible state
is the standard Kotlin tool for exactly this — Kotlin's when can then check that every
branch is handled, with no else needed and no state silently forgotten:
RecipeFeedUiState.kt
sealed class RecipeFeedUiState {
object Loading : RecipeFeedUiState()
data class Success(val recipes: List<Recipe>) : RecipeFeedUiState()
data class Error(val message: String) : RecipeFeedUiState()
}
Loading carries no data, so it's a plain object — there's only ever one
of it. Success and Error are data classes because each needs
to carry something specific along with it: the actual recipes, or a message describing what went
wrong.
Producing It: RecipeFeedViewModel
The ViewModel's job is to run the Retrofit call and translate the outcome — success or exception —
into the right RecipeFeedUiState:
RecipeFeedViewModel.kt
class RecipeFeedViewModel(
private val api: RecipeApiService
) : ViewModel() {
var uiState: RecipeFeedUiState by mutableStateOf(RecipeFeedUiState.Loading)
private set
init {
loadFeed()
}
fun loadFeed() {
viewModelScope.launch {
uiState = RecipeFeedUiState.Loading
uiState = try {
val recipes = api.getCommunityRecipes()
RecipeFeedUiState.Success(recipes)
} catch (e: Exception) {
RecipeFeedUiState.Error(e.message ?: "Something went wrong")
}
}
}
}
Notice loadFeed() resets uiState back to Loading at the
start every time it runs — this matters because the same function is reused for the initial load
and for the retry button below, so tapping retry correctly shows the spinner again instead
of leaving the old error message on screen while the new request is in flight.
Rendering It: A when Block Over uiState
The composable's entire job becomes matching over uiState and rendering exactly one of
three things — nothing more:
RecipeFeedScreen.kt
@Composable
fun RecipeFeedScreen(
viewModel: RecipeFeedViewModel = viewModel()
) {
when (val state = viewModel.uiState) {
is RecipeFeedUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is RecipeFeedUiState.Success -> {
LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(items = state.recipes, key = { it.id }) { recipe ->
RecipeRow(recipe = recipe)
}
}
}
is RecipeFeedUiState.Error -> {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = state.message, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = { viewModel.loadFeed() }) {
Text("Retry")
}
}
}
}
}
when (val state = viewModel.uiState) both branches on the type and smart-casts
state to that specific subtype inside each branch — that's why state.recipes
and state.message are accessible directly, with no manual casting. Because
RecipeFeedUiState is sealed, the Kotlin compiler also checks that every
subtype is handled; add a fourth state later (like an "empty feed" case) and every unhandled
when in the codebase fails to compile until it's addressed — a real safety net for a
pattern that's easy to leave incomplete otherwise.