State hoisting has a limit, and you have probably passed it
Hoisting everything to the root means every keystroke recomposes the tree.
TL;DR
"Hoist state to the lowest common ancestor" is good advice that gets applied as "hoist everything to the ViewModel". The result is a single state object at the root, so every change to any field recomposes the root's whole body.
A text field's in-progress value changes on every keystroke. If it lives in the root state object, every keystroke invalidates the root — and the list, the header and the footer all get re-executed to discover they are unchanged.
The right owner is the lowest composable that needs to coordinate on that value. Transient UI state usually coordinates with nothing, and belongs where it is used.
The case this post is about
A form screen with one hoisted state object, which is the recommended shape:
data class ProfileFormState(
val name: String = "",
val bio: String = "",
val avatarUrl: String? = null,
val suggestions: ImmutableList<Tag> = persistentListOf(),
val submitting: Boolean = false,
val bioExpanded: Boolean = false, // ← pure UI state
val searchFocused: Boolean = false, // ← pure UI state
)
@Composable
fun ProfileForm(vm: ProfileViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
Column {
AvatarPicker(state.avatarUrl, vm::onAvatarChange)
NameField(state.name, vm::onNameChange)
BioField(state.bio, state.bioExpanded, vm::onBioChange, vm::onBioToggle)
TagSuggestions(state.suggestions, vm::onTagSelect) // 40 chips
SubmitButton(state.submitting, vm::onSubmit)
}
}
Everything hoisted, single source of truth, unidirectional data flow. Textbook, and typing in the name field drops frames.
The symptom
Layout Inspector, type 20 characters into NameField
composable recompositions skips
ProfileForm 20 0
AvatarPicker 20 0 ← avatar did not change
NameField 20 0
BioField 20 0 ← bio did not change
TagSuggestions 20 0
TagChip 800 0 ← 40 chips × 20 keystrokes
SubmitButton 20 0
Every component re-executed on every keystroke, and 800 chip recompositions for a name change.
Frame timing:
type 20 characters
frame P50 9.2ms
frame P95 52.4ms
And a second symptom that pointed at the design rather than at performance: collapsing the bio field — a purely visual toggle — went through the ViewModel, so it appeared in the state object, in the test fixtures, and in the saved-state handle. A chevron's rotation had become part of the screen's domain model.
Why the obvious fix failed
"Make the state object stable." It already was — ImmutableList, all vals. Stability lets
Compose skip a composable whose parameters are unchanged; here the state object genuinely changed
on every keystroke, so there was nothing to skip.
"Pass individual fields instead of the whole object." This is a real improvement and we did it:
NameField(name = state.name, onChange = vm::onNameChange)
It fixes the children — AvatarPicker now receives an unchanged String? and skips. It does not
fix ProfileForm itself, which still reads state and still re-executes its whole body on every
keystroke. The counts went from 20 everywhere to 20 for ProfileForm and 0 for most children,
which is 80% of the win and not all of it.
"Wrap each section in its own composable." Already were. The re-execution is of the parent's body, which calls them.
"derivedStateOf on each field." The source changes as often as the result for name, so it
narrows nothing. Wrong tool.
The actual mechanism
Two separate costs, and hoisting trades one for the other.
Hoisting buys coordination. State in a common ancestor can be read by several children, which is what makes a form submit, a filter apply, or two panels stay in sync. This is the real and correct reason for the advice.
Hoisting costs invalidation scope. A composable that reads a State is invalidated when it
changes. Hoist a high-frequency value to the root, and the root — and therefore its whole body —
re-executes at that frequency.
There is a third option that the hoist-or-don't framing hides, and it resolves most of the hard cases: hoist the event, keep the value. A text field can own its own editing state and still report the result, which gives coordination without paying per-keystroke invalidation at the ancestor:
// the field owns the keystrokes; the form learns only what it needs to coordinate on
@Composable
fun NameField(initial: String, onCommit: (String) -> Unit) {
var text by remember(initial) { mutableStateOf(initial) }
TextField(
value = text,
onValueChange = { text = it }, // invalidates THIS composable only
modifier = Modifier.onFocusChanged { if (!it.isFocused) onCommit(text) },
)
}
The cost of getting this wrong in the other direction is worth naming, because it is the one that
looks most like good architecture: hoisting text straight into a ViewModel means every keystroke
makes a round trip out to a StateFlow and back. On a fast typist that round trip can land out of
order with the field's own internal state, and the symptom is dropped or transposed characters in
a field that works perfectly when you test it by typing slowly.
So the question for every piece of state is: who needs to coordinate on it?
name and bio are submitted together and validated together, so they coordinate at the form
level. They belong hoisted, and their high change frequency is a cost worth paying — mitigated by
passing fields rather than the object.
bioExpanded and searchFocused coordinate with nothing. No sibling reads them, nothing
submits them, they do not survive process death in any meaningful sense. Hoisting them buys zero
coordination and costs invalidation of the root.

