Step 9 of 15
ViewModel, StateFlow, collectAsStateWithLifecycle, UI state pattern
ViewModel และ StateFlow — แยก logic ออกจาก UI, survive configuration changes
A ViewModel survives configuration changes like rotation. It holds UI state safely outside the Activity, so the state is not lost when the Activity is recreated.
// build.gradle.kts
// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
// implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.value++
}
fun reset() {
_count.value = 0
}
}
MutableStateFlow — the private, writable holderStateFlow — the public, read-only view (use asStateFlow())viewModelScope — a coroutine scope tied to the ViewModel's lifeUse collectAsStateWithLifecycle() to observe the StateFlow only while the UI is visible. This saves battery.
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.count.collectAsStateWithLifecycle()
Column {
Text("Count: $count")
Button(onClick = { viewModel.increment() }) {
Text("Add")
}
}
}
// Tap Add three times:
// Count: 0 → Count: 1 → Count: 2 → Count: 3
// Rotate device — Count stays 3 (ViewModel survives)
For real screens, wrap everything the UI needs in one immutable data class. This is the single source of truth.
data class CounterUiState(
val count: Int = 0,
val canReset: Boolean = false
)
class CounterViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CounterUiState())
val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()
fun increment() {
_uiState.update { it.copy(count = it.count + 1, canReset = true) }
}
fun reset() {
_uiState.update { it.copy(count = 0, canReset = false) }
}
}
Diagram: Unidirectional data flow with ViewModel and immutable UI state.
Loading diagram...
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Text("Count: ${uiState.count}")
Button(onClick = viewModel::increment, enabled = true) { Text("Add") }
Button(onClick = viewModel::reset, enabled = uiState.canReset) { Text("Reset") }
}
}
The Reset button is disabled until the user increments at least once.
Use viewModelScope for coroutines. Update the state when the work finishes:
class CounterViewModel : ViewModel() {
// ... uiState as above ...
fun incrementDelayed() {
viewModelScope.launch {
delay(1000)
_uiState.update { it.copy(count = it.count + 1) }
}
}
}
When the ViewModel is cleared (the user leaves the screen for good), viewModelScope cancels the coroutine automatically. This is structured concurrency.
Build a ScoreViewModel with StateFlow<ScoreUiState>. The state holds a homeScore, an awayScore, and a winner string (computed with derivedStateOf logic in the state update). Buttons add points to each team. Confirm scores survive rotation.