A ViewModel exposes state as a read-only StateFlow and keeps the mutable one private, the pattern of StateFlow and SharedFlow. Derived state is built with flow operators instead of being stored twice. The catalog combines the loaded books with the search query, and stateIn turns the result into a StateFlow the UI can read:
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow() // read-only to the UI
val books: StateFlow<LoadState<List<Book>>> = combine(load, _query) { state, q ->
if (state is LoadState.Success) {
LoadState.Success(state.data.filter { it.title.contains(q, ignoreCase = true) })
} else state
}
.onStart { Log.d(TAG, "books: first collector arrived") }
.onCompletion { Log.d(TAG, "books: upstream stopped") }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LoadState.Loading)This is the StateFlow<LoadState<List<Book>>> that UI State with Sealed Classes promised. The state is immutable: a new LoadState.Success with a new list, never a mutated one, so equality checks see the change. There is one source of truth per value: the query lives in _query and nowhere else. And SharingStarted.WhileSubscribed(5_000) runs the combine only while someone collects, keeping it alive for five seconds after the last collector leaves, long enough to survive a rotation without restarting and short enough to stop work when the app goes to the background.