← Back to list

Four Ways to Avoid Prop Drilling in Jetpack Compose

Prop drilling isn’t a Compose problem — it’s a design problem. Here are the four patterns I use depending on what the UI is trying to do.

chanzmao in ProAndroidDev · 2026-07-09 22:47 · 3 claps · 4.5 min read
#android #jetpack-compose #android-app-development #androiddev #software-architecture
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏛️ · Architecture

Four Ways to Avoid Prop Drilling in Jetpack Compose

Prop drilling isn’t a Compose problem — it’s a design problem. Here are the four patterns I use depending on what the UI is trying to do.

Image created by AI

Image created by AI

Every Compose project seems to reach the same point.

To illustrate the problem, let’s start with a simple composable hierarchy.

Throughout this article, ParentScreen serves as the top-level composable. It calls Root, which in turn calls Content.

ParentScreen -> Root -> Content

Each composable is just passing the same values to the next one.

@Composable
fun ParentScreen() {
    Root(
        navigator = navigator,
        user = user,
        onRefresh = viewModel::refresh,
        onDelete = viewModel::delete,
        onRetry = viewModel::retry,
    )
}

@Composable
fun Root(
    navigator: Navigator,
    user: User,
    onRefresh: () -> Unit,
    onDelete: () -> Unit,
    onRetry: () -> Unit
) {
    Content(
        navigator = navigator,
        user = user,
        onRefresh = onRefresh,
        onDelete = onDelete,
        onRetry = onRetry
    )
}

@Composable
fun Content(
    navigator: Navigator,
    user: User,
    onRefresh: () -> Unit,
    onDelete: () -> Unit,
    onRetry: () -> Unit
) {
    // finally used here
}

Eventually, you realize something.

Root does not use any of these parameters itself.

It only forwards them to Content.

That is prop drilling.

Over time, I stopped thinking of prop drilling as the problem itself. More often than not, it’s a symptom of a design decision somewhere else in the UI.

After building several Compose applications, I’ve noticed that the cause almost always falls into one of four categories — and each one calls for a different solution.

1. The composable only exists for layout

This is the situation I encounter most often.

Suppose a composable only arranges its children.

@Composable
fun Card(
    userName: String
) {
    Surface {
        UserName(userName)
    }
}

Now imagine there are three more layout composables above Card.

None of them care about userName.

They’re just moving it down the tree.

When I see this, I usually stop passing data altogether.

Instead, I pass the UI.

Before

@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)
}

After

@Composable
fun ParentScreen() {
    val userName = "Alice"

    CardLayout {
        UserName(userName)
    }
}

@Composable
fun CardLayout(
    content: @Composable () -> Unit
) {
    Card {
        content()
    }
}

Now the layout component doesn’t know anything about userName.

It simply defines where the content should appear.

Once I started thinking this way, I realized why so many Compose APIs follow the same design.

Scaffold, LazyColumn, AlertDialog, and even Button all rely on slots instead of passing data through intermediate composables.

If a composable’s responsibility is layout, Slot APIs are usually my first choice.

2. The value really belongs to the whole tree

Sometimes the opposite is true.

The value isn’t specific to one composable at all.

Things like

  • a navigator
  • analytics
  • the current theme
  • the current user session

are often needed from many different places.

Passing them through five layers just so the sixth can use them feels unnecessary.

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 =
    compositionLocalOf<Navigator> {
        error("Navigator not provided")
    }

@Composable
fun ParentScreen() {
    CompositionLocalProvider(
        LocalNavigator provides navigator
    ) {
        Root()
    }
}

@Composable
fun Detail() {
    val navigator = LocalNavigator.current

    Button(
        onClick = {
            navigator.pop()
        }
    ) {
        Text("Back")
    }
}

The intermediate composables disappear from the dependency chain entirely.

That said, I try not to reach for **CompositionLocal**too quickly.

If everything becomes a **CompositionLocal**, it’s much harder to understand where data comes from.

For me, it’s best reserved for infrastructure that’s naturally shared across the composition.

3. The real problem is callbacks

Sometimes state isn’t the issue.

Callbacks are.

I’ve worked on screens that looked something like this.

Child(
    state = state,
    onRefresh = viewModel::refresh,
    onRetry = viewModel::retry,
    onDelete = viewModel::delete,
    onRename = viewModel::rename,
    onLogout = viewModel::logout
)

Every intermediate composable forwarded the exact same callbacks.

Nothing used them until the very bottom of the tree.

Whenever I notice this happening, I usually stop adding callbacks and switch to a single event dispatcher instead.

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
}

Now the API becomes much simpler.

Child(
    state = state,
    onEvent = viewModel::onEvent
)

Inside the UI, every interaction sends an event.

Button(
    onClick = {
        onEvent(ScreenEvent.Refresh)
    }
) {
    Text("Refresh")
}

Adding another user action no longer means modifying every composable between the screen and the button.

4. Maybe the ViewModel is too big

Sometimes I try all of the above and the prop drilling still doesn’t go away.

That’s usually a clue that the ViewModel owns too much.

Consider this hierarchy.

Screen
├── Toolbar
├── Content
│   ├── Tab
│   │   ├── BottomSheet
│   │   └── Dialog

If only the BottomSheet needs a certain piece of state, why does it live in the screen’s ViewModel?

Instead, I often give that feature its own ViewModel.

@Composable
fun BottomSheet() {
    val viewModel: BottomSheetViewModel = viewModel()

    val state by viewModel.state.collectAsState()

    ...
}

This naturally shortens the data flow because the state now lives closer to where it’s actually used.

With Navigation 3, scoping ViewModels to smaller parts of the UI has become much easier, making this approach even more practical.

There isn’t a “best” solution

When I first started using Compose, I was looking for one universal answer to prop drilling.

Eventually I realized that the question itself was wrong.

Prop drilling can happen for completely different reasons.

Sometimes the composable is only providing layout.

Sometimes the data really belongs to the whole composition.

Sometimes callbacks have taken over the API.

And sometimes the ViewModel has simply become too large.

Once I started identifying why the parameters were flowing through the tree, the solution usually became obvious.

Interestingly, most real screens end up using several of these patterns together.

A screen might use Slot APIs for layout, a CompositionLocal for navigation, a single onEvent callback for user interactions, and a dedicated ViewModel for a BottomSheet.

None of these techniques replace the others.

They complement each other.

And once you stop looking for a single solution, prop drilling becomes much easier to manage.

Enjoyed this article? Here’s how you can support my work:

  • 👏 Clap (up to 50 times!) if you found this insightful.
  • 👤 Follow me and turn on notifications (🔔) so you never miss the next deep dive.
  • 📧 Subscribe to get my latest stories delivered directly to your inbox.

You can find all my articles on Android architecture, Jetpack Compose, and Kotlin in this curated reading list.


메타데이터
post_id
50a64912c0d5
slug
four-ways-to-avoid-prop-drilling-in-jetpack-compose-50a64912c0d5
url
https://proandroiddev.com/four-ways-to-avoid-prop-drilling-in-jetpack-compose-50a64912c0d5
canonical_url
https://proandroiddev.com/four-ways-to-avoid-prop-drilling-in-jetpack-compose-50a64912c0d5
author_url
https://medium.com/@chanzmao
status
ok
fetched_at
2026-07-11 11:24:59