Android with Kotlin and Jetpack Compose · Architecture and Data: ViewModels, Navigation, Networking and Offline
ViewModels and UI State with StateFlow
Move screen logic into a ViewModel, model the screen as one immutable UI state, expose it as a StateFlow, and collect it safely in Compose with collectAsStateWithLifecycle.
What you'll build
A "Repositories" screen that shows a spinner while loading, then a list, or an error with a Retry button. Its logic lives in a ViewModel that survives rotation and is fully testable without a device.
The idea
Android's recommended architecture separates the UI layer (composables plus a ViewModel per screen) from the data layer (repositories). The ViewModel:
- outlives configuration changes such as rotation, so data isn't reloaded;
- exposes one immutable UI state (
StateFlow<RepoUiState>), which the screen renders; - receives events from the UI as plain function calls (
retry(),onQueryChange(...)); - launches work in
viewModelScope, which is cancelled when the screen is gone for good.
A sealed interface makes every possible state explicit, so the screen's when must handle loading, success and error. In Compose, collectAsStateWithLifecycle() collects the flow only while the screen is visible.
The code
sealed interface RepoUiState {
data object Loading : RepoUiState
data class Success(val repos: List<Repo>) : RepoUiState
data class Error(val message: String) : RepoUiState
}
class RepoViewModel(private val repository: RepoRepository) : ViewModel() {
private val _uiState = MutableStateFlow<RepoUiState>(RepoUiState.Loading)
val uiState: StateFlow<RepoUiState> = _uiState.asStateFlow()
init { load() }
fun retry() = load()
private fun load() {
viewModelScope.launch {
_uiState.value = RepoUiState.Loading
_uiState.value = try {
RepoUiState.Success(repository.topRepos())
} catch (e: IOException) {
RepoUiState.Error("Couldn't reach the server. Check your connection.")
}
}
}
}
@Composable
fun RepoRoute(viewModel: RepoViewModel = viewModel(factory = RepoViewModel.Factory)) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
RepoScreen(state, onRetry = viewModel::retry)
}
@Composable
fun RepoScreen(state: RepoUiState, onRetry: () -> Unit) {
when (state) {
RepoUiState.Loading -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
is RepoUiState.Success -> RepoList(state.repos)
is RepoUiState.Error -> ErrorMessage(state.message, onRetry)
}
}
RepoRoute connects the ViewModel; RepoScreen stays stateless, so previews and UI tests can pass any state directly. Dependencies: androidx.lifecycle:lifecycle-viewmodel-compose and lifecycle-runtime-compose.
Try the logic
The ViewModel's logic runs on plain coroutines. Here a fake repository fails once, then succeeds, and a collector records every state the screen would render:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.IOException
data class Repo(val name: String, val stars: Int)
sealed interface RepoUiState {
data object Loading : RepoUiState
data class Success(val repos: List<Repo>) : RepoUiState
data class Error(val message: String) : RepoUiState
}
class FakeRepository {
private var calls = 0
suspend fun topRepos(): List<Repo> {
delay(100)
if (++calls == 1) throw IOException("timeout")
return listOf(Repo("nowinandroid", 18_000), Repo("compose-samples", 21_000))
}
}
class RepoViewModel(private val repository: FakeRepository, private val scope: CoroutineScope) {
private val _uiState = MutableStateFlow<RepoUiState>(RepoUiState.Loading)
val uiState: StateFlow<RepoUiState> = _uiState.asStateFlow()
init { load() }
fun retry() = load()
private fun load() {
scope.launch {
_uiState.value = RepoUiState.Loading
_uiState.value = try {
RepoUiState.Success(repository.topRepos())
} catch (e: IOException) {
RepoUiState.Error("Couldn't reach the server. Check your connection.")
}
}
}
}
fun main() = runBlocking {
val vm = RepoViewModel(FakeRepository(), this)
val screen = launch { vm.uiState.collect { println("render: $it") } }
delay(200)
println("user taps Retry")
vm.retry()
delay(200)
screen.cancel()
}
Output
render: Loading render: Error(message=Couldn't reach the server. Check your connection.) user taps Retry render: Loading render: Success(repos=[Repo(name=nowinandroid, stars=18000), Repo(name=compose-samples, stars=21000)])
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Exposing
MutableStateFlow. The UI could change state directly; expose a read-onlyStateFlow. - Several separate flows for one screen. Loading flags, data and errors drift out of sync; combine them into one UI state.
- Passing a
Contextor views into the ViewModel. It outlives them and leaks memory; keep Android UI types out. - Collecting with
collectAsStatein production screens.collectAsStateWithLifecyclestops collecting in the background and saves battery.
Key points
- One ViewModel per screen exposes one immutable UI state as a
StateFlow. - Model states with a sealed interface; render them with an exhaustive
when. - Events are ViewModel functions; work runs in
viewModelScope. - Keep the screen composable stateless and connect it in a thin "route" composable.
Further reading: UI layer, State holders and UI state.
Practice questions
Why does a ViewModel survive screen rotation?
Answer
Rotation recreates the Activity, but ViewModels are stored in a ViewModelStore that's retained across configuration changes, so the same instance (and its loaded data) is given to the new Activity. It's cleared only when the screen is finished for good, which also cancels viewModelScope.
Why model UI state as a single sealed interface or data class?
Answer
One immutable state object makes the screen a pure function of that state, prevents impossible combinations (loading and error at once), makes the when exhaustive, and makes tests simple: send events, assert on the emitted states.
Open in the app to mark lessons complete, take notes and track your progress.