The Configuration Change Problem
Why ViewModel Exists
When Android destroys and recreates an Activity or Fragment due to a configuration change (screen rotation, locale change, dark mode toggle), any state held in the UI layer is lost. The naive fix — saving and restoring state in onSaveInstanceState — works for small data but breaks down for complex objects, network requests, or database cursors.
ViewModel solves this by outliving the UI it is scoped to. It is retained during configuration changes and only cleared when the UI is permanently finished (e.g., user presses Back).
Lifecycle Relationship
Activity/Fragment created
└─ ViewModel created (first time only)
└─ UI observes ViewModel state
Configuration change occurs
└─ Activity/Fragment destroyed
└─ ViewModel survives (still alive)
└─ New Activity/Fragment created
└─ UI re-attaches to existing ViewModel
Activity finished (Back press)
└─ ViewModel.onCleared() called
└─ ViewModel destroyed
This lifecycle makes ViewModel the right place for:
- UI state (screen data)
- Business logic
- Data loading and caching
- Network request orchestration
ViewModel in Compose
With Jetpack Compose, ViewModel integrates directly via the viewModel() or hiltViewModel() function:
class OrderViewModel(private val repo: OrderRepository) : ViewModel() {
private val _uiState = MutableStateFlow(OrderUiState())
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
fun loadOrder(orderId: String) {
viewModelScope.launch {
_uiState.value = OrderUiState(isLoading = true)
val order = repo.getOrder(orderId)
_uiState.value = OrderUiState(order = order)
}
}
}
@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> CircularProgressIndicator()
uiState.order != null -> OrderContent(uiState.order!!)
else -> ErrorMessage()
}
}
The ViewModel is scoped to the Activity or NavBackStackEntry. Multiple composables in the same screen share the same instance.
ViewModelProvider and Factories
How ViewModel Is Created
ViewModelProvider is the mechanism that creates and retrieves ViewModel instances. It ensures only one instance exists per scope.
In Compose, viewModel() or hiltViewModel() calls ViewModelProvider internally. When you need to pass constructor arguments, you use a factory.
ViewModelFactory
class OrderViewModel(private val orderId: String, private val repo: OrderRepository) : ViewModel() {
private val _uiState = MutableStateFlow(OrderUiState())
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
init {
loadOrder()
}
private fun loadOrder() {
viewModelScope.launch {
_uiState.value = OrderUiState(isLoading = true)
_uiState.value = OrderUiState(order = repo.getOrder(orderId))
}
}
class Factory(private val orderId: String, private val repo: OrderRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(OrderViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return OrderViewModel(orderId, repo) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
Using the Factory in Compose
@Composable
fun OrderScreen(orderId: String) {
val repository = LocalOrderRepository.current
val viewModel: OrderViewModel = viewModel(
factory = OrderViewModel.Factory(orderId, repository)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// ... render UI
}
viewModelScope
viewModelScope is a coroutine scope bound to the ViewModel's lifecycle. Coroutines launched here are automatically cancelled in onCleared(). This prevents leaks from long-running work.
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
private val _results = MutableStateFlow<List<SearchResult>>(emptyList())
val results: StateFlow<List<SearchResult>> = _results.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
_results.value = repo.search(query)
}
}
}
Never use GlobalScope in a ViewModel — coroutines there survive the ViewModel and cause leaks.
UI State Sealed Interfaces
Modeling UI State
Production apps should model screen state as a single sealed interface or data class. This avoids null checks, impossible states, and makes the UI deterministic:
sealed interface OrderUiState {
data object Loading : OrderUiState
data class Success(val order: Order) : OrderUiState
data class Error(val message: String) : OrderUiState
}
The composable renders based on the state variant:
@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is OrderUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is OrderUiState.Success -> {
OrderContent(order = (uiState as OrderUiState.Success).order)
}
is OrderUiState.Error -> {
ErrorMessage(message = (uiState as OrderUiState.Error).message)
}
}
}
One-Time Events
StateFlow replays the latest value. For one-time events (navigation, showing a snackbar), use a Channel or SharedFlow with replay = 0:
class OrderViewModel : ViewModel() {
private val _events = Channel<OrderEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun confirmOrder() {
viewModelScope.launch {
_events.send(OrderEvent.NavigateToConfirmation)
}
}
}
sealed interface OrderEvent {
data object NavigateToConfirmation : OrderEvent
data class ShowSnackbar(val message: String) : OrderEvent
}
Collect in the composable with LaunchedEffect:
@Composable
fun OrderScreen(viewModel: OrderViewModel = hiltViewModel()) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is OrderEvent.NavigateToConfirmation -> { /* navigate */ }
is OrderEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
}
}
}
}
Quiz
1. What happens to a ViewModel during a configuration change?
2. When is onCleared() called on a ViewModel?
3. Why should you avoid using GlobalScope in a ViewModel?
4. What is the purpose of a ViewModelFactory?
Flashcards
Question
Why does ViewModel survive configuration changes?
Click to reveal answer
Answer
ViewModel is scoped to the ViewModelStore, which is retained by the Activity across configuration changes. The Activity is destroyed and recreated, but the ViewModelStore persists.
Question
What is viewModelScope and why use it?
Click to reveal answer
Answer
A CoroutineScope bound to a ViewModel's lifecycle. Coroutines launched here are automatically cancelled when the ViewModel is cleared, preventing leaks.
Question
What is the correct way to handle one-time events in a ViewModel?
Click to reveal answer
Answer
Use a Channel or SharedFlow with replay = 0 instead of StateFlow. Collect in LaunchedEffect in the composable.
Question
When should you use a ViewModel instead of remember?
Click to reveal answer
Answer
When state must survive configuration changes, spans multiple composables at screen level, or holds complex business logic that should outlive the UI.
Revision Notes
Key Takeaways
- 1. ViewModel survives configuration changes by being scoped to the ViewModelStore
- 2. viewModelScope auto-cancels coroutines when the ViewModel is cleared
- 3. Model UI state as a sealed interface with Loading/Success/Error variants
- 4. Use Channel or SharedFlow for one-time events, not StateFlow
- 5. Always use a ViewModelFactory when you need constructor arguments
Interview Tips
- • Explain the ViewModel lifecycle relative to the Activity lifecycle
- • Discuss why GlobalScope is dangerous in ViewModels
- • Be ready to implement a ViewModelFactory for constructor injection
- • Explain how to handle one-time navigation events without stale state replay
Cheat Sheet
ViewModel Cheat Sheet
Lifecycle:
- Created on first access, survives config changes
- onCleared() called when scope is permanently destroyed
- Scoped to Activity, Fragment, or NavBackStackEntry
Creation:
viewModel()for no-arg constructorsviewModel { Factory(...) }for constructor argumentshiltViewModel()with Hilt/Dagger injection
Coroutine Scope:
viewModelScope— auto-cancels on onCleared()- Use
viewModelScope.launch { ... }for async work - Never use GlobalScope
State Pattern:
- Single sealed interface for UI state (Loading/Success/Error)
- StateFlow for state, Channel for one-time events
collectAsStateWithLifecycle()in Compose
Common Mistakes:
- Holding Activity/Context reference in ViewModel (memory leak)
- Using GlobalScope instead of viewModelScope
- Storing UI state in LiveData when Compose works better with StateFlow