Separate what the screen shows from how it looks. The ViewModel holds UI state and survives rotation; the UI observes it and sends events. Learn MVVM and unidirectional data flow with StateFlow.
Putting data and logic directly in a composable or Activity ties them to the UI lifecycle — state is lost on rotation, logic cannot be tested without the UI, and screens become tangled. MVVM (Model-View-ViewModel) separates the three: the View renders, the ViewModel holds state and logic, the Model is the data. This separation is what makes an app testable and maintainable as it grows.
Without separation (state + logic in the UI):
- state lost on rotation (Activity recreated)
- can't unit-test logic without spinning up the UI
- screens become giant tangled files
MVVM splits responsibilities:
VIEW (Compose) renders state, forwards user events — no business logic
VIEWMODEL holds UI state + logic, survives rotation
MODEL the data + where it comes from (repository, next lesson)
The View observes the ViewModel's state and sends it events. One direction
of data, one of events (unidirectional data flow).A ViewModel is a lifecycle-aware state holder that outlives configuration changes — when the Activity is recreated on rotation, the same ViewModel instance is retained, so its state is not lost. It also gives you viewModelScope, a coroutine scope automatically cancelled when the ViewModel is cleared, so background work tied to a screen cleans up correctly. This solves the rotation problem the platform lesson raised.
class CounterViewModel : ViewModel() {
var count by mutableStateOf(0) // survives rotation with the ViewModel
private set // only the ViewModel can change it
fun increment() {
count++
}
fun loadAsync() {
viewModelScope.launch { // cancelled automatically when cleared
// background work tied to this screen's lifetime
}
}
}
// on rotation the Activity is recreated but the SAME ViewModel is retained ->
// 'count' is preserved. No savedInstanceState ceremony needed for it.The robust way to expose state is a single immutable UI-state object in a StateFlow — a reactive holder the UI collects. The ViewModel updates the state, and every observer re-renders. Modeling the whole screen as one data class (loading, data, error) makes impossible states hard to represent and the UI a pure function of that one value — the reactive backbone of modern Android.
data class HomeUiState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null
)
class HomeViewModel : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() // read-only outside
fun load() {
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
val users = /* repository call */ emptyList<User>()
_uiState.update { it.copy(isLoading = false, users = users) }
}
}
}
// one immutable state object; the UI is a pure function of it.The Compose UI obtains the ViewModel with viewModel(), collects its state as Compose state (lifecycle-aware), and renders from that single state object while forwarding events back as method calls. This closes the unidirectional loop: state flows down into the UI, events flow up into the ViewModel. The composable stays free of logic — it just displays state and reports intent.
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
// collect state, lifecycle-aware (stops collecting when not visible)
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> Text("Error: ${state.error}")
else -> Column {
UserList(state.users)
Button(onClick = { viewModel.load() }) { Text("Refresh") } // event up
}
}
}
// state DOWN (collectAsState), events UP (viewModel.load()). The loop is closed.