Android ViewModel: Single state or not?

Andrei Riik on 2024-01-17

Android ViewModel: Single state or not?

In the Android/Kotlin world, there are 2 ways to provide the state from the ViewModel layer to the View layer: single state and multiple states. Which one is better? Let’s do some overview of the pros and cons of both methods.

High-level thoughts

Single state is nice at first glance. A single data class contains all fields, ensuring a consistent state. However, it requires additional effort under the hood: if there are several data sources for the state, then you need a synchronized section to update the state. Otherwise, you may end up with some concurrency-related issues. Also, this way may cause excessive updates or equality checks in the View layer, especially if we are talking about Jetpack Compose.

On the other hand, multiple states provide a more verbose external contract for ViewModel but there is no need for additional synchronization, each data field is independent by default, and Jetpack Compose can read state exactly where it’s needed.

The case

Let’s do a comparison on a small screen with 3 variables:

Code comparison

💡 Note: I’ll try to keep this as simple as possible. For this reason I won’t use redux-like methods. There are also other simplifications, such as no error handling. Please let me know in the comments if you think anything could be improved in this comparison.

💡 Note: I’ll skip the View layer to keep this article short.

One thing that will be constant for both cases is repository. With some simplifications, it will be like this:

class SomeRepository {

    suspend fun getTitle(): String {
        delay(500)
        return "Some Title"
    }

    suspend fun getSavedFieldText(): String {
        delay(1000)
        return "Saved Field Text"
    }
}

It’s just a stub that emulates some data loading. Now let’s move to the interesting part.

Single State

Screen state object:

data class ScreenState(
    val title: String,
    val text: String,
    val isLoading: Boolean,
) {
    companion object {
        fun default() = ScreenState(
            title = "",
            text = "",
            isLoading = false,
        )
    }
}

ViewModel:

💡 Note: I write the state as Flow because ViewModel should not deal with View layer entities like State from Compose.

class SomeViewModel(
    private val someRepository: SomeRepository,
) : ViewModel() {

    private val _screenState = MutableStateFlow(ScreenState.default())
    val screenState: StateFlow<ScreenState> = _screenState

    // Note: no more public fields here!

    private val _titleIsLoading = MutableStateFlow(false)
    private val _textIsLoading = MutableStateFlow(false)

    private val stateLock = Mutex()

    init {
        observeLoadingState()
        loadTitle()
        loadSavedFieldText()
    }

    fun onTextChanged(newValue: String) {
        viewModelScope.launch {
            updateState {
                copy(text = newValue)
            }
        }
    }

    private fun loadTitle() {
        viewModelScope.launch {
            _titleIsLoading.value = true
            val title = someRepository.getTitle()
            _titleIsLoading.value = false
            updateState {
                copy(title = title)
            }
        }
    }

    private fun loadSavedFieldText() {
        viewModelScope.launch {
            _textIsLoading.value = true
            val text = someRepository.getSavedFieldText()
            _textIsLoading.value = false
            updateState {
                copy(text = text)
            }
        }
    }

    private fun observeLoadingState() {
        viewModelScope.launch {
            _titleIsLoading
                .combine(_textIsLoading) { titleIsLoading, textIsLoading ->
                    titleIsLoading || textIsLoading
                }
                .collect { isLoading ->
                    updateState {
                        copy(isLoading = isLoading)
                    }
                }
        }
    }

    private suspend fun updateState(updater: ScreenState.() -> ScreenState) {
        stateLock.withLock {
            _screenState.value = _screenState.value.updater()
        }
    }
}

Pros:

Screen state object:

Not this time. Fields are inside ViewModel.

ViewModel:

class SomeViewModel(
    private val someRepository: SomeRepository,
) : ViewModel() {

    private val _title = MutableStateFlow("")
    val title: StateFlow<String> = _title

    private val _text = MutableStateFlow("")
    val text: StateFlow<String> = _text

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    private val _titleIsLoading = MutableStateFlow(false)
    private val _textIsLoading = MutableStateFlow(false)

    init {
        observeLoadingState()
        loadTitle()
        loadSavedFieldText()
    }

    fun onTextChanged(newValue: String) {
        _text.value = newValue
    }

    private fun loadTitle() {
        viewModelScope.launch {
            _titleIsLoading.value = true
            val title = someRepository.getTitle()
            _titleIsLoading.value = false
            _title.value = title
        }
    }

    private fun loadSavedFieldText() {
        viewModelScope.launch {
            _textIsLoading.value = true
            val text = someRepository.getSavedFieldText()
            _textIsLoading.value = false
            _text.value = text
        }
    }

    private fun observeLoadingState() {
        viewModelScope.launch {
            _titleIsLoading
                .combine(_textIsLoading) { titleIsLoading, textIsLoading ->
                    titleIsLoading || textIsLoading
                }
                .collect { isLoading ->
                    _isLoading.value = isLoading
                }
        }
    }

    // Note: no state sync and copying here!
}

Pros:

Both methods have pros and cons and both methods work. One makes the external contract more concise, the other reduces the coupling within the ViewModel.

I’ve worked with both approaches, and it’s important to know that, as always, there are trade-offs.

Please share your thoughts and your cases in the comments.

Thanks for reading!