On this page
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.
@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") }
}
}@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")
}
}
}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 to | Example |
|---|---|---|
| One component only | Keep it inside (stateful is fine) | Ripple, internal animation |
| Siblings on one screen | Their parent composable or a state holder | Search field + clear button + results list |
| Business logic or other screens | ViewModel | Cart quantities affecting price and checkout |
Offer both: stateless core, stateful convenience
@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
@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
MutableStateobject 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.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
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.