Handling UI Events in Jetpack Compose: A Clean Approach

Hunter Freas
7 min readMar 12, 2024

--

Photo by Hal Gatewood on Unsplash

While working on the backend of the application is very important, how we handle what the user sees is crucial to the success of our work. With this in mind, we spend a lot of time thinking about what the user will do, how to handle UI events, and how to structure our code accordingly. In this article, we will be looking at a clean approach for handling UI events in Jetpack Compose. This is an approach I found from Phillip Lackner, and I will be expanding upon it and explaining how to use it.

First, what are UI events? Simply put, UI events are actions that should be handled in the UI layer, either by the UI or by the ViewModel. These include button clicks, entering text into the text field, displaying error messages, or navigating to another screen.

Edit: Before you continue reading, what I am talking about here is not a UI event, but actually a User Event. If you want to learn more about UI Events or User Events, check here.

UI Event Handling Structure

How we are going to handle UI events is quite simple. We are going to have a UiEvent interface, a ViewModel and some Composables. The user interacts with the UI (Composable), and which then triggers a function in the ViewModel. Let’s look at the clean way for handling these UI events.

The Clean way is going to have three components:

  • UiEvent — The UiEvent is a sealed interface and in here, we are going to define everything a user can do or what will happen on a single screen.
  • ViewModel — In the ViewModel, instead of having an individual function for each user interaction, we are going to have a single function that has a single parameter: onEvent(event: UiEvent). This takes in a UiEvent being passed to it and handles it accordingly.
  • Composables — The UI layer. Here, we will call the onEvent function and pass in UiEvents.

Let’s go through the code to understand this better.

sealed interface UiEvent {
data object InteractionOne: UiEvent
data class InteractionTwo(val value: String): UiEvent
}

In our UiEvent, we are defining every possible event that can happen on the screen. We use data objects and data classes to define the event types to represent our UiEvents. Data classes are used for when we need to pass data from the Ui to the ViewModel, such as text input.

We use a sealed interface for two reasons. First, once a module with a sealed interface is compiled, no new implementations can be created. This means that the classes that we defined are fixed, nothing else can be added later. Secondly, using a sealed interface with a when statement, you can cover all possible defined UiEvents types.

Find out more about sealed interfaces here.

class EventViewModel(
private val onNavigate: () -> Unit
): ViewModel() {

fun onEvent(event: UiEvent) {
when (event) {
UiEvent.InteractionOne -> {
println("This is event one")
}
is UiEvent.InteractionTwo -> {
println("This is event two and its value: ${event.value}")
}
}
}
}

In the EventViewModel, we only have a single function named onEvent. This takes in the parameter of UiEvent. Because it is a sealed interface, combining it with a when ensures that we can cover all possible UiEvent types. Depending on the type, different functions will be called.

@Composable
fun HomeScreen(
state: EventUiState,
onEvent: (UiEvent) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Button(onClick = { onEvent(UiEvent.InteractionOne }) {
Text("Print InteractionOne")
}
Button(onClick = { onEvent(UiEvent.InteractionTwo("Hello"))}) {
Text("Print InteractionTwo")
}
}
}

In the HomeScreen Composable, we only have to pass in one function. Since onEvent takes in a UiEvent as a parameter, every interaction on the screen will use the same function, just with different UiEvents.

If we run the code, and click on the buttons, we will see our functions are working and printing as expected.

Benefits

This may look like some extra steps, but let’s take a look at the benefits.

  • Decouple the Layers
  • Simplifies Composables
  • Scalability and Maintainability

We will dive into each of these further.

Decouple the Layers

The purpose of using this style is to further separate our layers. Using a RadioButton as an example, we could call functions directly from the ViewModel in the UI as shown below and it will work just fine.

Button(onClick = { viewModel.printInteractionOne() }) {
Text("Print InteractionOne")
}

However, in this example, the UI has a direct interaction into the ViewModel. Generally when we are designing the structure of our projects, we are doing our best to separate the layers as much as possible. By using a UiEvent, the UI is not reliant on what the ViewModel does during that interaction.

Simplifies Composables

In the example from above, instead of passing in the two functions into the composable, we only passed one. Without the onEvent function, we would have to pass in each lambda function individually, like so.

@Composable
fun EventDemoButtons(
onInteractionOneClick: () -> Unit,
onInteractionTwoClick: (String) -> Unit,
) {
Button(onClick = { onInteractionOneClick() }) {
Text("Print InteractionOne")
}
Button(onClick = { onInteractionTwoClick("Hello") }) {
Text("Print InteractionTwo")
}
}
EventDemoButons(
onInteractionOneClick = { viewModel.onInteractionOneClick() },
onInteractionTwoClick = { viewModel.onInteractionTwoClick(it) }
)

