Master API Error Handling in Jetpack Compose — With Real Examples & Q&A
Handling API errors might sound boring — but it’s where user trust is either won or lost.
Whether it’s a 404, a network drop, or an unknown crash, how your app responds matters. In Jetpack Compose, we often nail the UI but forget to handle these issues gracefully. That’s why today we’re building a robust, reusable error-handling system that:
-
Shows friendly messages instead of raw errors
-
Keeps your ViewModels clean
-
Is scalable for real-world apps
Let’s get into it.
Why Is This Important?
Imagine this:
"java.net.UnknownHostException: Unable to resolve host..."
Now compare:
"No internet connection. Please check your network."
With clean error handling, you get:
-
Happy users who understand what's wrong
-
Clean architecture without messy
try-catch
everywhere -
Better maintainability when things grow
Step 1: Create a Flexible Result Wrapper
This gives us a consistent way to handle API state (loading, success, error).
sealed class Resource<out T> {
data class Success<out T>(val data: T) : Resource<T>()
data class Error(val error: AppError) : Resource<Nothing>()
object Loading : Resource<Nothing>()
}
Step 2: Define a Reusable AppError
Model
This allows for all kinds of error types: network, HTTP, unknown, and custom.
sealed class AppError {
data class Network(val message: String) : AppError()
data class Http(val code: Int, val message: String) : AppError()
data class Unknown(val message: String) : AppError()
data class Custom(val userMessage: String) : AppError()
}
Step 3: Create the ErrorHandler
Utility
This is your single point of truth for all error handling logic.
object ErrorHandler {
fun handleException(e: Throwable): AppError {
return when (e) {
is java.net.UnknownHostException -> AppError.Network("No internet connection.")
is java.net.SocketTimeoutException -> AppError.Network("The request timed out.")
is retrofit2.HttpException -> {
val code = e.code()
val message = parseHttpErrorMessage(e)
AppError.Http(code, message)
}
else -> AppError.Unknown(e.localizedMessage ?: "Unexpected error occurred.")
}
}
private fun parseHttpErrorMessage(e: retrofit2.HttpException): String {
return try {
val errorBody = e.response()?.errorBody()?.string()
val json = JSONObject(errorBody ?: "")
json.optString("message", "Server error")
} catch (ex: Exception) {
"Server error"
}
}
fun getUserFriendlyMessage(error: AppError): String {
return when (error) {
is AppError.Network -> error.message
is AppError.Http -> when (error.code) {
401 -> "Unauthorized access. Please log in again."
403 -> "Permission denied."
404 -> "Requested resource not found."
500 -> "Internal server error. Please try again later."
else -> error.message
}
is AppError.Unknown -> error.message
is AppError.Custom -> error.userMessage
}
}
}
Step 4: ViewModel Integration (Clean & Simple)
class MyViewModel(private val repository: MyRepository) : ViewModel() {
var result by mutableStateOf<Resource<List<MyData>>>(Resource.Loading)
private set
fun fetchData() {
viewModelScope.launch {
result = Resource.Loading
try {
val response = repository.getData()
if (response.isSuccessful) {
result = Resource.Success(response.body() ?: emptyList())
} else {
val error = ErrorHandler.handleException(retrofit2.HttpException(response))
result = Resource.Error(error)
}
} catch (e: Exception) {
val error = ErrorHandler.handleException(e)
result = Resource.Error(error)
}
}
}
}
Step 5: Display in Compose UI
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
when (val state = viewModel.result) {
is Resource.Loading -> CircularProgressIndicator()
is Resource.Success -> LazyColumn {
items(state.data) { item -> Text(item.name) }
}
is Resource.Error -> Column {
Text(ErrorHandler.getUserFriendlyMessage(state.error))
Button(onClick = { viewModel.fetchData() }) {
Text("Retry")
}
}
}
}
Benefits Recap
1) Clean Code
No repeated try-catch
or string resources all over.
2) Reusable
Drop-in utility that works across any feature/module.
3) Extendable
Add logging, analytics, or localization without touching your ViewModel.
4) Great UX
Users understand what went wrong and what to do next.
Bonus: 10+ Q&A for All Experience Levels
Beginner
-
Q: Why not just show
e.localizedMessage
directly?
A: It’s often too technical or meaningless for users. -
Q: What’s the point of
sealed class Resource<T>
?
A: It helps model API states: loading, success, error — in a type-safe way. -
Q: What does
mutableStateOf
do in ViewModel?
A: It makes your state observable in Composables.
Intermediate
-
Q: How do I localize these messages?
A: InjectContext
or usestringResource(R.string.error_xxx)
inErrorHandler
. -
Q: Can I log these errors?
A: Absolutely. Add Firebase Crashlytics, Sentry, or Timber logging insideErrorHandler
. -
Q: How do I test this?
A: Inject fake data and simulate errors usingResource.Error(AppError.Network(...))
.
Advanced
-
Q: Can I extract error codes from a custom JSON response?
A: Yes. UseJSONObject
or Gson/Moshi to parse error bodies inparseHttpErrorMessage()
. -
Q: How can I handle GraphQL or WebSocket errors?
A: Create new subclasses inAppError
likeGraphQLError
,SocketError
, etc. -
Q: Will this work in a multiplatform project (KMM)?
A: With tweaks, yes. KeepAppError
in shared code, and create platform-specific handlers. -
Q: How do I decouple
ErrorHandler
from Compose and Retrofit?
A: MakeErrorHandler
take interfaces or data models and inject error parsers for full testability. -
Q: Can I map errors to UI actions (like logout on 401)?
A: Yes — add side-effect handlers in the ViewModel or handle 401 explicitly inErrorHandler
.
Conclusion
Error handling isn’t glamorous — but it’s essential. By building a centralized, flexible error-handling system with AppError
and ErrorHandler
, you’ve created a foundation that scales as your app grows.
Make your code cleaner. Make your UX friendlier.
Handle errors like a pro.
Clap & Follow if this post helped you build more reliable apps!
Drop a comment if you want to see this extended with localization or analytics integration.
Comments
Post a Comment