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.

Where this fits: This assumes Retrofit itself — the @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.

Note: This is correct, real Compose/Kotlin code — but this website's own project doesn't run a live Android Studio build, so no request actually fires from this page.
Next up: This pattern works well for a feed of dozens of recipes loaded in one shot — but RecipeBox's full community feed can have thousands of entries. Paging 3 with Compose covers loading that kind of list a page at a time.