What you'll learn
So far, screen state has lived inside composables with remember — fine for small things, but it dies when the screen is recreated (rotation) and mixes UI with logic. The ViewModel is the proper home for a screen's state and logic. This module is the backbone of the architecture in the next lesson.
By the end you'll be able to:
- Explain what a
ViewModelis and why it survives configuration changes - Expose state with
StateFlow - Collect that state with
collectAsStateWithLifecycle() - Model a whole screen with a single UI state class
Why a ViewModel
A ViewModel is a lifecycle-aware object that holds UI-related state and the logic to change it. Two properties make it essential:
- It survives configuration changes. When the user rotates the phone, the Activity is destroyed and recreated —
rememberstate is lost, but the ViewModel (and its state) lives on. No more losing a half-filled form to a rotation. - It separates logic from UI. The composable becomes a thin layer that just displays state and forwards events. The what happens lives in the ViewModel, where it can be unit tested without any UI at all.
Note
viewModel() in a composable.Exposing state with StateFlow
A StateFlow is an observable state holder — it always has a current value and emits updates when that value changes (you met Flow's cousin, mutableStateOf, in Phase 1; Flow is the Kotlin-wide version, ideal outside composables). The standard pattern is a private mutable flow the ViewModel updates, exposed as a public read-only flow the UI observes — the "single source of truth" rule again:
class CounterViewModel : ViewModel() {
// Private, mutable inside the ViewModel…
private val _count = MutableStateFlow(0)
// …public, read-only outside it.
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.value += 1
}
}Key idea
_count / count pairing is the encapsulation pattern you'll write constantly: only the ViewModel can mutate state (_count), while the UI can only read it (count). State changes go through the ViewModel's functions — never from the UI directly.Collecting state in the UI
In the composable, get the ViewModel with viewModel() and turn its flow into Compose state with collectAsStateWithLifecycle(). That gives you a plain value that recomposes on change — and, crucially, it only collects while the screen is actually visible, saving work when it's in the background:
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
// Lifecycle-aware: stops collecting when the screen isn't visible.
val count by viewModel.count.collectAsStateWithLifecycle()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: $count", fontSize = 24.sp)
Button(onClick = viewModel::increment) { Text("Increment") }
}
}Watch out
collectAsStateWithLifecycle() over the plain collectAsState() for StateFlow from a ViewModel. The lifecycle-aware version stops collecting when the app is backgrounded, avoiding wasted work and subtle bugs. (It comes from the lifecycle-runtime-compose dependency.)The UI state pattern
Rather than exposing a dozen separate flows, bundle everything a screen needs into one immutable data class — often named SomethingUiState. It captures the entire screen at any instant: is it loading, what data do we have, is there an error? The UI reads one object and renders accordingly:
// One data class describes the whole screen at any moment.
data class ProfileUiState(
val isLoading: Boolean = true,
val name: String = "",
val error: String? = null,
)
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun load() {
_uiState.update { it.copy(isLoading = true, error = null) }
// …fetch, then:
_uiState.update { it.copy(isLoading = false, name = "Ada") }
}
}Update it immutably with _uiState.update { it.copy(...) }, changing only the fields that moved. In the UI you branch on the state — show a spinner when isLoading, an error message when error != null, otherwise the content. One object, one source of truth, every screen state representable.
Recap & quick check
Key takeaways
- A ViewModel holds a screen's state and logic, survives configuration changes, and is unit-testable.
- Expose state as a public read-only StateFlow backed by a private MutableStateFlow.
- Only the ViewModel mutates state; the UI reads it and calls ViewModel functions for events.
- Collect with collectAsStateWithLifecycle() so collection pauses when the screen isn't visible.
- Model the whole screen with one immutable UI state data class (loading, data, error).
Quick check
1. What key problem does a ViewModel solve versus remember?
2. Why expose count as StateFlow but keep _count as MutableStateFlow?
3. How should you collect a ViewModel's StateFlow in Compose?
4. Where should the logic that changes state live?
5. What is the 'UI state' pattern?
You now have a proper home for state and logic. Let's assemble these pieces into a complete architecture — MVVM.