ViewModel State Management in Android with Kotlin

In Android development, managing UI state correctly is one of the most important things you can get right. Get it wrong and you'll face crashes on rotation, memory leaks, and unpredictable UI behavior. Get it right and your app feels solid, responsive, and maintainable.

ViewModels are the cornerstone of state management in modern Android apps. They survive configuration changes, separate UI logic from the Activity/Fragment, and integrate seamlessly with Jetpack's lifecycle-aware components. If you're new to ViewModels, start with our Android ViewModel Example guide first.

Android ViewModel State Events Tree

Android UI State Events Tree — how state flows from ViewModel to UI

What is "state"? State is any data that determines how your UI looks and behaves at a given moment — a list of items, a loading flag, an error message, or a user's input. Managing state means controlling when and how this data changes and how the UI reflects those changes.

Why Keep State in ViewModels?

  • Survives Configuration Changes: Screen rotation, language change, dark mode toggle — ViewModel persists through all of them.
  • Single Source of Truth: All UI components read from one place, eliminating inconsistency between views.
  • Easily Testable: ViewModel has no dependency on Android UI, so you can unit test it without a device.
  • Decoupled Architecture: UI layer only observes data — business logic lives in ViewModel, repository, or use cases.

3 Approaches to State Management

1. Using LiveData

Lifecycle-aware observable data holder — best for simple UI state

LiveData automatically stops delivering updates when the UI is inactive (paused or stopped), preventing crashes and memory leaks. It's the simplest approach and works well for straightforward state values.

  • Automatically respects the Activity/Fragment lifecycle
  • No need to manually unsubscribe observers
  • Best for Java interop or simpler use cases
class ProductViewModel : ViewModel() {

    // Private mutable — only ViewModel can change this
    private val _productName = MutableLiveData<String>()

    // Public immutable — UI observes this
    val productName: LiveData<String> = _productName

    fun updateName(newName: String) {
        _productName.value = newName
    }
}

// In Activity/Fragment
viewModel.productName.observe(viewLifecycleOwner) { name ->
    binding.tvProductName.text = name
}

2. Using StateFlow

Coroutine-based reactive stream — best for complex state with Kotlin coroutines

StateFlow is the modern, Kotlin-first alternative to LiveData. It integrates natively with Kotlin coroutines, supports backpressure handling, and works well with Jetpack Compose. It always holds a value and emits updates to collectors.

  • Kotlin coroutines native — no Java overhead
  • Works perfectly with Jetpack Compose
  • Supports update{} for atomic state transitions
  • Use repeatOnLifecycle to safely collect in UI
class ShoppingCartViewModel : ViewModel() {

    private val _items = MutableStateFlow<List<Product>>(emptyList())
    val items: StateFlow<List<Product>> = _items.asStateFlow()

    fun addItem(product: Product) {
        _items.update { current -> current + product }
    }

    fun loadItems() {
        viewModelScope.launch {
            val result = repository.fetchItems()
            _items.value = result
        }
    }
}

// In Fragment — safely collect with repeatOnLifecycle
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.items.collect { items ->
            adapter.submitList(items)
        }
    }
}

3. Using SavedStateHandle

Survives process death — best for preserving user input and navigation args

Unlike LiveData and StateFlow, SavedStateHandle persists data even after the system kills your app process (e.g. low memory). It uses the same mechanism as onSaveInstanceState but without the boilerplate. Ideal for form input, search queries, and navigation arguments.

  • Survives both configuration changes AND process death
  • Inject via constructor — Hilt or by viewModels() handles it automatically
  • Can expose data as LiveData or StateFlow
class ProfileViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    // Automatically restored after process death
    val userName: StateFlow<String> =
        savedStateHandle.getStateFlow("userName", "")

    fun saveUserName(newUserName: String) {
        savedStateHandle["userName"] = newUserName
    }
}

// No special setup needed — works automatically with
// by viewModels() or Hilt's @HiltViewModel

Which Approach Should You Use?

Approach Survives Rotation Survives Process Death Coroutine Support Best For
LiveData ✅ Yes ❌ No ⚠️ Limited Simple values, Java interop
StateFlow ✅ Yes ❌ No ✅ Native Complex state, Compose, coroutines
SavedStateHandle ✅ Yes ✅ Yes ✅ Yes User input, nav args, critical state

Best Practices

  • Always expose immutable state to the UI — use MutableStateFlow privately and expose StateFlow publicly. This prevents the UI from modifying state directly.
  • Use repeatOnLifecycle when collecting StateFlow — never use lifecycleScope.launch { flow.collect {} } directly. Always wrap with repeatOnLifecycle(STARTED) to avoid collecting in the background.
  • Model state as a sealed class for complex screens — instead of multiple separate flows, wrap your UI state in a single sealed class with states like Loading, Success, and Error.
  • Don't hold references to Context in ViewModel — ViewModel outlives the Activity. Use AndroidViewModel if you truly need Application context.

Frequently Asked Questions

Is LiveData obsolete now that StateFlow exists?
Not entirely. LiveData still works well for simple use cases and has better Java interop. However, for new Kotlin-first projects — especially those using Jetpack Compose — StateFlow is the recommended approach. See how both are used together in our LiveData and ViewModel guide.

Can I use both LiveData and StateFlow in the same project?
Yes, they can coexist. You can even convert between them using asLiveData() and asStateFlow(). For consistency though, pick one approach per feature.

When should I use SavedStateHandle vs a database?
SavedStateHandle is for small, transient UI state like form input or scroll position. For large datasets or data that should persist across app reinstalls, use a proper database like Room.

Does ViewModel work with Jetpack Compose?
Yes — use the viewModel() composable from androidx.lifecycle:lifecycle-viewmodel-compose. Pair it with StateFlow and collectAsStateWithLifecycle() for optimal results.

📝 Summary
  • LiveData — simple, lifecycle-aware, great for Java/Kotlin projects
  • StateFlow — modern, coroutine-native, ideal for Kotlin-first and Compose projects
  • SavedStateHandle — survives process death, use for user input and navigation args
  • Always expose immutable state to the UI layer
  • Use repeatOnLifecycle(STARTED) when collecting flows in Fragments
  • Model complex screen state as a sealed class with a single StateFlow

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form