StateFlow vs MutableState

by | Nov 16, 2024

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 Compose
  • lifecycle-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 the collectAsStateWithLifecycle() function. This will manage the cancellation and restart of the Flow collection based on the LifeCycleOwner’s current state. Just like with composeColor in the ViewModel, we will now use delegation in our MainScreen composable to handle unwrapping the color value that we can access directly with flowState instead of needing to call flowState.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 StateFlows, 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 updatesReactivitySingle PropertyDelegate SupportSavedState Integration
Compose State
StateFlow
Compose State vs StateFlow Comparison Table

Even though StateFlows 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.

The Essentials of Industry-Level Android App Development

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.

Read More