
Jetpack Compose made UI development on Android faster and more expressive — but it also made state management mistakes more visible and more expensive.
If you’ve ever asked yourself:
- “Why is this recomposing so much?”
- “Why did this small change trigger half of the screen?”
- “Why does my UI feel slower after migrating to Compose?”
There’s a good chance the problem is incorrect state hoisting.
In this article, I’ll walk through what state hoisting really means in practice, the most common mistakes I see in real projects, and how to structure state to keep your Compose UI fast and predictable.
What State Hoisting Actually Means
State hoisting is often summarized as:
“Move state up to the caller.”
That’s correct — but incomplete.
A better definition is:
State should live in the lowest possible level that still owns the business decision.
Too low → duplicated logic
Too high → unnecessary recompositions
Compose doesn’t punish you for holding state — it punishes you for holding it in the wrong place.
Mistake #1: Keeping State Inside Reusable Composables
@Composable
fun EmailInput() {
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it }
)
}
This looks harmless, but it creates hidden state.
Why this is a problem
- The parent can’t control the value
- Testing becomes harder
- Reuse becomes limited
- You lose predictability
The correct approach
@Composable
fun EmailInput(
value: String,
onValueChange: (String) -> Unit
) {
TextField(
value = value,
onValueChange = onValueChange
)
}
Now:
- The composable is stateless
- State ownership is explicit
- Recomposition is easier to reason about
Mistake #2: Hoisting State Too High
This is the other extreme.
@Composable
fun LoginScreen() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isPasswordVisible by remember { mutableStateOf(false) }
Column {
EmailInput(email, onValueChange = { email = it })
PasswordInput(
password,
isPasswordVisible,
onToggleVisibility = {
isPasswordVisible = !isPasswordVisible
}
)
}
}
What’s wrong here?
Toggling password visibility now recomposes the entire screen, including components that don’t care about that change.
Better approach
Hoist business state, not pure UI behavior.
@Composable
fun PasswordInput(
password: String,
onPasswordChange: (String) -> Unit
) {
var isPasswordVisible by remember { mutableStateOf(false) }
TextField(
value = password,
onValueChange = onPasswordChange,
visualTransformation = if (isPasswordVisible)
VisualTransformation.None
else
PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = {
isPasswordVisible = !isPasswordVisible
}) {
Icon(...)
}
}
)
}
Local UI behavior stays local.
Screen-level state stays meaningful.
Mistake #3: Unstable Lambdas and Accidental Recomposition
This one is subtle and very common.
items(users) { user ->
UserItem(
user = user,
onClick = { viewModel.onUserClick(user.id) }
)
}
Each recomposition creates a new lambda instance.
Why this matters
Compose uses referential equality to decide recomposition.
New lambda = potential recomposition.
Safer pattern
val onUserClick: (String) -> Unit = remember {
{ id -> viewModel.onUserClick(id) }
}
items(users) { user ->
UserItem(
user = user,
onClick = { onUserClick(user.id) }
)
}
This becomes even more important in lists, complex screens, and animations.
Mistake #4: Ignoring Stability Annotations
Compose optimizes aggressively — if you help it.
If your state objects are unstable, Compose assumes they might change at any time.
Use @Immutable wisely
@Immutable
data class UserUiModel(
val id: String,
val name: String,
val avatarUrl: String
)
This tells Compose:
“If the reference didn’t change, the content didn’t change.”
The result:
- Fewer recompositions
- Better scroll performance
- Cleaner mental model
A Practical Rule of Thumb
When deciding where state should live, ask:
- Is this business state?
→ ViewModel / Screen level - Is this UI-only behavior?
→ Local composable - Does this state affect multiple siblings?
→ Hoist just enough — no more
Compose rewards intentional architecture, not extremes.
Conclusion
State hoisting is not a rule — it’s a design decision.
Most performance issues I see in Compose apps don’t come from the framework, but from:
- Over-hoisting
- Hidden state
- Unstable references
- Fear of local state
Get state ownership right, and Compose becomes:
- Predictable
- Performant
- A joy to work with
State Hoisting in Jetpack Compose: Common Mistakes That Kill Performance was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments