Escape Prop-Drilling and Event-Drilling without Losing Your Architecture

Introduction
We have already seen in a previous article how to tackle prop-drilling in Jetpack Compose using Composition Locals, passing data down the tree without polluting intermediate composables with redundant parameters.
In this article, we address the same problem for event-drilling, where callbacks flow up the tree, cluttering every intermediate composable between a child action and the parent handler.
What is Event-Drilling?
Prop-drilling passes data down: a deep child needs a theme value, so you thread it through every intermediate composable that doesn’t use it.
Event-drilling passes callbacks up: a deep child needs to delete an item or log analytics, so you thread onDeleteItem and onLogEvent through every intermediate composable, even ones that don’t care about these actions.
Both pollute your composable signatures with pass-through parameters.
The Problem
In Compose, passing callbacks up the tree gets messy fast. You want a button three levels deep to delete an item, select an item, or log analytics, so you thread onDeleteItem, onSelectItem, and onLogEvent through every intermediate composable.
This is event-drilling, and it clutters your function signatures with callbacks that most composables never invoke.
The Solution: Event Sink via Composition Locals
Instead of threading callbacks through every layer, provide a single event sink (a central function that receives events from anywhere in the tree and handles them in one place).
Think of it like an office intercom, where any room (composable) can call reception (your parent handler), and reception decides what to do (delete an item, navigate, log an event).
The tradeoff
You gain — Simpler composable APIs: No more pass-through callback parameters
You lose — Some explicitness: Instead of seeing onDeleteItem in function parameters, events flow through an “ambient” channel
Use carefully: Keep the sink scoped to screens or nav graphs, not global. This keeps code clean without creating architectural chaos.
What’s an event sink?
A sink is where things drain to. An event sink is a function (UiEvent) -> Unit that receives events and handles them centrally.
Children emit events (like “delete item” or “select item”), and the parent decides how to handle each one.
What about navigation?
Navigation events can work with this pattern, but with a critical distinction:
Children should request navigation (“user clicked logout”) rather than dictate exact destinations (“navigate to /settings/profile/edit”).
The parent or ViewModel validates and routes. See the “Critical Note” and “ViewModel routing” sections below for safe navigation patterns.
Define Different Events
First, define your events as a sealed interface. Without composition locals, these would need to be passed as callbacks through every intermediate composable (classic event-drilling).
// The events coming from down the compose tree to the parent for handling
sealed interface UiEvent {
data class SelectItem(val itemId: String, val origin: String? = null) : UiEvent
data class DeleteItem(val itemId: String, val origin: String? = null) : UiEvent
data class Log(val message: String, val origin: String? = null) : UiEvent
}
Define the staticCompositionLocalOf
Next, expose the event sink via staticCompositionLocalOf rather than the usual compositionLocalOf:
val LocalUiEventSink = staticCompositionLocalOf<(UiEvent) -> Unit>({})
Why staticCompositionLocalOf?
compositionLocalOfwatches the value and recomposes all consumers when it changes.staticCompositionLocalOfdoes NOT watch for changes.
For our event sink, we use staticCompositionLocalOf because:
- Children only CALL the function to send events
- Children never READ the function’s value to display UI
- Since children don’t display anything from the sink, they don’t need to recompose when it changes
What could go wrong with compositionLocalOf?
When the parent recomposes, it might create a new lambda (new memory reference). With compositionLocalOf, this would trigger recomposition in ALL child composables (even though they don’t care about the change). They’re just sending events, not displaying anything.
With staticCompositionLocalOf, children never recompose when the sink changes. This is the correct choice for write-only functions.
Simple rule: Use staticCompositionLocalOf when children only CALL the function. Use compositionLocalOf when children READ the value to DISPLAY UI.
Utilize the Composition Local at Parent Composable
At the parent level (screen root or nav graph), provide the sink and centralize event handling:
@Composable
fun AppRoot(viewModel: ItemViewModel = viewModel()) {
val navController = rememberNavController()
val snackbarHostState = remember { SnackbarHostState() }
// Lifecycle-aware scope: cancels all launches when provider leaves composition
val scope = rememberCoroutineScope()
// BUFFERED (~64 slots) absorbs short bursts; after that, send() suspends instead of dropping
val events = remember { Channel<UiEvent>(capacity = Channel.BUFFERED) }
// rememberUpdatedState: captures latest lambda on each recomposition without triggering effects
// Critical: ensures handler always uses current navController/snackbarHostState, not stale references from initial composition
val handler by rememberUpdatedState { e: UiEvent ->
when (e) {
is UiEvent.SelectItem -> navController.navigate("/details/${e.itemId}")
is UiEvent.DeleteItem -> viewModel.deleteItem(e.itemId)
is UiEvent.Log -> Log.d("UiEvent", e.message)
}
}
// Consumes events from channel and dispatches to handler
LaunchedEffect(Unit) { for (e in events) handler(e) }
// Cleanup: close channel to fail-fast if children try sending after disposal
DisposableEffect(Unit) { onDispose { events.close() } }
// Provide the sink to all descendants via composition local
CompositionLocalProvider(
LocalUiEventSink provides { e ->
// Main.immediate: synchronous dispatch on main thread (Compose's UI dispatcher)
scope.launch(context = Dispatchers.Main.immediate) {
// Channel closes on dispose; late sends throw ClosedSendChannelException
runCatching { events.send(e) }
.onFailure { /* Already disposed; safe to ignore or log */ }
}
}
) {
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
NavHost(navController, startDestination = "home", modifier = Modifier.padding(padding)) {
composable("home") { HomeScreen() }
composable("details/{id}") { /* … */ }
}
}
}
}
Trigger Event at Child Composable
Any child composable, no matter how deeply nested, can now emit events without callback drilling:
@Composable
fun DeepChildScreen() {
val send = LocalUiEventSink.current
Button(onClick = {
send(UiEvent.DeleteItem("item_42"))
send(UiEvent.SelectItem("43")) // Show next item after deletion
send(UiEvent.Log("Deleted and moved to next"))
}) { Text("Delete & Next") }
}
Notes you care about:
rememberCoroutineScope()is composition-tied; launches are cancelled when this provider leaves the tree. If a child tries to emit after disposal, the job is cancelled rather than leaking.- We close the
ChannelinonDisposeto fail fast if anything tries to send later. - Use
send(suspends) instead oftrySendto avoid silent drops;BUFFEREDhere gives small elasticity without losing events. - Events should pass semantic data (like
itemId), not navigation routes or business logic. The parent composable resolves the data and performs the appropriate action. This keeps business logic centralized and testable.
Critical Note:
Deep composables should not decide navigation.
If a leaf needs to navigate, bubble the intent via an explicit callback or screen-scoped ViewModel, don’t let UI leaves dictate app flow.
If a button three levels deep directly triggers Navigate(“/checkout”), that’s a smell: business logic has leaked into your UI layer.
Scoping to a single screen
If you want the sink limited to just one screen’s subtree, provide it at the screen level instead:
@Composable
fun DetailsScreen() {
val scope = rememberCoroutineScope()
val events = remember { Channel<UiEvent>(Channel.BUFFERED) }
val handler by rememberUpdatedState { e: UiEvent ->
when (e) {
is UiEvent.SelectItem -> /* handle locally */
else -> Unit
}
}
LaunchedEffect(Unit) { for (e in events) handler(e) }
DisposableEffect(Unit) { onDispose { events.close() } }
CompositionLocalProvider(
LocalUiEventSink provides { e ->
scope.launch(Dispatchers.Main.immediate) {
runCatching { events.send(e) }
}
}
) {
DetailsContent() // Only this subtree can use this sink
}
}
Multiple consumers? Use MutableSharedFlow
When the same event needs multiple handlers (e.g., navigate AND log analytics), use MutableSharedFlow instead of Channel:
val events = remember {
MutableSharedFlow<UiEvent>(
replay = 0, // Don't replay past events to new collectors
extraBufferCapacity = 64, // Handle burst taps without blocking
onBufferOverflow = BufferOverflow.SUSPEND
)
}
// First collector: handle UI effects
LaunchedEffect(Unit) {
events.collect { e -> handler(e) }
}
// Second collector: log analytics (runs independently)
LaunchedEffect(Unit) {
events.collect { e -> analytics.log(e) }
}
CompositionLocalProvider(
LocalUiEventSink provides { e ->
scope.launch(Dispatchers.Main.immediate) {
events.emit(e)
}
}
) { /* … */ }
Why this setup:
- replay = 0: Events are one-shot actions (like “delete item”). New collectors shouldn’t replay old events.
- extraBufferCapacity = 64: Absorbs rapid button taps without blocking the emitter.
- Separate LaunchedEffect per collector: If analytics is slow, it won’t block UI event handling. Each collector processes events independently.
Key difference from Channel: A Channel has one consumer; MutableSharedFlow broadcasts to multiple consumers simultaneously.
ViewModel routing (testable variant)
When to use this: If you want to unit-test event handling logic or scope events to a specific navigation destination (like a screen or nested nav graph).
The idea: Instead of handling events directly in the composable, route them through a ViewModel. The ViewModel exposes a flow of events that the UI collects and handles.
class ScreenViewModel : ViewModel() {
private val _effects = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 64
)
val effects: SharedFlow<UiEvent> = _effects
fun onEvent(e: UiEvent) {
viewModelScope.launch { _effects.emit(e) }
}
}
@Composable
fun DetailsScreen() {
val vm: ScreenViewModel = viewModel()
val nav = rememberNavController()
val snack = remember { SnackbarHostState() }
// Collect events from ViewModel and handle them
LaunchedEffect(Unit) {
vm.effects.collect { e ->
when (e) {
is UiEvent.SelectItem -> nav.navigate("/details/${e.itemId}")
is UiEvent.DeleteItem -> vm.deleteItem(e.itemId)
is UiEvent.Log -> Log.d("Event", e.message)
}
}
}
// Provide the ViewModel's onEvent function as the sink
CompositionLocalProvider(LocalUiEventSink provides vm::onEvent) {
DetailsContent() // Child composables call LocalUiEventSink.current(...)
}
}
Benefits:
- Testable: You can unit-test
onEvent()and verify emitted effects without any UI - Scoped: The ViewModel (and its event sink) is tied to this screen’s lifecycle via
viewModel() - Survives configuration changes: Events queued in the ViewModel survive rotations
Testing example:
@Test
fun `onEvent emits DeleteItem effect`() = runTest {
val vm = ScreenViewModel()
val effects = mutableListOf<UiEvent>()
backgroundScope.launch {
vm.effects.collect { effects += it }
}
vm.onEvent(UiEvent.DeleteItem("item_42"))
assertEquals(1, effects.size)
assertTrue(effects[0] is UiEvent.DeleteItem)
assertEquals("item_42", (effects[0] as UiEvent.DeleteItem).itemId)
}
Tradeoff: More boilerplate (ViewModel class), but you gain testability and explicit scoping. Use this when testing matters more than minimal code.
Job Offers
Bottom line
Use the ambient sink for lightweight, cross-cutting UI effects. Keep it scoped, log an origin for traceability, and favor explicit callbacks or VM-routed events for navigation and state.
That way you cut plumbing without sacrificing architectural clarity.
This article was previously published on proandroiddev.com



