Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 025 / 100

ViewModel best practices for Android & Kotlin developers

Keep ViewModels focused.

Expona Admin8 min read

Introduction

ViewModels are the backbone of modern Android architecture, but we've seen enough production crashes and memory leaks to know that not all ViewModel implementations are equal. ViewModel best practices for Android & Kotlin developers aren't just theoretical—they're the difference between a stable app and one that hemorrhages user sessions.

Over the past few years, working through real e-commerce and SaaS platforms on Android, we've refined how we structure ViewModels to keep them predictable, testable, and maintainable. This guide covers the patterns that work at scale: exposing immutable state, organizing screen logic into a single UiState class, avoiding dangerous references, and treating errors as first-class state citizens.

Expose immutable StateFlow

The most common ViewModel mistake we encounter is exposing mutable LiveData or StateFlow directly. When you hand the consuming layer—typically a Fragment or Composable—a mutable observable, you're inviting chaos. A View layer that modifies ViewState directly breaks the unidirectional data flow that makes debugging possible.

Instead, expose immutable StateFlow backed by a private mutable version. This pattern is straightforward and becomes second nature:

kotlin
class OrderViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<OrderUiState>(OrderUiState.Loading)
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()

    fun loadOrder(orderId: String) {
        viewModelScope.launch {
            try {
                _uiState.value = OrderUiState.Loading
                val order = orderRepository.getOrder(orderId)
                _uiState.value = OrderUiState.Success(order)
            } catch (e: Exception) {
                _uiState.value = OrderUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// In your Composable or Fragment
val state by viewModel.uiState.collectAsState()
when (state) {
    is OrderUiState.Loading -> LoadingScreen()
    is OrderUiState.Success -> OrderDetails(state.order)
    is OrderUiState.Error -> ErrorScreen(state.message)
}

The asStateFlow() call transforms the mutable version into a read-only interface. Your UI observes it; your ViewModel controls it. This separation is non-negotiable in production code because it prevents accidental mutations and makes state transitions traceable through your ViewModel's methods alone.

StateFlow is preferable to LiveData for several reasons. It's coroutine-native, has better backpressure handling, and works seamlessly across threads. If you're still using LiveData, migrate incrementally—StateFlow adoption pays dividends immediately.

One UiState class per screen

We've seen ViewModels with a dozen separate LiveData fields: one for loading, one for data, one for error messages, one for refresh states, one for button enabled flags. This fragmentation makes it nearly impossible to reason about consistent UI behavior. A screen should have exactly one source of truth for its state.

Define a sealed class that encompasses all possible states for your screen. Every UI state your screen can display should be representable by a variant of this class:

kotlin
sealed class ProductListUiState {
    object Loading : ProductListUiState()
    data class Success(
        val products: List<Product>,
        val isRefreshing: Boolean = false,
        val selectedSortOrder: SortOrder = SortOrder.RELEVANCE
    ) : ProductListUiState()
    data class Error(val message: String, val retryable: Boolean = true) : ProductListUiState()
}

Notice that we include isRefreshing within the Success state, not as a separate field. This ensures that your UI always has a coherent picture: you can't accidentally show "loading" while displaying stale product data, because those states are mutually exclusive by design.

In your ViewModel, you transition between states explicitly:

kotlin
fun refreshProducts() {
    viewModelScope.launch {
        val current = _uiState.value
        if (current is ProductListUiState.Success) {
            _uiState.value = current.copy(isRefreshing = true)
            try {
                val products = productRepository.getProducts()
                _uiState.value = ProductListUiState.Success(products, isRefreshing = false)
            } catch (e: Exception) {
                // Stay in Success state but show a snackbar error
                _uiState.value = current.copy(isRefreshing = false)
                _errorEvent.trySend(e.message ?: "Refresh failed")
            }
        }
    }
}

This approach scales beautifully. As your screen gains features—filtering, sorting, pagination—you add fields to the Success variant. Your UI observer doesn't multiply; it remains a single when expression that covers all states.

No Context or View references

ViewModels outlive configuration changes by design. They're scoped to the lifecycle of a navigation destination, not a Fragment. If you hold a reference to a Context or View, you're holding a reference to something that might be destroyed while your ViewModel keeps it alive—a classic memory leak.

We've debugged production crashes in ride-hailing and travel apps where ViewModels cached Context for string resources or preferences. The Fragment rotates, the old Context becomes a zombie reference, and your heap grows until the system kills the process.

Never do this:

kotlin
// WRONG - DO NOT DO THIS
class BadViewModel(context: Context) : ViewModel() {
    private val sharedPrefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
    
    fun saveUserPreference(key: String, value: String) {
        sharedPrefs.edit().putString(key, value).apply()
    }
}

Instead, inject dependencies that wrap the functionality you need without holding Context:

kotlin
interface PreferenceStore {
    fun saveUserPreference(key: String, value: String)
    fun getUserPreference(key: String): String?
}

class SharedPreferencesStore(context: Context) : PreferenceStore {
    private val sharedPrefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
    
    override fun saveUserPreference(key: String, value: String) {
        sharedPrefs.edit().putString(key, value).apply()
    }
    
    override fun getUserPreference(key: String): String? {
        return sharedPrefs.getString(key, null)
    }
}

// In your ViewModel - no Context reference
class GoodViewModel(private val preferenceStore: PreferenceStore) : ViewModel() {
    fun saveUserPreference(key: String, value: String) {
        preferenceStore.saveUserPreference(key, value)
    }
}

The same principle applies to View references. Never pass a Button or TextView into your ViewModel. The ViewModel shouldn't know or care about UI widgets. If you need to trigger UI animations or scroll to a position, emit that as an event through a separate Channel or SharedFlow, and let the View layer handle it.

Handle errors as state

Treating errors as a special case—logging them silently or showing a Toast—means your UI state becomes inconsistent with what actually happened. When an error occurs, your UiState should reflect it clearly so the UI can respond appropriately.

We distinguish between critical errors that should block the screen and recoverable errors that warrant a snackbar:

kotlin
sealed class CheckoutUiState {
    object Idle : CheckoutUiState()
    object Processing : CheckoutUiState()
    data class Success(val orderId: String) : CheckoutUiState()
    data class Error(
        val severity: ErrorSeverity,
        val message: String,
        val code: String? = null
    ) : CheckoutUiState()
}

enum class ErrorSeverity {
    RECOVERABLE,  // Show snackbar, retry available
    CRITICAL      // Show error screen, navigation may be blocked
}

class CheckoutViewModel(private val paymentGateway: PaymentGateway) : ViewModel() {
    private val _uiState = MutableStateFlow<CheckoutUiState>(CheckoutUiState.Idle)
    val uiState: StateFlow<CheckoutUiState> = _uiState.asStateFlow()

    fun processPayment(cartTotal: Double, paymentMethod: PaymentMethod) {
        viewModelScope.launch {
            _uiState.value = CheckoutUiState.Processing
            try {
                val result = paymentGateway.charge(cartTotal, paymentMethod)
                _uiState.value = CheckoutUiState.Success(result.orderId)
            } catch (e: NetworkException) {
                _uiState.value = CheckoutUiState.Error(
                    severity = ErrorSeverity.RECOVERABLE,
                    message = "Network error. Please check your connection and try again.",
                    code = "NETWORK_ERROR"
                )
            } catch (e: InvalidPaymentException) {
                _uiState.value = CheckoutUiState.Error(
                    severity = ErrorSeverity.RECOVERABLE,
                    message = e.message ?: "Payment method declined",
                    code = e.errorCode
                )
            } catch (e: Exception) {
                _uiState.value = CheckoutUiState.Error(
                    severity = ErrorSeverity.CRITICAL,
                    message = "An unexpected error occurred. Please contact support.",
                    code = "UNKNOWN"
                )
            }
        }
    }
}

In your UI layer, you handle errors explicitly:

kotlin
when (val state = uiState) {
    is CheckoutUiState.Error -> {
        when (state.severity) {
            ErrorSeverity.RECOVERABLE -> {
                Snackbar.make(view, state.message, Snackbar.LENGTH_LONG)
                    .setAction("Retry") { viewModel.processPayment(total, method) }
                    .show()
            }
            ErrorSeverity.CRITICAL -> {
                ErrorScreen(message = state.message, onContactSupport = { /* ... */ })
            }
        }
    }
    is CheckoutUiState.Success -> navigateToOrderConfirmation(state.orderId)
    CheckoutUiState.Processing -> LoadingScreen()
    CheckoutUiState.Idle -> {}
}

This approach makes debugging straightforward. When a user reports a problem, you know exactly what state their screen was in. Error codes become traceable. Your logging and analytics can hook directly into state transitions.

Common mistakes

Exposing MutableStateFlow directly. Even if you trust your team today, someone will mutate it in six months. Use asStateFlow() to enforce immutability at the interface level.

Multiple observable fields instead of a single UiState. The moment you have two StateFlow fields, you've introduced the possibility of inconsistent state. Consolidate immediately.

Performing long operations on the main thread in init. ViewModel's init block executes synchronously. If you load data there, your Fragment creation blocks. Use viewModelScope.launch in methods instead.

Ignoring coroutine cancellation. When a screen closes, viewModelScope cancels all jobs automatically. But if you're manually launching jobs outside the scope, they'll leak. Always use viewModelScope for screen-scoped work.

Storing configuration state without SavedStateHandle. If your screen needs to restore data after a process death (not just rotation), inject SavedStateHandle and use it for critical fields. This is often overlooked in ViewModels but essential for real-world resilience.

Key takeaways

  • Expose immutable StateFlow backed by a private mutable version; this enforces unidirectional data flow and prevents accidental mutations from the UI layer.
  • Define one sealed UiState class per screen with variants for Loading, Success, and Error; all screen state becomes a single source of truth.
  • Never hold Context or View references in a ViewModel; inject repositories and stores instead to avoid memory leaks across configuration changes.
  • Treat errors as state, not as side effects; distinguish between recoverable and critical errors so your UI can respond appropriately.
  • Use viewModelScope for all coroutine launches to ensure automatic cancellation when the screen closes.
  • Validate your architecture with unit tests that verify state transitions; this catches inconsistent states before production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles