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.
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
repeatOnLifecycleto 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
MutableStateFlowprivately and exposeStateFlowpublicly. This prevents the UI from modifying state directly. - Use repeatOnLifecycle when collecting StateFlow — never use
lifecycleScope.launch { flow.collect {} }directly. Always wrap withrepeatOnLifecycle(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, andError. - Don't hold references to Context in ViewModel — ViewModel outlives the Activity. Use
AndroidViewModelif 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.
- 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