Chanzmao ʕ•ᴥ•ʔ Bear Blog

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:

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.

These patterns aren’t mutually exclusive. In fact, the cleanest Compose codebases often combine all four, using each where it fits naturally.

#Android #Jetpack Compose #Kotlin