Skip to content

How to Handle Nested Callbacks in Jetpack Compose Without Prop Drilling

Purpose

This post demonstrates how to handle nested callbacks in Jetpack Compose without callback prop drilling.

Environment

  • Jetpack Compose 1.5+
  • Kotlin 1.9+
  • Android ViewModel

The Problem with Callback Prop Drilling

When I build complex screens in Jetpack Compose, I often have deeply nested component hierarchies. A button click at the bottom of the tree needs to trigger an action at the screen level.

Here’s what I ended up with:

CallbackPropDrilling.kt
// BAD: Callbacks through every layer
@Composable
fun MyScreen(viewModel: MyViewModel) {
DeepNestedButton(
onButtonClick = { viewModel.doSomething() }
)
}
@Composable
fun DeepNestedButton(onButtonClick: () -> Unit) {
// 3-5 layers of composables...
IntermediateA(onButtonClick = onButtonClick)
}
@Composable
fun IntermediateA(onButtonClick: () -> Unit) {
IntermediateB(onButtonClick = onButtonClick)
}
@Composable
fun IntermediateB(onButtonClick: () -> Unit) {
IntermediateC(onButtonClick = onButtonClick)
}
@Composable
fun IntermediateC(onButtonClick: () -> Unit) {
Button(onClick = onButtonClick) {
Text("Click me")
}
}

Every intermediate composable receives the callback and passes it down. With 5 different actions, I have 5 callbacks flowing through each layer.

This code has several problems:

  • Each intermediate composable receives callbacks it does not use
  • Adding a new action means updating every composable in the chain
  • The code becomes hard to read with many callback parameters
  • Refactoring becomes painful when the hierarchy changes

Solution 1: MVI with Action Interface

I found a cleaner approach using MVI architecture with a sealed action interface. Instead of many callbacks, I pass a single callback that accepts all possible actions.

First, I define a sealed interface for all actions:

Action.kt
sealed interface Action {
data class ItemClicked(val id: String) : Action
data object LoadMore : Action
data object Refresh : Action
data class DeleteItem(val id: String) : Action
}

Then I create a state class to hold all UI state:

ScreenState.kt
data class MyScreenState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)

The ViewModel handles all actions in one place:

MyViewModel.kt
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow(MyScreenState())
val state: StateFlow<MyScreenState> = _state.asStateFlow()
fun handleAction(action: Action) {
when (action) {
is Action.ItemClicked -> handleItemClick(action.id)
is Action.LoadMore -> loadMore()
is Action.Refresh -> refresh()
is Action.DeleteItem -> deleteItem(action.id)
}
}
private fun handleItemClick(id: String) { /* ... */ }
private fun loadMore() { /* ... */ }
private fun refresh() { /* ... */ }
private fun deleteItem(id: String) { /* ... */ }
}

Now the screen composable only passes one callback:

MyScreen.kt
@Composable
fun MyScreen(viewModel: MyViewModel) {
val state by viewModel.state.collectAsState()
// Only root knows about ViewModel
MyScreenContent(
state = state,
onAction = viewModel::handleAction
)
}
@Composable
fun MyScreenContent(
state: MyScreenState,
onAction: (Action) -> Unit
) {
Column {
if (state.isLoading) {
CircularProgressIndicator()
}
ItemList(
items = state.items,
onItemClick = { id -> onAction(Action.ItemClicked(id)) }
)
Button(onClick = { onAction(Action.LoadMore) }) {
Text("Load More")
}
}
}

Even deeply nested components can trigger actions:

DeepComponent.kt
@Composable
fun DeepNestedButton(onAction: (Action) -> Unit) {
Button(onClick = { onAction(Action.Refresh) }) {
Text("Refresh")
}
}

I added a new action by adding one line to the sealed interface and one case in the handler. No intermediate composables need to change.

Solution 2: CompositionLocal for Global Actions

For actions that are truly global (navigation, showing snackbars, analytics), I use CompositionLocal. This avoids passing these callbacks through the entire tree.

