Error Handling Patterns in Compose: From Try-Catch to Graceful UX

By Engineering Team • Published: 2025-12-12 • Updated: 2025-12-1213 min read

Error HandlingUXBest PracticesArchitecture

Errors Are Inevitable

Network requests fail. Users enter invalid data. APIs return unexpected responses. A production-quality app handles all these gracefully, turning potential crashes into helpful user experiences.

Compose makes error handling elegant when you model errors as state. Instead of try-catch scattered everywhere, errors become first-class citizens in your UI state.

The Sealed Result Pattern

Model operation outcomes with a sealed class that captures success, loading, and error states:

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: Throwable) : Result<Nothing>()
    object Loading : Result<Nothing>()
}

// In ViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {
    
    private val _userState = MutableStateFlow<Result<User>>(Result.Loading)
    val userState: StateFlow<Result<User>> = _userState.asStateFlow()
    
    fun loadUser(userId: String) {
        viewModelScope.launch {
            _userState.value = Result.Loading
            _userState.value = try {
                Result.Success(userRepository.getUser(userId))
            } catch (e: Exception) {
                Result.Error(e)
            }
        }
    }
}

Rendering Error States

Create reusable components for different result states:

@Composable
fun <T> ResultHandler(
    result: Result<T>,
    onRetry: () -> Unit,
    content: @Composable (T) -> Unit
) {
    when (result) {
        is Result.Loading -> {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }
        is Result.Error -> {
            ErrorScreen(
                message = result.exception.toUserMessage(),
                onRetry = onRetry
            )
        }
        is Result.Success -> {
            content(result.data)
        }
    }
}

// Extension function for user-friendly messages
fun Throwable.toUserMessage(): String = when (this) {
    is java.net.UnknownHostException -> "No internet connection"
    is java.net.SocketTimeoutException -> "Request timed out"
    is HttpException -> when (code()) {
        401 -> "Please log in again"
        403 -> "Access denied"
        404 -> "Content not found"
        in 500..599 -> "Server error. Please try later."
        else -> "Something went wrong"
    }
    else -> "An unexpected error occurred"
}

Form Validation Errors

For form inputs, model validation errors alongside the data:

data class FormField(
    val value: String = "",
    val error: String? = null
)

data class LoginFormState(
    val email: FormField = FormField(),
    val password: FormField = FormField(),
    val isSubmitting: Boolean = false
)

class LoginViewModel : ViewModel() {
    private val _formState = MutableStateFlow(LoginFormState())
    val formState: StateFlow<LoginFormState> = _formState.asStateFlow()
    
    fun updateEmail(email: String) {
        _formState.update { 
            it.copy(email = FormField(value = email, error = validateEmail(email)))
        }
    }
    
    private fun validateEmail(email: String): String? {
        return when {
            email.isBlank() -> "Email is required"
            !email.contains("@") -> "Invalid email format"
            else -> null
        }
    }
}

// In Compose
@Composable
fun EmailField(field: FormField, onValueChange: (String) -> Unit) {
    Column {
        OutlinedTextField(
            value = field.value,
            onValueChange = onValueChange,
            isError = field.error != null,
            label = { Text("Email") }
        )
        if (field.error != null) {
            Text(
                text = field.error,
                color = MaterialTheme.colorScheme.error,
                style = MaterialTheme.typography.bodySmall,
                modifier = Modifier.padding(start = 16.dp, top = 4.dp)
            )
        }
    }
}

Snackbars for Transient Errors

Some errors are best shown briefly and dismissed automatically:

@Composable
fun AppScaffold(viewModel: AppViewModel) {
    val snackbarHostState = remember { SnackbarHostState() }
    
    // Collect error events
    LaunchedEffect(Unit) {
        viewModel.errorEvents.collect { error ->
            val result = snackbarHostState.showSnackbar(
                message = error.message,
                actionLabel = if (error.isRetryable) "Retry" else null,
                duration = SnackbarDuration.Short
            )
            if (result == SnackbarResult.ActionPerformed) {
                error.onRetry?.invoke()
            }
        }
    }
    
    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { padding ->
        AppContent(Modifier.padding(padding))
    }
}
Note: Match error severity to UI treatment: blocking errors get full-screen treatment, recoverable errors get snackbars, field errors appear inline.
← Back to Blog