Declarative UI State Shouldn't Need an Imperative load()

A common pattern in Android looks like this:
data class UiState(
val users: List<User> = emptyList()
)
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
init {
load()
}
fun load() {
viewModelScope.launch {
val users = repository.fetchUsers()
_uiState.value = UiState(users)
}
}
fun retry() {
load()
}
}
The UI may also trigger the initial load:
@Composable
fun UserScreen(
viewModel: UserViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.load()
}
UserContent(
uiState = uiState,
onRetry = viewModel::retry
)
}
The problem is not init or LaunchedEffect themselves.
The problem is that we are building a declarative UiState with an imperative load() command.
The state describes what the UI should look like, but something outside that state still has to tell the application:
“Now, load the data.”
So we end up with code like:
Imperative:
init
↓
load()
↓
fetchUsers()
↓
UiState
Declarative:
users
↓
UiState
We can remove that imperative boundary by making the data loading itself part of the Flow.
data class UiState(
val users: List<User> = emptyList()
)
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _retry = MutableSharedFlow<Unit>(
extraBufferCapacity = 1
)
val uiState: StateFlow<UiState> =
_retry
.onStart { emit(Unit) }
.flatMapLatest {
repository.fetchUsers()
}
.map { users ->
UiState(users)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
UiState()
)
fun retry() {
_retry.tryEmit(Unit)
}
}
Now the Composable only observes the state:
@Composable
fun UserScreen(
viewModel: UserViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
UserContent(
uiState = uiState,
onRetry = viewModel::retry
)
}
There is no init { load() }.
There is no LaunchedEffect(Unit) { viewModel.load() }.
The initial load and retry follow the same path:
Initial subscription
↓
onStart
↓
Unit
↓
flatMapLatest
↓
fetchUsers()
↓
UiState
Retry
↓
Unit
↓
flatMapLatest
↓
fetchUsers()
↓
UiState
The important shift is simple:
Instead of imperatively telling the ViewModel when to load, we declare how UiState is derived from a Flow.
retry() still exists, but it no longer mutates UiState or calls load().
It simply emits a trigger that causes the state to be derived again.