Loading, Error and Retry Screens in Jetpack Compose
Stop showing blank screens. Add a loading spinner, a friendly error with a Try again button, an empty state and pull-to-refresh to your Compose app.
In Part 13: Get Data from the Internet in Android with Retrofit we loaded real fruits from the internet. It worked! But there is a problem. While the data is loading, the user sees a blank screen. And if the internet is off, the user sees... a blank screen again. Is the app broken? Is it loading? Nobody knows! In this part you will learn how to show loading and error states in Jetpack Compose: a spinner while waiting, a friendly error message with a "Try again" button, an empty message, and pull-to-refresh. Let's make our app feel professional!
Why Do We Need Loading and Error Screens?
Think about ordering food on Foodpanda. After you order, the app shows "Your food is being prepared". If the restaurant is closed, it tells you clearly. It never shows you an empty white page.
Real networks are not perfect:
- Mobile data in a bus can be slow.
- Wi-Fi can disconnect.
- The server can have a problem.
So our screen can be in one of three states:
- Loading - we are waiting for data.
- Success - we got the fruits!
- Error - something went wrong.
The screen is always in exactly one of these. Never two at the same time.
Step 1: Model the States with a Sealed Interface
Kotlin has a perfect tool for "one of these few things": a sealed interface. Create a new Kotlin file called FruitsUiState:
package com.example.myfirstapp
sealed interface FruitsUiState {
data object Loading : FruitsUiState
data class Success(val fruits: List<FruitDto>) : FruitsUiState
data class Error(val message: String) : FruitsUiState
}
Change
com.example.myfirstappto your own package name.
Let's understand it:
sealedmeans "these are the only options". Nobody can add a fourth one somewhere else. Kotlin knows the full list.Loadingis adata objectbecause it has no extra data. We are just waiting.Successcarries the list of fruits.Errorcarries a message we can show to the user.- Think of it like a traffic light. It can be red, yellow or green. Never purple!
Step 2: Update the ViewModel
Now the ViewModel will hold a FruitsUiState instead of a plain list. Replace your whole FruitViewModel file with this:
package com.example.myfirstapp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
class FruitViewModel : ViewModel() {
private val _uiState = MutableStateFlow<FruitsUiState>(FruitsUiState.Loading)
val uiState: StateFlow<FruitsUiState> = _uiState.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
init {
loadFruits()
}
// Shows the big loading spinner, then the result
fun loadFruits() {
viewModelScope.launch {
_uiState.value = FruitsUiState.Loading
_uiState.value = fetchFruits()
}
}
// Used by pull-to-refresh: keeps the list visible while loading
fun refresh() {
viewModelScope.launch {
_isRefreshing.value = true
_uiState.value = fetchFruits()
_isRefreshing.value = false
}
}
private suspend fun fetchFruits(): FruitsUiState {
return try {
delay(1500) // Only for learning! Remove this later.
val fruits = RetrofitInstance.api.getFruits()
FruitsUiState.Success(fruits)
} catch (e: IOException) {
FruitsUiState.Error("No internet connection. Please check your network.")
} catch (e: HttpException) {
FruitsUiState.Error("Server problem (code ${e.code()}). Please try again later.")
} catch (e: CancellationException) {
throw e // let the coroutine stop normally
} catch (e: Exception) {
FruitsUiState.Error("Something went wrong. Please try again.")
}
}
}
Let's understand it:
- We start with
Loading, because the first thing we do is load. loadFruits()setsLoadingfirst, then sets the result. The user sees the spinner, then the fruits (or the error).refresh()is for pull-to-refresh. It does not setLoading, because we don't want the whole list to disappear. Instead,_isRefreshingshows a small spinner at the top.fetchFruits()does the real work and returns a state. Both functions share it, so we don't write the same code twice.delay(1500)waits 1.5 seconds. The Fruityvice API is fast, so without it you would barely see the spinner. It's only for learning. Remove it before you share your app!
The Kinds of Errors
IOException- a network problem. No internet, Wi-Fi dropped, server can't be reached. Like the waiter can't find the kitchen.HttpException- the server answered, but with an error code, like 404 (not found) or 500 (server broken). Like the kitchen says "Sorry, no biryani today".e.code()gives us that number.Exception- anything else, for example the server sent data in a strange shape. We show a general message instead of crashing.
Tip:
CancellationExceptionis special. Coroutines use it to stop work when the user leaves the screen. That's why we catch it beforeExceptionand throw it again withthrow e, so the coroutine can stop normally.
Step 3: Build the Loading and Error Screens
Now let's make two small composables. Create a new file called StateScreens:
package com.example.myfirstapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Composable
fun ErrorScreen(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "😕", style = MaterialTheme.typography.displayMedium)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("Try again")
}
}
}
Let's understand it:
CircularProgressIndicator()is the spinning circle from Material 3. It spins until we remove it.BoxwithAlignment.Centerputs the spinner in the middle of the screen.ErrorScreentakes themessageand anonRetryfunction. The screen does not know how to retry. It just callsonRetrywhen the button is clicked. The ViewModel does the real work.
Step 4: Show the Right Screen with when
Now update FruitListScreen. It chooses which screen to show based on the state. It also adds pull-to-refresh and an empty state. Replace the FruitListScreen function from Part 13 with this (keep your FruitApiRow function and its imports, like Card and Column, as they are):
package com.example.myfirstapp
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun FruitListScreen(
modifier: Modifier = Modifier,
viewModel: FruitViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
when (val current = state) {
is FruitsUiState.Loading -> LoadingScreen(modifier)
is FruitsUiState.Error -> ErrorScreen(
message = current.message,
onRetry = { viewModel.loadFruits() },
modifier = modifier
)
is FruitsUiState.Success -> PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = modifier.fillMaxSize()
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
if (current.fruits.isEmpty()) {
item {
Text(
text = "No fruits found. Pull down to refresh.",
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(32.dp)
)
}
} else {
items(current.fruits) { fruit ->
FruitApiRow(fruit = fruit)
}
}
}
}
}
}
Let's understand it:
when (val current = state)checks which state we have. Because the interface issealed, Kotlin knows all three options. If you forget one, Android Studio shows an error. Very helpful!- Why
val current = state? Becausestatecomes fromby, Kotlin can't be sure it stays the same. Saving it incurrentlets us safely usecurrent.messageandcurrent.fruits. - Loading → the spinner.
- Error → the message and the "Try again" button. The button calls
viewModel.loadFruits(). - Success → the list, wrapped in
PullToRefreshBox. - Empty state: if the server sends an empty list, we show a friendly message instead of a blank screen. We put it inside the
LazyColumn, so the user can still pull down to refresh.
What Is PullToRefreshBox?
You know this from Facebook or Gmail. You pull the list down with your finger, a small circle appears, and the list reloads. That's pull-to-refresh.
PullToRefreshBox comes from Material 3. It needs two things:
isRefreshing-trueshows the small spinner,falsehides it.onRefresh- what to do when the user pulls down. We callviewModel.refresh().
Put a scrollable list (like LazyColumn) inside it, and it just works.
Tip:
PullToRefreshBoxbecame a stable API in Material 3 version 1.4.0. If your project uses an older Compose BOM, Android Studio will show an error saying it is experimental. You can update your Compose BOM, or add@OptIn(ExperimentalMaterial3Api::class)aboveFruitListScreen.
Tip: Press Alt+Enter on red words to add imports. The tricky ones are:
androidx.compose.material3.pulltorefresh.PullToRefreshBox(note thepulltorefreshpart),androidx.compose.foundation.lazy.items,androidx.compose.runtime.getValue,retrofit2.HttpExceptionandjava.io.IOException.
Run the app. You will see the spinner for 1.5 seconds, then the fruits. Pull the list down, and a small circle appears at the top. 🎉
Step 5: Test the Error Screen with Airplane Mode
How do we test the error screen? We turn off the internet!
- Run the app on the emulator.
- Swipe down from the top of the emulator screen to open Quick Settings.
- Tap Airplane mode to turn it on.
- Close the app fully and open it again (or pull to refresh).
- You should see 😕 and "No internet connection. Please check your network."
- Now turn Airplane mode off.
- Tap Try again. The spinner shows, then your fruits come back! 🎉
Tip: You can do the same on a real phone. Testing with no internet is something real Android developers do every day.
Common Errors
1. 'when' expression must be exhaustive
You forgot one of the three states in when. Add the missing branch.
2. Smart cast to 'FruitsUiState.Error' is impossible
You wrote when (state) and then used state.message. Use when (val current = state) and current.message instead.
3. Unresolved reference: PullToRefreshBox
The import is wrong or your Material 3 version is too old. Use androidx.compose.material3.pulltorefresh.PullToRefreshBox and check the Tip above.
4. The app still crashes with no internet
Check that your try / catch wraps the getFruits() call, and that you imported java.io.IOException (not another IOException).
5. The spinner never stops
Make sure fetchFruits() always returns a state, and that refresh() sets _isRefreshing.value = false at the end.
Try It Yourself
- Change the error message to Bangla, for example: "ইন্টারনেট সংযোগ নেই".
- Add a small
Text("Loading fruits...")under the spinner inLoadingScreen. - Make an
EmptyScreencomposable with an emoji and a "Reload" button, and use it instead of the plain text.
Frequently Asked Questions
Why not just use three Boolean variables like isLoading and isError?
With many Booleans, you could have isLoading = true and isError = true at the same time. That makes no sense! A sealed interface makes sure the screen is in exactly one state.
Should I keep delay(1500) in my real app?
No. It only makes the app slower. It's just there so you can see the spinner while learning. Delete it when you're done.
Why does pull-to-refresh not work on the error screen? The error screen is not a scrollable list, so there's nothing to pull. That's why it has a "Try again" button instead.
Quick Recap
- Real networks are slow and can fail, so always plan for it.
- A sealed interface gives us three clear states:
Loading,SuccessandError. - The ViewModel sets
Loadingfirst, thenSuccessorError. - Catch
IOException(network problem) andHttpException(server error code). when (val current = state)shows the right screen.CircularProgressIndicatorshows a spinner, and a "Try again" button callsloadFruits().PullToRefreshBoxadds pull-to-refresh to a list.- Test errors with Airplane mode.
Our app now downloads fruits every time it opens. But what if the user wants to save their favourite fruits, even with no internet? For that we need a database on the phone.