Again, this will work, but this is a simple feature with two buttons. As your features become more complex, the number of UI events grows. Passing in each function can lead to confusion for which function does what. Simplifying it by passing in a single function is the cleaner approach.

@Composable
fun EventDemoButtons(
onEvent: (UiEvent) -> Unit
) {
Button(onClick = { onEvent(UiEvent.InteractionOne) }) {
Text("Print InteractionOne")
}
Button(onClick = { onEvent(UiEvent.InteractionTwo("Hello")) }) {
Text("Print InteractionTwo")
}
}

Here is how you can call the DemoButtonScreen.

EventDemoButtons(onEvent = {
viewModel.onEvent(it)
})

Scalability and Maintainability

As you are working on a feature, there are always moments where new functions are requested, interactions to be modified, or other cases. Using a centralized event handling method will make it much easier to handle these changes. For example, if we need to add a new interaction, InteractionThree, we simply add it to the UiEvent interface and then update the onEvent with the new interaction.

sealed interface UiEvent {
data object InteractionOne: UiEvent
data class InteractionTwo(val value: String): UiEvent
data object InteractionThree: UiEvent
}
fun onEvent(event: UiEvent) {
when (event) {
UiEvent.InteractionOne -> {
Log.d("UiEvent", "This is event one")
}
is UiEvent.InteractionTwo -> {
Log.d("UiEvent", "This is event two and its value: ${event.value}")
}
UiEvent.InteractionThree -> {
Log.d("UiEvent", "This is event three")
}
}
}

Examples

We have seen how to implement a clean approach to UI event handling, but what are some practical examples? Let’s implement a basic screen containing a TextField and a Button.

@Composable
fun HomeScreen(
state: EventUiState,
onEvent: (UiEvent) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(0.5f))
TextField(modifier = Modifier.fillMaxWidth(),
value = state.text, onValueChange = {
onEvent(UiEvent.OnValueChange(it))
})

Button(modifier = Modifier.fillMaxWidth(), onClick = { onEvent(UiEvent.Save) }) {
Text(
text = "Save"
)
}
Spacer(modifier = Modifier.weight(0.5f))

}
}
sealed interface UiEvent {
data class OnValueChange(val value: String): UiEvent
data object Save: UiEvent
}
when (event) {
is UiEvent.OnValueChange -> {
_state.update {
it.copy(
text = event.value
)
}
}
UiEvent.Save -> {
onNavigate()
}
}
}

Here, we are going to update the state in our ViewModel using the UiEvent.OnValueChange() in our TextField and passing in the new value. Doing this approach, the UI doesn’t know what happens to the data, it is completely separated from what goes on in the UI.

Adding to the Example

Whoops, we don’t have any way of handling an error in code. What if the TextField is empty? Let’s implement some error handling. We can add a private function to the ViewModel to check if the text is empty.

private fun checkForErrors(): Boolean {
if (_state.value.text.isBlank()) {
_state.update {
it.copy(
errorMessage = "Text can't be blank."
)
}
}
return _state.value.errorMessage == null
}

Now we can modify how we handle the UiEvent.Save in the onEvent.

UiEvent.Save -> {
if (checkForErrors()) {
onNavigate()
}
}

Great, so now we can display an error message if the textfield is blank. We should also set our state’s errorMessage back to null to ensure that the user can save later. We can add an event named ClearError to the UiEvent.

sealed interface UiEvent {
data class OnValueChange(val value: String): UiEvent
data object Save: UiEvent
data object ClearError: UiEvent
}

Add the event handling to the onEvent.

UiEvent.ClearError -> {
_state.update {
it.copy(
errorMessage = null
)
}
}

And finally, we can add the logic for displaying the error message and calling the UiEvent.ClearError from the Composable. Again, we are simply passing in the event to our onEvent function in the UI, that’s it.

val context = LocalContext.current
LaunchedEffect(key1 = state.errorMessage) {
if (state.errorMessage != null) {
Toast.makeText(context, state.errorMessage, Toast.LENGTH_SHORT).show()
onEvent(UiEvent.ClearError)
}
}

Conclusion

That’s it! We have taken a look at how to handle UI events in a cleaner way by creating a sealed interface where we define all events, implementing an onEvent function that takes an event as a parameter, and finally passing in only the onEvent function to our Composables.

How do you handle UI events? Let me know what you thought of this!

Connect on LinkedIn.

--

--