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 SavedStateHandle or in persistent storage.
  • viewModelScope is 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, not collectAsState, 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) }
    }
}
EffectKeysUse
LaunchedEffectAny keyLaunch a coroutine tied to composition
DisposableEffectAny keyRegister and unregister listeners
rememberCoroutineScopeNoneA scope for callbacks such as onClick
SideEffectNonePublish to non-Compose code on every successful composition
produceStateAny keyTurn 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.

Compose state, recomposition and Material 3 Dependency injection with Hilt

Last refreshed 2026-09-18.