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:
- Title
- Text from a text field
- Loading state
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:
- You can always find the state of the whole screen in one place.
- Easy to write tests for the state.
- Additional code to keep the state synchronized.
- Excessive copying of all fields when only one field was updated.
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:
- Each field is independent from each other by default. Low coupling.
- No need in excessive sync sections and fields copying.
- Optimised for Jetpack Compose: it will read
.value
exactly where it’s needed, not on the highest possible level of composition.
- External contract of ViewModel becomes a bit more verbose.
- Unit tests become a bit less concise.
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!