Architecture: ViewModel, lifecycle and saved state
Lifecycle-aware collection, ViewModel scope and survival rules, SavedStateHandle for process death, and unidirectional data flow.
ViewModel and its real lifetime
class BookViewModel(
private val repo: BookRepository,
private val savedState: SavedStateHandle,
) : ViewModel() {
private val _uiState = MutableStateFlow(BookUiState())
val uiState: StateFlow<BookUiState> = _uiState.asStateFlow()
private val query: StateFlow<String> = savedState.getStateFlow("query", "")
init {
viewModelScope.launch {
query.debounce(300).collectLatest { q -> load(q) }
}
}
private suspend fun load(q: String) {
_uiState.update { it.copy(loading = true, error = null) }
runCatching { repo.search(q) }
.onSuccess { books -> _uiState.update { it.copy(loading = false, books = books) } }
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message) } }
}
}
@Composable
fun BooksRoute(vm: BookViewModel = hiltViewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
BooksScreen(state = state, onRetry = vm::retry)
}- A ViewModel survives rotation and configuration changes; it does not survive process death. Anything that must survive both belongs in
SavedStateHandleor in persistent storage. viewModelScopeis cancelled when the ViewModel is cleared, which happens when the screen leaves the back stack for good - not when it is merely rotated.- Collect with
collectAsStateWithLifecycle, notcollectAsState, so a backgrounded screen stops consuming and the flow can pause upstream work. stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initial)is the standard way to expose a cold flow as UI state, keeping it alive briefly across a rotation.
⚠️
Never hold an
Activity, a View or a Context in a ViewModel. It outlives the Activity it was created from during a configuration change, so a reference there is a leak. Use AndroidViewModel only when you genuinely need the application context.Lifecycle-aware side effects
@Composable
fun BooksScreen(state: BookUiState, onRetry: () -> Unit) {
// runs once per composition entry, not on every recomposition
LaunchedEffect(Unit) { onRetry() }
// re-runs when the key changes; the previous coroutine is cancelled
LaunchedEffect(state.query) { analytics.track("query", state.query) }
// a flow collected only while the composable is in composition
LaunchedEffect(Unit) {
viewModel.events.collect { event -> when (event) { is Toast -> show(event.text) } }
}
// observe the lifecycle directly
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) viewModel.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}| Effect | Keys | Use |
|---|---|---|
LaunchedEffect | Any key | Launch a coroutine tied to composition |
DisposableEffect | Any key | Register and unregister listeners |
rememberCoroutineScope | None | A scope for callbacks such as onClick |
SideEffect | None | Publish to non-Compose code on every successful composition |
produceState | Any key | Turn a callback API into state |
A key of Unit means "run once for this composition". Passing a changing key turns the effect into a restart-on-change, which is what you want for reloading on an id change and a bug when you only wanted one launch.
Unidirectional data flow
data class BookUiState(
val loading: Boolean = false,
val books: List<Book> = emptyList(),
val error: String? = null,
) {
val isEmpty: Boolean get() = !loading && error == null && books.isEmpty()
}
// events go up as a sealed type, state comes down as one immutable object
sealed interface BookEvent {
data class QueryChanged(val value: String) : BookEvent
data object Retry : BookEvent
data class BookSelected(val id: Long) : BookEvent
}
class BookViewModel : ViewModel() {
fun onEvent(event: BookEvent) = when (event) {
is BookEvent.QueryChanged -> savedState["query"] = event.value
BookEvent.Retry -> viewModelScope.launch { load() }
is BookEvent.BookSelected -> _navEvents.emit(event.id)
}
}
@Composable
fun BooksRoute(vm: BookViewModel = hiltViewModel(), onOpen: (Long) -> Unit) {
val state by vm.uiState.collectAsStateWithLifecycle()
BooksScreen(state = state, onEvent = vm::onEvent, onOpenBook = onOpen)
}- One state object per screen keeps the screen's contract explicit and makes previews and tests trivial: construct the state, render it, assert.
- Events as a sealed interface mean the ViewModel has one entry point, which is easy to log, test and reason about.
- Keep navigation out of the ViewModel by emitting an event and letting the composable call the navigation callback - or pass lambdas into the screen.
- Derive display values in the state class or with
derivedStateOf, not with a second flag that can contradict the first.
FAQ
How do I handle process death?
Keep the identifier of what is being shown in
SavedStateHandle, persist anything durable in a database or DataStore, and reload on creation. UI state is reconstructible; only the inputs to that reconstruction must be saved.Should the ViewModel expose a StateFlow or a Compose State?
A
StateFlow in the ViewModel, collected with collectAsStateWithLifecycle in the UI. The ViewModel then stays free of Compose types and is testable without a Compose runtime.Related
Compose state, recomposition and Material 3 Dependency injection with Hilt
Last refreshed 2026-09-18.