๐ŸŽจ Jetpack Compose

State Hoisting Explained with Examples

State hoisting is the pattern that makes Compose components reusable, previewable and testable: move state out of a composable and pass it in with an event callback. Here's how to do it, how far to hoist, and when not to.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. What state hoisting is
  2. Why it pays off
  3. How high should you hoist?
  4. Offer both: stateless core, stateful convenience
  5. Screen-level hoisting with a ViewModel
  6. Real-world cases
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

You build a quantity selector with its own internal count. Later, the cart total needs that count, a "reset" button needs to change it, and a test needs to start it at 5. None of that is possible while the state is locked inside. State hoisting solves it.

What state hoisting is

To hoist state, replace a composable's internal state with two parameters: the current value, and an event lambda requesting a change. The composable becomes stateless, and the caller owns the state.

Before: stateful (hard to reuse)Kotlin
@Composable
fun QuantitySelector() {
    var qty by remember { mutableIntStateOf(1) }
    Row(verticalAlignment = Alignment.CenterVertically) {
        IconButton(onClick = { if (qty > 1) qty-- }) { Icon(Icons.Default.Remove, "Decrease") }
        Text("$qty")
        IconButton(onClick = { qty++ }) { Icon(Icons.Default.Add, "Increase") }
    }
}
After: stateless (hoisted)Kotlin
@Composable
fun QuantitySelector(
    quantity: Int,
    onQuantityChange: (Int) -> Unit,
    modifier: Modifier = Modifier,
    range: IntRange = 1..99,
) {
    Row(modifier, verticalAlignment = Alignment.CenterVertically) {
        IconButton(onClick = { onQuantityChange(quantity - 1) }, enabled = quantity > range.first) {
            Icon(Icons.Default.Remove, contentDescription = "Decrease quantity")
        }
        Text("$quantity", Modifier.widthIn(min = 32.dp), textAlign = TextAlign.Center)
        IconButton(onClick = { onQuantityChange(quantity + 1) }, enabled = quantity < range.last) {
            Icon(Icons.Default.Add, contentDescription = "Increase quantity")
        }
    }
}
State hoisting: value down, events up The parent CartItemRow owns quantity state. It passes the quantity value down to QuantitySelector, which sends onQuantityChange events back up. The parent updates its state, or forwards the event to the ViewModel. Parent (state owner)quantity in ViewModel/state QuantitySelectorstateless quantity = 3 onQuantityChange(4)
Hoisting creates unidirectional data flow at the component level, the same pattern as ViewModel and screen.

Why it pays off

  • Single source of truth: the cart total and the selector can't disagree.
  • Reusable: the same selector works in the cart, product page and wishlist.
  • Previewable: preview any quantity or disabled state directly.
  • Testable: UI tests set values and verify the events emitted.
  • Interceptable: the parent can validate, clamp or reject changes.

How high should you hoist?

Hoist to the lowest common ancestor of all the composables that read or write the state, and no higher. Three practical levels:

State used byโ€ฆHoist toExample
One component onlyKeep it inside (stateful is fine)Ripple, internal animation
Siblings on one screenTheir parent composable or a state holderSearch field + clear button + results list
Business logic or other screensViewModelCart quantities affecting price and checkout

Offer both: stateless core, stateful convenience

Pattern used by Material componentsKotlin
@Composable
fun RatingBar(rating: Int, onRatingChange: (Int) -> Unit, modifier: Modifier = Modifier) {
    Row(modifier) {
        (1..5).forEach { star ->
            IconToggleButton(checked = star <= rating, onCheckedChange = { onRatingChange(star) }) {
                Icon(if (star <= rating) Icons.Filled.Star else Icons.Outlined.StarOutline,
                    contentDescription = "$star stars")
            }
        }
    }
}

// Convenience overload for simple use cases
@Composable
fun RatingBar(modifier: Modifier = Modifier, initial: Int = 0) {
    var rating by rememberSaveable { mutableIntStateOf(initial) }
    RatingBar(rating, { rating = it }, modifier)
}

Screen-level hoisting with a ViewModel

Route + Screen splitKotlin
@Composable
fun CartRoute(viewModel: CartViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    CartScreen(state = state, onQuantityChange = viewModel::setQuantity, onCheckout = viewModel::checkout)
}

@Composable
fun CartScreen(state: CartUiState, onQuantityChange: (String, Int) -> Unit, onCheckout: () -> Unit) {
    LazyColumn {
        items(state.items, key = { it.id }) { item ->
            CartItemRow(item, onQuantityChange = { qty -> onQuantityChange(item.id, qty) })
        }
        item { Button(onClick = onCheckout, enabled = state.canCheckout) { Text("Checkout") } }
    }
}

The Route composable connects to the ViewModel. The Screen is fully stateless, so previews and UI tests can drive it with any CartUiState.

Real-world cases

Form fields

Every field takes value and onValueChange, and the form state lives in the ViewModel, which validates as the user types.

Tabs and pager

The selected tab is hoisted to the screen so the toolbar title and content stay in sync.

Design system

All library components are stateless, with optional stateful wrappers.

Common mistakes

  • Hoisting everything to the ViewModel, including purely visual state such as tooltip visibility.
  • Passing a MutableState object down instead of a value and lambda, which lets children mutate state invisibly.
  • Passing the ViewModel into reusable components.
  • Keeping a local copy of hoisted state, creating two sources of truth.

Frequently asked questions

Doesn't hoisting create too many lambdas?

It creates some, and that's fine. Group related events into a small interface or data class if a component takes many of them.

Is hoisting the same as unidirectional data flow?

Hoisting is how you implement UDF in Compose: state flows down as parameters and events flow up as lambdas.

Should TextField state be hoisted?

Usually yes, to the screen or ViewModel, so validation and submission can read it. Compose also offers a TextFieldState-based API for more advanced text editing.

Conclusion

State hoisting turns components into stateless functions of their inputs: value down, events up, hoisted to the lowest common owner. It's the single habit that most improves reuse, previews and tests in Compose.

Next step: move between screens with Navigation in Jetpack Compose.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Jetpack Compose clicks once you build real screens with real state. Practise modern Android UI development on projects that go beyond toy examples.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp