Let me tell you the story of how I started using Jetpack Compose in my professional day-to-day work and in my personal projects.
I work for a medical device manufacturing company and had primarily focused on developing a desktop application used by doctors to program our devices. One day, I was tasked with developing an Android application for end users to control their devices. At that time, I already had significant experience developing mobile applications for various platforms, so starting this new project wasn’t difficult for me.
I began developing the Android application using the standard approach with XML for the UI and Kotlin for the code-behind. However, within a month, the stable version of Jetpack Compose was finally released! I immediately jumped on the bandwagon. Having previous experience with declarative UI frameworks like SwiftUI and Flutter, I wasn’t a stranger to Compose. Of course, I had to adapt my development workflow to utilize Compose, but the fundamental difference wasn’t significant.
In the early days of Compose, managing UI state was primarily done using mutableState
properties within the ViewModel. I personally dislike having excessive code in the UI layer, especially business logic, which I believe should be handled in the ViewModel. Consequently, all my UI-related properties were implemented in this manner:
var myProperty by mutableStateOf("Some value")
private set
Here you have an observable property that is read-only for the UI and easy to use. Here is an example of how to use this property in a Composable function:
Text(viewModel.myProperty)
It looks easy, but when you have around ten such properties, and your logic is primarily asynchronous involving different threads, it can become problematic.
Updating a property must be done on the UI thread, so either the update function should look like this:
fun updateMyProperty(newValue:String) {
viewModelScope.launch {
myProperty = newValue
}
}
Or, the place where you need to set a new value should use viewModelScope.launch
to execute the updateMyProperty
function. While this might not seem too difficult, you have to remember to perform UI updates on the main thread.
Introducing UiState
as a way to hold your data and pass it to the UI
For a long time, I used this approach and ignored new trends, which was a mistake. As developers, we must stay informed about new trends, not by immediately adopting them, but rather by investigating their potential value. This also holds true for modern design patterns. It’s crucial to research first to determine if a pattern will offer long-term benefits. Sometimes, adopting a pattern might initially require writing more code, but the end result is often a more maintainable and robust codebase in the long run.
So, when the idea of using a data class called UiState
to store all your properties in a single object emerged, I didn’t pay much attention to it initially. That was until I encountered a ViewModel with 25 properties, and maintaining the code became a real pain. My coworker suggested we start using the UiState
pattern, and I had to invest some time to determine if it was truly the right approach for us. A quick search online didn’t immediately highlight the advantages of this pattern. What ultimately convinced me to switch was the fact that using a UiState
data class with MutableStateFlow
ensures thread safety!”
In our project, we work with Bluetooth devices, and all communication is asynchronous. Every time we receive a new value from a device, we must update properties on the UI thread because the hardware access layer operates on its own thread. Therefore, the decision was made to convert all ViewModel properties into a single UiState
data class.
Let’s take the example above and convert it into a UiState
, but this time, let’s add a couple of new properties.
class MyViewModel : ViewModel() {
data class UiState(
val myProperty: String = "",
val myProperty2: String = "",
val myProperty3: String = "",
)
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
}
I declared the UiState
class within the ViewModel to keep it closely associated with the ViewModel where it will be used. While you can declare this data class outside the ViewModel, doing so would typically require giving it a unique name, such as MyViewModelUiState
. I don’t prefer this approach because each ViewModel would then have a corresponding UiState
with its name prepended to the data class name. Declaring UiState
inside the ViewModel allows you to reuse the same class name across different ViewModels, which promotes consistency.
To update the fields within UiState
, you must use the .update
function of MutableStateFlow
. Here’s an example:
fun updateMyProperty3(newValue:String) {
_uiState.update { it.copy(myProperty3 = newValue) }
}
Why use the update
function? Because it’s a thread-safe approach. The copy
functionality of a data class is not inherently thread-safe, so if you attempt to update a value like this: _uiState.value = _uiState.value.copy(myProperty3 = newValue)
, you will eventually encounter data corruption. However, by using the update
function, you are protected from such errors.
Now, how do you use UiState
in a Composable function? It’s straightforward. Here’s the code:
----- you are getting instance of ViewModel , I am skipping this part since you can do it in a few diferent ways
val state by viewModel.uiState.collectAsStateWithLifecycle()
------ using it in Composables
Text(state.myProperty)
---------------
As you can see from the example above, observing UiState
is very straightforward. I use collectAsStateWithLifecycle
instead of collectAsState
because collectAsStateWithLifecycle
is aware of the current lifecycle of the view and will not collect and update the UI if your View or application is in the background. Why waste battery, right? Even if you have received new data while your app was in the background, when you return to the app, the UI will reflect all changes automatically.
This approach, especially when you have numerous properties, looks much cleaner and is easier to maintain.”
Introducing UiEvent
: Dispatching Events from the UI to the ViewModel
Now, we’re moving on to another pattern called UiEvent
. What is it, and why should you use it? When your UI receives input from a user, you need to pass this data or event (for example, a button click) to your ViewModel to perform its logic. The conventional way to do this is to declare a function in the ViewModel and call it directly from the UI. Here’s an example:
--- ViewModel code
fun okButtonClicked(){
// do some job
}
fun updateMyProperty(newValue:String) {
_uiState.update { it.copy(myProperty3 = newValue) }
}
It doesn’t look bad, right? However, if you check my previous article about Compose previews with Hilt ViewModels, you’ll see that I used two Composables: a public one used in the navigation graph and a private one that holds the actual UI. The public Composable is responsible for obtaining the ViewModel instance and then passing its data and event-handling functions to the private Composable. Here’s an example of using a ViewModel with Hilt dependency injection:
@Composable
fun MyScreen(viewModel = hitViewModel()){
val state by viewModel.uiState.collectAsStateWithLifecycle()
MyScreenContent(
state = state,
updateProperty = viewModel::updateProperty,
updateProperty2 = viewModel::updateProperty2,
updateProperty3 = viewModel::updateProperty3,
goBackClicked = viewModel::goBackClicked,
onOkButtonClicked = viewModel::onOkButtonClicked,
onCancelButtonClicked = viewModel::onCancelButtonClicked
)
}
@Composable fun MyScreenContent(
state: MyScreen.UiState,
updateProperty:(String) -> Unit,
updateProperty2:(String) -> Unit,
updateProperty3:(String) -> Unit,
goBackClicked:() -> Unit,
onOkButtonClicked:() -> Unit,
onCancelButtonClicked:() -> Unit,
){
// UI goes here
}
Since we’ve already created UiState
, you only need to pass a single variable into the private Composable. Imagine not using UiState
! You would have to pass three properties into the Composable, along with three functions to modify them and three additional functions to handle business logic. To avoid this clutter in the UI, we’ll use the UiEvent
pattern.
So, what’s the idea behind UiEvent
? You declare a sealed class within your ViewModel like this:
sealed class UiEvent{
data class UpdateProperty(val value:String): UiEvent()
data class UpdateProperty2(val value:String): UiEvent()
data class UpdateProperty3(val value:String): UiEvent()
data object GoBackClicked: UiEvent()
data object OnOkButtonClicked: UiEvent()
data object OnCancelButtonClicked: UiEvent()
}
This sealed class holds references to other classes that inherit from UiEvent
. If the UI needs to pass data with an event, you should use a data class with the necessary data declared in its constructor. However, if no data needs to be passed, you can use a data object.
Here’s how you can handle these events:
fun onUiEvent(event:UiEvent) {
when(event) {
is UiEvent.UpdateMyProperty -> _uiState.update { it.copy(myProperty = event.value) }
is UiEvent.UpdateMyProperty2 -> _uiState.update { it.copy(myProperty2 = event.value) }
is UiEvent.UpdateMyProperty3 -> _uiState.update { it.copy(myProperty3 = event.value) }
UiEvent.goBackClicked -> //logic goes here
UiEvent.onOkButtonClicked-> //logic goes here
UiEvent.onCancelButtonClicked-> //logic goes here
}
}
Now you have a centralized place to handle all UI events. Let’s now update the UI part to use UiEvent
instead of passing all the functions separately. This is how it will look:
@Composable
fun MyScreen(viewModel = hitViewModel()){
val state by viewModel.uiState.collectAsStateWithLifecycle()
MyScreenContent(
state = state,
onUiEvent = viewModel::onUiEvent
)
}
@Composable fun MyScreenContent(
state: MyScreen.UiState,
onUiEvent:(MyScreen.UiEvent) -> Unit,
){
// UI goes here
Button(onClick {
onUiEvent(MyScreenViewModel.UiEvent.GoBackClicked)
}) {
Text("Back")
}
Button(onClick = {
onUiEvent(MyScreenViewModel.UiEvent.UpdateMyProperty("New value"))
}) {
Text("Set myProperty value")
}
}
Now the UI looks much cleaner, and it will be easier to maintain in the future if you need to add more functionality. By implementing UiState
and UiEvent
, your UI will generally look like this: you only need to use one property (UiState
) and one function (onUiEvent
), as opposed to passing each property and each function separately.
Conclusion
In conclusion, UiState
and UiEvent
are very useful patterns to adopt, and I encourage anyone not already using them to start doing so. In the long term, they will provide you with much more flexibility, even if they require a little more initial coding. Of course, the decision to follow these patterns is yours, as sometimes they might seem like overkill if, for example, you only have a single property and you’re certain it won’t be modified on a non-UI thread. However, you never know if you’ll need more properties in the future! So, why not start writing code properly from the beginning rather than refactoring it later?
Good luck to you all, and I wish you an easy and enjoyable coding journey!