Flow and modelling UI state
Lesson 4 of 51 min read
Cold vs hot, StateFlow for state, SharedFlow for events, and the operators worth knowing.
If coroutines are "one result later", Flow is "many results over time". For a screen, that is exactly what state is.
Cold vs hot, in one example
A cold Flow does nothing until collected, and each collector gets its own run — a Room query, an API poll. A hot flow is running regardless, and collectors join whatever is happening now.
- StateFlow — hot, always holds exactly one current value. This is your screen state.
- SharedFlow — hot, no stored value, can replay. This is your one-shot events: navigate, show a snackbar.
The shape that reviewers expect
data class FeedUiState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
class FeedViewModel(repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow(FeedUiState())
val state: StateFlow<FeedUiState> = _state.asStateFlow()
}
Two details carry weight: the mutable flow is private and exposed with asStateFlow(), and the state is one object, so impossible combinations like loading-with-an-error are easy to spot.
stateIn, and why the timeout
val state = repo.observeFeed()
.map { FeedUiState(items = it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FeedUiState())
WhileSubscribed(5_000) keeps the upstream alive for five seconds after the last collector leaves, so a rotation does not tear down and restart the database query. Knowing why that number is there is a senior signal.
The four operators that come up
map— transform each value.combine— join two sources into one state (data plus filter).debounce— wait for the user to stop typing.flatMapLatest— cancel the previous inner work when a new value arrives. This is the search-box operator.
Collecting in the UI
Use collectAsStateWithLifecycle() in Compose. Plain collectAsState() keeps collecting while the app is in the background, which is a battery and crash report waiting to happen.