Hoist to the lowest composable that needs to coordinate, which is often not the ViewModel. Transient UI state — expanded, focused, hovered, scroll position, a dialog's visibility — usually coordinates with nothing.
The fix
Three changes: push transient state down, pass fields not objects, and defer the highest-frequency read.
1. Transient UI state lives where it is used:
@Composable
fun BioField(bio: String, onChange: (String) -> Unit) {
// Coordinates with nothing. Owned here.
var expanded by remember { mutableStateOf(false) }
Column {
TextField(bio, onChange, maxLines = if (expanded) 10 else 2)
IconButton({ expanded = !expanded }) { Chevron(expanded) }
}
}
data class ProfileFormState(
val name: String = "",
val bio: String = "",
val avatarUrl: String? = null,
val suggestions: ImmutableList<Tag> = persistentListOf(),
val submitting: Boolean = false,
// bioExpanded and searchFocused removed — they were never domain state
)
The state object is now a description of the form, not of the UI's current appearance. That is the design improvement, and the performance improvement follows from it.
2. Pass fields, so children can skip:
@Composable
fun ProfileForm(vm: ProfileViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
Column {
AvatarPicker(state.avatarUrl, vm::onAvatarChange)
NameField(state.name, vm::onNameChange)
BioField(state.bio, vm::onBioChange)
TagSuggestions(state.suggestions, vm::onTagSelect)
SubmitButton(state.submitting, vm::onSubmit)
}
}
3. And defer the highest-frequency read into the field that owns it. This is what finally stops
ProfileForm recomposing per keystroke:
@Composable
fun ProfileForm(vm: ProfileViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
Column {
AvatarPicker(state.avatarUrl, vm::onAvatarChange)
// the lambda defers the read — ProfileForm is not subscribed to `name`
NameField(name = { vm.state.value.name }, onChange = vm::onNameChange)
…
}
}
@Composable
fun NameField(name: () -> String, onChange: (String) -> Unit) {
TextField(value = name(), onValueChange = onChange) // the read happens HERE
}
A lambda parameter is a deliberate trade: it is less idiomatic and harder to read, so it is worth doing only where measurement says it matters — which here is the single highest-frequency field, not all five.
The decision rule, written down for the team:
Where does this state live?
Does a sibling read it? → hoist to their common ancestor
Does submitting or validating depend on it? → hoist to the ViewModel
Must it survive process death? → ViewModel + SavedStateHandle
Otherwise (expanded, focused, hovered,
dialog visibility, local scroll) → remember, where it is used
How we proved it
Recomposition counts after each step, which is what showed that the three changes are not interchangeable:
type 20 characters into NameField
composable original +fields +transient down +deferred read
ProfileForm 20 20 20 1
AvatarPicker 20 0 0 0
BioField 20 20 0 0
TagSuggestions 20 0 0 0
TagChip 800 0 0 0
Reading that table left to right is the post: passing fields fixed the siblings, moving transient
state down fixed BioField, and only the deferred read fixed ProfileForm itself.
Frame timing:
original final
frame P50 9.2ms 6.1ms
frame P95 52.4ms 7.8ms
jank frames 6.8% 0.1%
And the design effect, which I value more than the frames:
ProfileFormState fields: 7 → 5
test fixtures mentioning
bioExpanded/searchFocused: 14 → 0
ViewModel public methods: 9 → 7
Two UI toggles were appearing in fourteen test fixtures. Removing them from the domain state made the ViewModel's tests about the form rather than about the chevron.
A regression guard, since the deferred-read trick is the most likely thing to be "cleaned up":
@Test
fun typingDoesNotRecomposeTheWholeForm() {
var formRecompositions = 0
composeTestRule.setContent {
SideEffect { formRecompositions++ }
ProfileForm(vm)
}
repeat(20) { vm.onNameChange("a".repeat(it)) }
composeTestRule.waitForIdle()
assertTrue(formRecompositions <= 2, "form recomposed $formRecompositions times")
}
One limit: the lambda-parameter pattern costs readability, and I would not apply it to all five fields — the measured win came almost entirely from the one field being typed into. The general lesson is the ownership rule; the deferred read is a targeted optimisation applied where a measurement pointed.
What this generalizes to
"Single source of truth" is about coordination, not location. The principle says two components must not disagree. It does not say every value lives at the root. Applying it as a location rule produces a root that changes constantly.
Ask who needs to coordinate. For every piece of state: which components read it, and does anything outside the component care? Most transient UI state answers "nothing", and that answer is the placement.
Scope the invalidation to the frequency. High-frequency values want narrow scope; low-frequency values can live wide. A keystroke-rate value at the root of a tree is the pattern to look for in any reactive system.
A performance fix that simplifies the domain model is the good kind. Removing bioExpanded from
the state object made the screen faster and made the ViewModel's tests about the form. When an
optimisation also shrinks a model, it was a design problem wearing a performance costume.
Related
- derivedStateOf exists because you were recomposing on every keystroke — narrowing by result instead of by placement
- Recomposition is not re-rendering, and that is why your list stutters — why skipping needs stable parameters
- StateFlow drops what you needed and keeps what you didn't — what belongs in a state object
Keep reading
This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".