Writing
State Hoisting Has a Ceiling
I recently opened a file in a codebase I’d inherited and found this:
@Composable
fun ProfileScreen(
name: String,
onNameChange: (String) -> Unit,
email: String,
onEmailChange: (String) -> Unit,
bio: String,
onBioChange: (String) -> Unit,
isEditing: Boolean,
onEditToggle: () -> Unit,
expandedSection: ProfileSection?,
onSectionExpand: (ProfileSection) -> Unit,
avatarMenuVisible: Boolean,
onAvatarMenuToggle: () -> Unit,
scrollOffset: Int,
onScrollOffsetChange: (Int) -> Unit,
)
Fourteen parameters. Whoever wrote this wasn’t being sloppy. Each pair was added by someone asking “should this state be hoisted?” and answering with the only rule anyone seems to remember: hoist it.
To be fair to the docs, they don’t actually say that. The official state-hoisting page has a section titled “No state hoisting needed,” and it says exactly what you’d hope: state can stay internal to a composable when nothing else needs to control it. But that paragraph isn’t what survives contact with a team. What survives is the slogan — stateless composables are better, hoisting makes things testable — repeated in code reviews until it becomes a reflex with only one direction. Follow a rule that only knows “up” for long enough and you get the signature above: a composable that has leaked its entire internal anatomy into its API.
This article is about finding the ceiling: the point where hoisting stops paying rent, and what to reach for instead.
First, the case for hoisting (because it’s a good one)
I don’t want to strawman this. Hoisting earned its place as the default advice, and the canonical example still holds up:
@Composable
fun NameField(
name: String,
onNameChange: (String) -> Unit,
) {
TextField(value = name, onValueChange = onNameChange)
}
By pulling name out, this composable becomes stateless. You can preview it with any value. You can test it without faking user input. Two screens can reuse it. And most importantly, there’s exactly one source of truth: the caller. name can’t drift out of sync with whatever the caller does with it (validation, saving, syncing to a server).
Notice what problem hoisting actually solved there: ownership ambiguity. The caller genuinely cares about the name. It validates it, submits it, maybe pre-fills it. When two parties care about a value, lifting it to the one that makes decisions is exactly right.
The rule everyone remembers
The official rule of thumb goes like this: hoist state to the lowest common ancestor of everything that reads it, at least as high as the highest thing that writes it.
Read that carefully. It’s a placement rule. It tells you where state goes once you’ve already decided the state is shared. The docs do address the prior question — that “No state hoisting needed” section again — but a quiet paragraph is no match for a memorable rule. In practice, the placement rule is the one that makes it into review comments, and it gets applied with the sharing question never asked. isDropdownExpanded? Hoist it. avatarMenuVisible? Hoist it. The scroll offset of an internal list? Sure, hoist it — testability, right?
That reflex is how you get fourteen parameters.
Four signs you’ve hit the ceiling
1. The caller can’t answer questions about the state
This is my favorite test because it takes about five seconds. Look at a hoisted parameter and ask: does the parent have any opinion about this value?
// In ProfileRoute:
var avatarMenuVisible by remember { mutableStateOf(false) }
ProfileScreen(
avatarMenuVisible = avatarMenuVisible,
onAvatarMenuToggle = { avatarMenuVisible = !avatarMenuVisible },
// ...
)
Does ProfileRoute ever read avatarMenuVisible for its own purposes? Does it ever set it for a reason of its own? No. It stores the value and flips it when told to flip it — a landlord holding a box it’s not allowed to open.
2. Callback soup
Closely related: count how many of your lambdas do literally nothing but write the value back.
onNameChange = { name = it },
onEmailChange = { email = it },
onBioChange = { bio = it },
onEditToggle = { isEditing = !isEditing },
If the callback contained validation, an analytics event, or a save call, the hoist would be earning its keep. When every callback is a bare assignment, you’ve built a telephone line that only ever transmits “okay.”
3. Pass-through layers
The state lives three levels up, and the levels in between just… carry it.
@Composable
fun ProfileScreen(expandedSection: ProfileSection?, onExpand: (ProfileSection) -> Unit) {
ProfileContent(expandedSection, onExpand) // doesn't read it
}
@Composable
fun ProfileContent(expandedSection: ProfileSection?, onExpand: (ProfileSection) -> Unit) {
SectionList(expandedSection, onExpand) // doesn't read it either
}
Every intermediate composable now has a wider API for state it ignores, and every refactor of that state touches three files. Prop-drilling is a smell in every UI framework ever invented; Compose didn’t get an exemption.
4. The signature is the screen
When the parameter list reads like a full inventory of everything on the screen, the abstraction has inverted. A composable’s parameters should describe what the caller needs to configure, not everything the composable contains. The ProfileScreen at the top of this article isn’t an API — it’s an X-ray.
The wrong fix: shoving it all in the ViewModel
I’ve made this move myself. The moment the pain shows up, everything migrates into the ViewModel: avatarMenuVisible becomes a StateFlow, the dropdown gets an event in a sealed class, and dismissing a menu now goes through a state holder that outlives the screen and sits behind a layer of viewModelScope ceremony.
This is the same mistake at a higher altitude. The ViewModel has no more opinion about avatarMenuVisible than ProfileRoute did; you’ve hoisted to an owner that still doesn’t care, just one with a longer lifecycle. And survival is a separate question with a cheaper answer: a value that should live through rotation can use rememberSaveable without ever leaving the composition. So ask the question directly. When the user rotates the phone, should the avatar menu reopen? Obviously not. Nothing about that value justifies a ViewModel.
The distinction that matters is the one the docs name screen UI state vs. UI element state:
- Screen UI state is what your app is about: the user’s profile data, whether the save succeeded, loading and error states. Business logic produces it, and it belongs in a ViewModel.
- UI element state is how the screen currently looks: expansion, focus, scroll position, which menu is open. No business logic ever touches it. Some of it is worth keeping across rotation (
rememberSaveableexists for exactly that); none of it needs a ViewModel.
Compose itself draws this line. rememberLazyListState() keeps scroll position out of your ViewModel, and it’s built on rememberSaveable under the hood, so the position survives rotation anyway. UI element state can be worth saving without ever becoming the ViewModel’s business.
Past the ceiling: plain state holders
When several pieces of UI element state travel together, the answer isn’t fourteen parameters and it isn’t a ViewModel. It’s a boring class:
@Stable
class ProfileUiState(
isEditing: Boolean = false,
expandedSection: ProfileSection? = null,
) {
var isEditing by mutableStateOf(isEditing)
private set
var expandedSection by mutableStateOf(expandedSection)
private set
var avatarMenuVisible by mutableStateOf(false)
private set
fun toggleEdit() {
isEditing = !isEditing
if (!isEditing) expandedSection = null // rules live WITH the state
}
fun toggleSection(section: ProfileSection) {
expandedSection = section.takeUnless { it == expandedSection }
}
fun toggleAvatarMenu() { avatarMenuVisible = !avatarMenuVisible }
companion object {
val Saver = listSaver<ProfileUiState, Any?>(
save = { listOf(it.isEditing, it.expandedSection?.name) },
restore = {
ProfileUiState(
isEditing = it[0] as Boolean,
expandedSection = (it[1] as String?)?.let(ProfileSection::valueOf),
)
},
)
}
}
@Composable
fun rememberProfileUiState() =
rememberSaveable(saver = ProfileUiState.Saver) { ProfileUiState() }
This is a documented pattern, by the way: the docs call these “plain state holder classes,” and LazyListState is their worked example. It’s just nobody’s slogan.
Look at what happened in toggleEdit. The rule “collapsing edit mode also collapses sections” now lives next to the state it governs, not scattered across callbacks in a parent that shouldn’t care. And look at what the Saver skips: this is where you decide, value by value, what deserves to survive recreation. Edit mode comes back after a rotation. An open avatar menu doesn’t, and nobody expects it to.
The class tests without a composition, without Robolectric, without anything:
@Test
fun `exiting edit mode collapses sections`() {
val state = ProfileUiState()
state.toggleEdit()
state.toggleSection(ProfileSection.Contact)
state.toggleEdit()
assertNull(state.expandedSection)
}
People hoist “for testability” and end up with fourteen parameters that are miserable to construct in tests. The state holder delivers the testability hoisting only promised.
The third option: slots
When you catch yourself adding parameters so a caller can customize what’s inside your composable, stop configuring and start accepting content:
// Before: configuration creep
@Composable
fun SectionCard(
title: String,
subtitle: String?,
showDivider: Boolean,
trailingIcon: ImageVector?,
onTrailingClick: (() -> Unit)?,
// ...it never ends
)
// After: a slot
@Composable
fun SectionCard(
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Card {
Column {
Text(title, style = MaterialTheme.typography.titleMedium)
content()
}
}
}
The caller supplies composables instead of flags describing composables. Any state that content needs stays at the call site, where it was probably born anyway. This is how Material itself is built: Scaffold, TopAppBar, and friends are mostly slots, which is why their parameter lists stay sane.
Slots have a cost. The caller does more work, and you give up control over what goes in the hole. But when the alternative is a boolean for every possible variation, that’s a trade you take.
The five-second test
For any piece of state, in order:
- Does anyone besides this composable read or write it? No → keep it local:
rememberif it’s disposable,rememberSaveableif it should come back after rotation. You’re done. - Does business logic read or produce it? Yes → ViewModel (screen UI state).
- Is it several UI values that change together, or with rules between them? Yes → plain state holder, with a
Saverfor the parts worth keeping. - Are you hoisting it just so a caller can customize your internals? → Slot instead.
- Would the callback be a bare assignment? → You’re hoisting on reflex. Go back to 1.
Run the opening monstrosity through this and it collapses to:
@Composable
fun ProfileScreen(
profile: ProfileData, // screen UI state, owned by the ViewModel
onProfileChange: (ProfileData) -> Unit, // validation lives with the owner
onSave: () -> Unit,
uiState: ProfileUiState = rememberProfileUiState(),
headerActions: @Composable RowScope.() -> Unit = {},
)
The name, email, and bio fields didn’t disappear. They pass the ownership test (the ViewModel validates, saves, and pre-fills them), so they stay hoisted, but as one draft value with one callback instead of six parameters. Fourteen parameters became five. Nothing got less testable: the UI state tests as a plain class, the profile logic tests through the ViewModel, and the composable itself has a signature you can hold in your head.
What hoisting was standing in for
State hoisting was never the principle. It was one application of the principle: state should live where it’s decided, not where it’s displayed.
A value the caller makes decisions about gets hoisted. A value business logic decides belongs in the ViewModel. When the only rules are between a handful of UI values, a state holder keeps them and their rules in one place. And when there are no decisions to make at all, the kindest thing you can do for your future self is leave the state exactly where it is.
“Hoist your state” endures because it fits on a slide. The full guidance never fit, and the codebases show it. The real skill is noticing the moment the slide stops applying. In my experience, that moment arrives somewhere around parameter number six.