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:
// BAD: Callbacks through every layer@Composablefun MyScreen(viewModel: MyViewModel) { DeepNestedButton( onButtonClick = { viewModel.doSomething() } )}
@Composablefun DeepNestedButton(onButtonClick: () -> Unit) { // 3-5 layers of composables... IntermediateA(onButtonClick = onButtonClick)}
@Composablefun IntermediateA(onButtonClick: () -> Unit) { IntermediateB(onButtonClick = onButtonClick)}
@Composablefun IntermediateB(onButtonClick: () -> Unit) { IntermediateC(onButtonClick = onButtonClick)}
@Composablefun 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:
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:
data class MyScreenState( val items: List<Item> = emptyList(), val isLoading: Boolean = false, val error: String? = null)The ViewModel handles all actions in one place:
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:
@Composablefun MyScreen(viewModel: MyViewModel) { val state by viewModel.state.collectAsState()
// Only root knows about ViewModel MyScreenContent( state = state, onAction = viewModel::handleAction )}
@Composablefun 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:
@Composablefun 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.
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:
@Composablefun 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:
@Composablefun 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.
@Composablefun 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:
@Composablefun 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
// WRONG: Child composables should not know about ViewModel@Composablefun 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:
// CORRECT: Child receives state and callbacks@Composablefun 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:
// Too many callbacks!@Composablefun MyScreenContent( onItemClick: (String) -> Unit, onDeleteClick: (String) -> Unit, onRefreshClick: () -> Unit, onLoadMoreClick: () -> Unit, onRetryClick: () -> Unit) { ... }With the Action interface, this becomes:
// Single callback!@Composablefun 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:
- π¨βπ» Jetpack Compose Official Documentation
- π¨βπ» Anchor Library
- π¨βπ» Android State Hoisting Guide
Oh, and if you found these resources useful, donβt forget to support me by starring the repo on GitHub!
Comments