Phase 3 · Navigation & ArchitectureModule 17~30 min read

ViewModel & UI State

Hold and expose UI state properly with ViewModel and StateFlow, surviving configuration changes.

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 ViewModel is 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 — remember state 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

A ViewModel is scoped to its screen (a navigation destination or Activity). It's created the first time you ask for it and reused across recompositions and rotations, then cleared when the screen is truly gone. You get one via 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:

CounterViewModel.kt
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

The _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:

CounterScreen.kt
@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

Prefer 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:

ProfileViewModel.kt
// 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.