Stop Passing Event/UI-Action Callbacks in Jetpack Compose

ilyas ipek on 2024-03-16

Stop Passing Event/UI-Action Callbacks in Jetpack Compose

Why the UDF is a waste of time for most screens.

Made via CodeImage

Important note: In this article, I assume you’re familiar with UDF (Unidirectional data flow) and the MVI architecture.

In Google Docs, Google advises us to use UDF to increase the testability and reusability of our components. For example…

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(text = topAppBarText)             
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {/*...*/}
        },
    )
}

By providing the component with only the data it needs topAppBarText and collecting ui-actions using lambdas onBackPressed we make this component reusable and testable. Awesome right?

However, when you apply that principle to your whole screen you will quickly end up passing 20–30 event callbacks from the top component all the way to the bottom one. Lemme give you an example :)

@Composable
fun HomeScreen(
    onItemClick: (String) -> Unit,
    onSearchClick: () -> Unit,
    /* More state and callbacks */
) {
    Home(
        onItemClick = onItemClick,
        onSearchClick = onSearchClick,
        /* More state and callbacks */
    )
}

@Composable
private fun Home(
    onItemClick: (String) -> Unit
    onSearchClick: () -> Unit,
    /* More state and callbacks */
) {
    HomeContent(
        onItemClick = onItemClick,
        onSearchClick = onSearchClick,
        /* More state and callbacks */
    )
}

@Composable
private fun HomeContent(
    onItemClick: (String) -> Unit,
    onSearchClick: () -> Unit,
    /* More state and callbacks */
) {
    Row { Button(onClick = onSearchClick) { /***/ } }
    HomeList(
        onItemClick = onItemClick,
        /* More state and callbacks */
    )
}

@Composable
private fun HomeList(onItemClick: (String) -> Unit) {
    items.forEach { item ->
        HomeListItem(
            onItemClick = onItemClick
        )
    }
}

@Composable
private fun HomeListItem(onItemClick: (String) -> Unit) {
    Button(onClick = { onItemClick(item) }) {
        /*...*/
    }
}

Here you notice that we needed to pass onItemClickon/onSearchClick like 900 times, and this is just a small example, in a real project the number of the same callback passed down might make you think that going back to XML is not a bad idea :)

Also, check this example in Google sample, please follow the callbacks down annnd up (HomeRoute & JetnewsNavGraph) for more fun 🫠

The UDF is a really good approach for reusable/generic components but it’s not suitable for the whole screen for 3 reasons…

  1. Unless the same screen is reusable for the Create and Update operation, 99% of the screens are not reusable.
  2. Almost all of the screen’s subcomponents are specific to that screen and can’t be reused elsewhere.
  3. Since the devs are lazy, they will not extract components into smaller components to avoid the pain of passing all those callbacks around. As a result, the code will become less readable and harder to maintain.

The solution is simple, if you’re using MVI, you already have a sealed class for all your Intents/Actions with a onAction()/onIntent()/processIntents() function that handles them in the ViewModel. Just pass that one onAction()function around.

Note: You can still use this solution even if you’re not using MVI.

// Optionally, create a typealias or just use (UiAction) -> Unit
typealias OnAction = (UiAction) -> Unit

@Composable
fun HomeScreen(
    onAction: OnAction
) {
    Home(onAction = onAction)
}

@Composable
private fun Home(onAction: OnAction) {
    HomeContent(onAction = onAction)
}

@Composable
private fun HomeContent(onAction: OnAction) {
    Row { 
        Button(onClick = { onAction(UiAction.OnSearchClick) }) {
           /*...*/
        } 
    }
    HomeList(onAction = onAction)
}

@Composable
private fun HomeList(onAction: OnAction, /*...*/) {
    items.forEach { item ->
        HomeListItem(
            item = item,
            onAction = onAction,
        )
    }
}

@Composable
private fun HomeListItem(
    item: String,
    onAction: OnAction,
) {
    Button(onClick = { onAction(UiAction.OnItemClick(item)) }) { /*...*/ }
}

Why is this better?

  1. Instead of 900 event callbacks passed all around, now it’s just one.
  2. Faster development time and happier developers.
  3. You will encourage your team to split big components into multiple subcomponents since it has no pain.
  4. Debugging becomes easier. It’s way faster to navigate where the Action is triggered rather than navigate to the Action and then go down the tree component by component till you forget what you were looking for :)
  5. Enhanced Code Readability
  6. Just passing the function reference, i.e. viewModel::onAction, instead of multiple lambdas will result in slightly better performance, especially when you use unstable classes like viewModel inside the lambda. Check out this video for more information.

That’s it and I hope you love the blog ❤️