Introduction
At PL Coding, we frequently get asked about the best way to handle state in ViewModels. Why is this such a common question? State management is a crucial part of Android, and I believe it is because so many options are available that developers get confused about which to use.
What Are Our Options To Manage State?
The common ways to manage state that you’ll most likely encounter and find a lot of resources on are:
- LiveData
- StateFlow
- Compose State
LiveData
is an Android-specific state holder that is lifecycle-aware, which we can immediately disregard since it has been outclassed by Kotlin’s StateFlow
, which now has lifecycle-aware emission collection. Even Google uses it in their app samples (NowInAndroid sample) and their documentation.
We’ll examine how to use the other two options, their advantages and disadvantages, and, in the end, what my recommendation is.
Setup And Updating State
First, a couple of dependencies must be included in the libs.versions.toml
file.
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.7"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7"
lifecycle-runtime-compose
– required to collect flow emissions safely in Composelifecycle-viewmodel-compose
– required to create a ViewModel in a composable context
Now, we’ll look at how we declare a state variable using Compose state and StateFlow
.
- Compose State – We only need a single variable. To avoid exposing a mutable property to our Compose code, we can add a private setter. Updating the state is a straightforward assignment expression.
- StateFlow – Here, we need two variables, a private mutable one and a public immutable one. We call this a backing property, and its purpose is to allow us to expose an immutable version of our state, which is backed by a mutable version that we can more easily work with. Updating the state is also a simple one-liner.
A crucial difference between the two approaches to updating the state is that StateFlow
has a thread-safe update
function. This allows you to safely update your states from any coroutine (even if it is not running on the main dispatcher). Thread safety is vital for avoiding race conditions when working with asynchronous code, so having a built-in way to achieve this is an excellent advantage.
Even though thread safety is essential, inside your ViewModel, you’ll rarely update your state off the main dispatcher. Thread-safety considerations are also applicable when you create a new state based on the current one, like with currentState.copy()
. With the example below, we aren’t doing that. We directly assign a new value to the current state, so thread-safety considerations aren’t needed in this example.
class MainViewModel : ViewModel() {
var composeColor by mutableStateOf(OxFFFFFFFF)
private set
private val _flowColor = MutableStateFlow(OxFFFFFFFF)
val flowColor = _stateFlowColor
fun generateNewColor() {
val color = Random.nextLong(until = OxFFFFFFFF)
composeColor = color
_flowColor.value = color
}
}
Observing State
- Compose State – Access the variable directly from the ViewModel. It is automatically unwrapped because of the delegate (
by
keyword) used in the ViewModel. - StateFlow – To safely collect the
Flow
by adhering to the lifecycle of the composable, we use thecollectAsStateWithLifecycle()
function. This will manage the cancellation and restart of theFlow
collection based on theLifeCycleOwner
’s current state. Just like withcomposeColor
in the ViewModel, we will now use delegation in ourMainScreen
composable to handle unwrapping the color value that we can access directly withflowState
instead of needing to callflowState.value
.
@Composable
fun MainScreen(
// This function is from the dependencies added to the project earlier
val viewModel = viewModel<MainViewModel>()
) {
val composeState = viewModel.composeColor
val flowState by viewModel.flowColor.collectAsStateWithLifecycle()
}
SavedStateHandle Integration
Another important consideration is how these two options work with SavedStateHandle
. It is important because this is where you can store small pieces of the state that need to survive process death. Process death happens when the device your app is running on terminates your app’s process when it is open in the background to free up resources for other activities the user is focused on.
You can also use SavedStateHandle
to retrieve navigation arguments between screens with Compose Navigation.
First, how it is done using StateFlow:
class MainViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val flowColor = savedStateHandle.getStateFlow(key = "color", initialValue = OXFFFFFFFF)
fun generateNewColor() {
val color = Random.nextLong(until = OxFFFFFFFF)
savedStateHandle["color"] = color
}
}
Our code got a bit shorter because SavedStateHandle
supports StateFlow
s, which we can convert with a single function call. We pass in our key and set an initial value (the first-launch SavedStateHandle
won’t have a value of “color”). We can then update our SavedStateHandle
and automatically trigger a new emission to our flowColor
.
SavedStateHandle
is a form of persistent storage. When used in your ViewModel, it behaves no differently than an in-memory HashMap. It only persists the data as a Bundle
when the onSaveInstanceState
of the Activity
is called during process death. The data won’t be persisted when the user kills the app.
With Compose state, we can do something similar.
class MainViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val composeColor by savedStateHandle.saveable {
mutableStateOf(OXFFFFFFFF)
}
fun generateNewColor() {
val color = Random.nextLong(until = OxFFFFFFFF)
composeColor = color
}
}
We also have support for Compose state via the saveable
extension function which will handle the writing of the color value to SavedStateHandle
when you assign a new value to composeColor
.
Other Considerations
Coupling your ViewModel to Compose
Using Compose state in your ViewModel will limit its usability outside of Compose code. With the recent introduction of CMP (Compose Multiplatform), we can now use Compose code for iOS and Desktop applications! Coupling Compose to our ViewModels will only be an issue if you plan to use that ViewModel with a Fragment or Activity. Even then, there is interoperability support for rendering Compose code inside Fragments and Activities.
Reactivity with StateFlow
With StateFlow
, we have access to all the intermediary Flow
operators like map
, filter
, onEach
and combine
. This can be super powerful as with reactivity programming; you just define what needs to happen when a state changes. This means that you don’t need to remember to explicitly call a validatePassword
() function each time the password value changes, which then sets an isPasswordValid
field someplace else with which you then update the UI.
Instead, after determing that isPasswordValid
’s value 100% depends on the value of a password, we can define the following reactive flow:
class MainViewModel : ViewModel() {
private val _password = MutableStateFlow("")
val password = _password.asStateFlow()
val isPasswordValid = password
.mapLatest { password ->
val hasMinLength = password.length >= 6
val hasDigit = password.any { it.isDigit() }
val hasLowercaseChar = password.any { it.isLowerCase() }
val hasUppercaseChar = password.any { it.isUpperCase() }
hasLowercaseChar && hasUppercaseChar && hasMinLength && hasDigit
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = false
)
}
With this setup, isPasswordValid
will remain updated as the password
value changes. This reactivity is not present when you use the Compose state.
Compose state can be turned into a StateFlow
using the snapshotFlow {}
function and the stateIn()
call. You can even turn part of your Compose state into a StateFlow
. With this: snapShotFlow { state.username }
(along with stateIn()
you will have a StateFlow
of a username value.
My Recommendation
Neither of these approaches is wrong. Here is a breakdown where I compare the two approaches based on the criteria we just covered:
Thread-safe updates | Reactivity | Single Property | Delegate Support | SavedState Integration | |
---|---|---|---|---|---|
Compose State | ❌ | ❌ | ✅ | ✅ | ✅ |
StateFlow | ✅ | ✅ | ❌ | ✅ | ✅ |
Even though StateFlow
s requires a backing property, I will continue to use it as my preferred way to manage my states in a ViewModel. It provides access to simple thread-safe state updates and additional functionality with the intermediary Flow operators, allowing for the creation of reactive states.
Master Building Large-Scale Native Android Apps
Take this 23h online course to learn everything it takes to become an industry-ready native Android developer. Learn about multi-module architecture, Gradle, Jetpack Compose, authentication, offline-first development and more.