Six parameters deep and none of them are used here
Correct hoisting can still produce an unmaintainable codebase. The four failure modes that show up once a screen grows past a few components, and the fixes for each.

Day 13 of 100. Days 11 and 12 covered where state should live. Today: the ways a codebase that follows both rules still becomes miserable to work in.
The symptom
A component signature that reads like a form:
@Composable
fun OrderSummaryCard(
order: Order,
currency: Currency,
user: User,
isEditable: Boolean,
isLoading: Boolean,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
onShareClick: () -> Unit,
onCurrencyChange: (Currency) -> Unit,
onRetry: () -> Unit,
)
Every parameter is correctly hoisted. The state lives at the right level. And adding one field to the card means editing five files, because those parameters are threaded through five layers of composables, most of which don't use them.
That's prop drilling, and it's the price of hoisting applied without the second half of the technique.

Why the obvious fix fails
The obvious fix is to hoist further — put everything in the ViewModel and let each component reach for it:
@Composable
fun OrderSummaryCard(vm: OrderViewModel = viewModel()) { … } // no parameters!
The signature is clean and the component is now useless. It can't be previewed without constructing a ViewModel, can't be reused on a screen backed by a different one, can't be tested without a fake, and — Day 7 — it reads state in its own scope while also being unable to tell you what it depends on.
You've traded a visible problem for an invisible one. The parameters were noisy; the hidden dependency is worse, because nothing in the signature says the component is coupled to a specific screen.
Pitfall 1 — Prop drilling
The real fix has two parts, and which one applies depends on whether the intermediate layers care about the values.
If the values travel together, pass the object. Ten parameters that are always passed as a group are one parameter wearing a disguise:
@Composable
fun OrderSummaryCard(
state: OrderCardState,
actions: OrderCardActions,
)
Grouping the callbacks into an interface is the part people skip, and it's the half
that removes most of the churn — adding an action becomes one new method on
OrderCardActions, not a new parameter in every intermediate signature.
If the intermediate layers don't care, don't pass through them at all. Use slots:
@Composable
fun OrderScreenScaffold(
header: @Composable () -> Unit,
content: @Composable () -> Unit,
)
OrderScreenScaffold now knows nothing about orders. The caller composes the header
where the state already is, and the scaffold just places it. This is why Scaffold,
Card and every container in Material take @Composable lambdas rather than data —
containers should own layout, never content.
Pitfall 2 — Over-hoisting
Yesterday's promoExpanded stayed in the composition for a reason. The general form:
data class ScreenUiState(
val items: List<Item>,
val isMenuOpen: Boolean, // why does the ViewModel know?
val scrollPosition: Int, // or this?
val isTooltipVisible: Boolean, // or this?
)
Three costs, all real. Every menu toggle emits a new state object and recomposes everything collecting it. The ViewModel's unit tests now assert on tooltip visibility. And the state class grows monotonically, because nothing is ever obviously safe to remove.
The test from Day 11 still applies: if I deleted this composable, would the value mean
anything? A closed menu means nothing. Applied honestly, it removes most of what
accumulates in UiState classes.
Pitfall 3 — Callbacks named after the widget
onButtonClick: () -> Unit
onTextChange: (String) -> Unit
onCheckboxToggle: (Boolean) -> Unit
These name the mechanism. When the button becomes a menu item, the name lies, and the parent's handler — written against "a button was clicked" — has to be re-read to find out what it actually does.
Name the intent instead:
onArchiveOrder: () -> Unit
onSearchQueryChange: (String) -> Unit
onNotificationsToggle: (Boolean) -> Unit
The parent can now be read without opening the child. This sounds like a style nit; in practice it's the difference between a screen you can reason about from its call site and one where you have to trace every lambda to find out what it does.
Pitfall 4 — Hoisting state that isn't state
var total by remember { mutableStateOf(0.0) }
LaunchedEffect(items) { total = items.sumOf { it.price } } // derived, not owned
total is a function of items. Storing it separately creates a second thing that
can be wrong, and the effect that maintains it will eventually be forgotten when a new
mutation path is added.
val total = items.sumOf { it.price } // just compute it
The rule: if you can compute it from other state, it isn't state. Recomputing on
recomposition is almost always cheaper than a LaunchedEffect plus an extra slot, and
it cannot drift. If profiling proves the computation is genuinely expensive,
remember(items) { … } caches it — still derived, still one source of truth.
The same instinct catches a related bug: a mutableStateOf initialised from a
parameter.
@Composable
fun Row(item: Item) {
var name by remember { mutableStateOf(item.name) } // never updates
}
remember runs once. When item changes, the composable shows the old name forever.
Either it's derived (use item.name directly) or it's editable local state (key the
remember: remember(item.id)), and deciding which is the point.
The fifth one: hoisting instead of extracting
A signature that has grown past about six parameters is often not a hoisting problem at all — it's one component doing two jobs.
@Composable
fun OrderSummaryCard(
order: Order, currency: Currency, onCurrencyChange: (Currency) -> Unit, // pricing
user: User, onShareClick: () -> Unit, // sharing
)
Splitting it into OrderPricing and OrderSharing gives two components with three
parameters each, both previewable in isolation, and a parent whose call site reads as a
description of the screen. No hoisting changed; the boundary did.
The signal to watch for is parameter clustering — subsets that always change together and are never used together. That's two components sharing a function body, and every technique above works better once they're separated.
How to prove it
Three checks that surface all four pitfalls quickly:
Write a @Preview. If it needs more than a few literals, the component is
over-coupled. If it needs a ViewModel, it's coupled to a screen.
Count parameters that pass straight through. Any composable forwarding a value it doesn't read is a prop-drilling layer — either group the parameters or turn that layer into a slot.
Grep your UiState for booleans. Most isSomethingOpen / isSomethingVisible
fields are UI element state that drifted upward. Each one you move back down shrinks
the recomposition surface of the whole screen.
What this generalizes to
All four pitfalls are the same failure at different scales: a component knowing more than its job requires. Prop drilling makes intermediate layers know about values they don't use. Over-hoisting makes the ViewModel know about widgets. Widget-named callbacks make the parent know about the child's implementation. Derived state makes the code know a fact it could have asked for.
React went through this exact sequence and landed on the same answers — composition over
configuration, and context for the genuinely global. Tomorrow, Day 14, is the piece that
handles what needs to survive process death: rememberSaveable and the saved-state
machinery underneath it.
Day 13 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Where to hoist state — common pitfalls.