skip to Main Content

State Hoisting in Jetpack Compose: Common Mistakes That Kill Performance

January 22, 20264 minute read

  

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:

  1. Is this business state?
    → ViewModel / Screen level
  2. Is this UI-only behavior?
    → Local composable
  3. 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.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top