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.
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
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