State hoisting

Jetpack Compose Junior Kotlin 2.0+ API 24+ Reviewed By Best Latest Apps

Pick the fix

You want to preview and reuse this counter in two screens. What's the best change?

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("Count: $count") }
}
Options
Hint 1 of 1

Who should own the count?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A Make it take count: Int and onIncrement: () -> Unit and move the state to the caller

Correct. That's state hoisting: the counter becomes stateless and reusable.

Why not the others?
  • B Pass the screen's ViewModel into it. That ties the counter to one ViewModel type and breaks previews.
  • C Make count a global variable. Global state is shared by every caller and isn't observable.
  • D Switch to rememberSaveable. That changes how long the state lives, not who owns it.

30-second answer

State hoisting means moving state out of a composable to its caller, passing the value down and events up: value and onValueChange. The composable becomes stateless, so it's reusable, easy to preview and test, and the state has a single owner. Hoist state to the lowest common parent that needs to read or change it.

Practise saying it · 30 s. No recording, just a timer

Explain state hoisting.

Say your answer out loud, then check which of these points you covered:

  • Value down, events up
  • Stateless composables are reusable and previewable
  • Hoist to the lowest common owner
  • Pass lambdas, not ViewModels

Deep dive

Screen-level state usually lives in a ViewModel; small UI-element state can live in a plain state holder class.

Stateless composables take only data and lambdas, so a preview can pass any value.

Hoisting too high makes more of the tree recompose and couples unrelated parts.

Pass lambdas, not the whole ViewModel, into child composables.

A hoisted counter Compile-checked · Kotlin 2.4.21
@Composable
fun Counter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
    Button(onClick = onIncrement, modifier = modifier) { Text("Count: $count") }
}

Common mistakes

  • Passing the ViewModel deep into child composables.
  • Keeping the same state in two places and syncing them.
  • Hoisting everything to the screen root.

Senior take & follow-ups

Name the pattern (value down, events up) and the rule (lowest common owner).

Follow-up questions

Think of your answer first, then open the hint.

  1. Where does scroll position state usually live?

    Show hint

    In a remembered LazyListState, hoisted only if someone else needs it.

  2. Why not pass the ViewModel to children?

    Show hint

    It ties them to one screen, breaks previews and makes testing harder.

Similar on iOS: @Binding and the $ prefix SwiftUI passes a Binding; Compose passes a value and a lambda.

Suggest a fix if something here is wrong or out of date.