GlobalActions.kt
data class GlobalActions(
val navigate: (String) -> Unit = {},
val showSnackbar: (String) -> Unit = {},
val trackEvent: (String) -> Unit = {}
)
val GlobalActionsProvider = compositionLocalOf { GlobalActions() }

I provide these at the app root:

MyApp.kt
@Composable
fun MyApp() {
val navController = rememberNavController()
CompositionLocalProvider(
GlobalActionsProvider provides GlobalActions(
navigate = { route -> navController.navigate(route) },
showSnackbar = { message -> /* show snackbar */ },
trackEvent = { event -> /* track */ }
)
) {
MyScreen()
}
}

Any nested composable can access these without prop drilling:

NestedComponent.kt
@Composable
fun DeepNestedButton() {
val actions = GlobalActionsProvider.current
Button(onClick = { actions.navigate("detail") }) {
Text("Go to Detail")
}
}

I use this only for truly global actions. For screen-specific actions, I stick with the MVI pattern.

Solution 3: Slot API for Container Components

For reusable container components, I use the Slot API pattern. The container provides slots where parents can inject their own content.

CardContainer.kt
@Composable
fun CardContainer(
title: String,
// Slot for actions - parent decides implementation
actions: @Composable RowScope.() -> Unit
) {
Card {
Column {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall
)
Row(content = actions)
}
}
}

The parent provides the actual buttons:

Usage.kt
@Composable
fun MyScreen(viewModel: MyViewModel) {
CardContainer(
title = "Settings",
actions = {
Button(onClick = { viewModel.handleAction(Action.Refresh) }) {
Text("Refresh")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { viewModel.handleAction(Action.Save) }) {
Text("Save")
}
}
)
}

This pattern keeps the container reusable while allowing parents to control action handling.

Common Mistakes to Avoid

I made several mistakes while learning these patterns:

Passing ViewModel to Child Composables

WrongViewModel.kt
// WRONG: Child composables should not know about ViewModel
@Composable
fun ItemList(viewModel: MyViewModel) { // Don't do this!
items.forEach { item ->
Button(onClick = { viewModel.delete(item) }) { ... }
}
}

This breaks preview functionality and makes testing harder. Instead, pass state and callbacks:

CorrectStateHoisting.kt
// CORRECT: Child receives state and callbacks
@Composable
fun ItemList(
items: List<Item>,
onDelete: (String) -> Unit
) {
items.forEach { item ->
Button(onClick = { onDelete(item.id) }) { ... }
}
}

Using CompositionLocal for Screen-Specific State

CompositionLocal should only hold truly global state. I tried using it for screen state and ran into issues with previews and testing.

Not Consolidating Callbacks

Before I used the Action interface, I had this:

ManyCallbacks.kt
// Too many callbacks!
@Composable
fun MyScreenContent(
onItemClick: (String) -> Unit,
onDeleteClick: (String) -> Unit,
onRefreshClick: () -> Unit,
onLoadMoreClick: () -> Unit,
onRetryClick: () -> Unit
) { ... }

With the Action interface, this becomes:

ConsolidatedCallbacks.kt
// Single callback!
@Composable
fun MyScreenContent(
state: MyScreenState,
onAction: (Action) -> Unit
) { ... }

The Reason

I think the key reason the MVI action interface works well is:

  • Single callback reduces parameter count from N to 1
  • Adding new actions only requires changes in two places (interface and handler)
  • State and events flow in opposite directions (unidirectional data flow)
  • Testing is easy because I can test action handling separately from UI

Summary

In this post, I showed how to handle nested callbacks in Jetpack Compose without prop drilling. The key point is consolidating callbacks into a single action interface using MVI architecture.

For most cases, I use the MVI action interface pattern. For global actions like navigation, I use CompositionLocal. For reusable containers, I use the Slot API pattern.

The result is cleaner code, easier refactoring, and better testability. Adding new actions no longer requires updating every composable in the hierarchy.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments