Chanzmao ʕ•ᴥ•ʔ Bear Blog

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.

References

#Android #Flow #Jetpack Compose