Stop Passing Everything Down the Compose Tree: 4 Ways to Eliminate Prop Drilling
From Slot APIs to scoped ViewModels, here are four Compose patterns that keep deeply nested UI trees clean and maintainable.

Jetpack Compose encourages building UIs from small, reusable composables. As an application grows, however, those composables naturally become deeply nested.
A common consequence is prop drilling: passing the same data or callbacks through multiple intermediate composables that don’t actually use them.
Parent
↓
Root
↓
Content
↓
Card
↓
UserName
In this example, Root, Content, and Card simply forward parameters to UserName. They don’t consume the data themselves—they merely act as relays.
While Compose doesn’t completely eliminate this problem, it provides several elegant ways to reduce or avoid it. Let’s look at the four patterns I reach for most often.
1. Use Slot APIs for Layout Components
Whenever the intermediate composables only define layout, Slot APIs are usually the cleanest solution.
Before
Every composable forwards the same parameter.
@Composable
fun ParentScreen() {
val userName = "Alice"
Root(userName)
}
@Composable
fun Root(userName: String) {
Content(userName)
}
@Composable
fun Content(userName: String) {
Card(userName)
}
@Composable
fun Card(userName: String) {
UserName(userName)
}
@Composable
fun UserName(userName: String) {
Text(userName)
}
Only UserName actually needs userName.
After
Instead, let the parent construct the child.
@Composable
fun ParentScreen() {
val userName = "Alice"
CardLayout {
UserName(userName)
}
}
@Composable
fun CardLayout(
content: @Composable () -> Unit
) {
Card {
content()
}
}
The layout component no longer knows anything about userName. It simply decides where the content is displayed.
This is exactly why APIs like Scaffold, LazyColumn, and Button use slots instead of exposing dozens of parameters.
Use Slot APIs whenever a composable is responsible for layout rather than data.
2. Use CompositionLocal for Shared Infrastructure
Some objects naturally belong to the entire composition rather than a single branch.
Examples include:
- Navigator
- Theme
- Analytics
- User session
- Density
Passing these through every composable quickly becomes repetitive.
Before
@Composable
fun ParentScreen() {
Root(navigator)
}
@Composable
fun Root(navigator: Navigator) {
Content(navigator)
}
@Composable
fun Content(navigator: Navigator) {
Detail(navigator)
}
@Composable
fun Detail(navigator: Navigator) {
Button(
onClick = { navigator.pop() }
) {
Text("Back")
}
}
After
val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("No Navigator provided")
}
@Composable
fun ParentScreen() {
CompositionLocalProvider(LocalNavigator provides navigator) {
Root()
}
}
@Composable
fun Root() {
Content()
}
@Composable
fun Content() {
Detail()
}
@Composable
fun Detail() {
val navigator = LocalNavigator.current
Button(
onClick = { navigator.pop() }
) {
Text("Back")
}
}
Now every intermediate composable disappears from the dependency chain.
The trade-off is that dependencies become implicit, so reserve CompositionLocal for values that are truly shared across large portions of the UI.
3. Aggregate State and Events
Prop drilling isn’t only about state.
Callbacks often become an even bigger problem.
Before
Child(
userName = state.userName,
isLoading = state.isLoading,
onRefresh = viewModel::refresh,
onRetry = viewModel::retry,
onDelete = viewModel::delete,
onRename = viewModel::rename,
onLogout = viewModel::logout
)
Every intermediate composable forwards the same callbacks.
@Composable
fun Content(
userName: String,
isLoading: Boolean,
onRefresh: () -> Unit,
onRetry: () -> Unit,
onDelete: () -> Unit,
onRename: (String) -> Unit,
onLogout: () -> Unit
) {
Child(
userName,
isLoading,
onRefresh,
onRetry,
onDelete,
onRename,
onLogout
)
}
After
Instead of exposing multiple callbacks, expose a single event dispatcher.
sealed interface ScreenEvent {
data object Refresh : ScreenEvent
data object Retry : ScreenEvent
data object Delete : ScreenEvent
data object Logout : ScreenEvent
data class Rename(val name: String) : ScreenEvent
}
Child(
state = state,
onEvent = viewModel::onEvent
)
Button(
onClick = {
onEvent(ScreenEvent.Refresh)
}
) {
Text("Refresh")
}
The composable API becomes much smaller, and adding new user actions no longer requires changing every intermediate function.
4. Split Large ViewModels
Sometimes prop drilling is a symptom rather than the root problem.
If a single ScreenViewModel owns every piece of state, everything naturally flows from that one object.
Before
ScreenViewModel
↓
Screen
├── Toolbar
├── Content
│ ├── Tab
│ │ ├── BottomSheet
│ │ └── Dialog
Every child depends on the same ViewModel.
After
ScreenViewModel
↓
Screen
├── Toolbar
├── Content
│ ├── Tab
│ │
│ ├── BottomSheetViewModel
│ │ ↓
│ │ BottomSheet
│ │
│ └── DialogViewModel
│ ↓
│ Dialog
@Composable
fun BottomSheet() {
val viewModel: BottomSheetViewModel = viewModel()
val state by viewModel.state.collectAsState()
// ...
}
With Navigation 3 and nested navigation graphs, scoping ViewModels to smaller parts of the UI has become much easier.
The result is less shared state and significantly less prop drilling.
Which Approach Should You Choose?
| Situation | Recommended approach | |
|---|---|---|
| Layout-only composables | Slot API | |
| Shared infrastructure | CompositionLocal | |
| Too many callbacks | Aggregate events | |
| Huge screen-wide state | Split ViewModels |
Final Thoughts
Prop drilling isn’t always a sign of poor architecture. Sometimes it’s simply the result of a growing UI tree.
The important question is why the data is flowing through so many layers.
- If intermediate composables only define layout, use Slot APIs.
- If the value is shared application-wide, use CompositionLocal.
- If callbacks dominate your APIs, aggregate them into a single event stream.
- If one ViewModel owns everything, consider breaking it into smaller scopes.
These patterns aren’t mutually exclusive. In fact, the cleanest Compose codebases often combine all four, using each where it fits naturally.