Android with Kotlin and Jetpack Compose · Compose Foundations: Screens, Layout, Lists and State
State, State Hoisting and Side Effects
Where state should live in Compose: remember and rememberSaveable, state hoisting into stateless composables, derived state, and running side effects safely with LaunchedEffect.
What you'll build
A checkout form with email and quantity fields, a total that updates as you type, a "Place order" button that is enabled only when the form is valid, and a one-time message when the order succeeds.
The idea
State is any value that can change over time and that the UI shows. Compose has a few rules:
- State that should survive recomposition goes in
remember { mutableStateOf(...) }; userememberSaveablefor user input that should survive rotation. - State hoisting: a reusable composable shouldn't own its state. It takes the value and an event callback (
value,onValueChange) and the caller owns the state. Stateless composables are easier to preview, test and reuse. - Unidirectional data flow: state flows down as parameters, events flow up as callbacks.
- Values computed from state (
total,isValid) are just calculations; usederivedStateOfonly when a frequently changing value (such as the scroll position) feeds a rarely changing result. - Side effects (showing a snackbar, logging, starting a timer) go in effect handlers such as
LaunchedEffect(key), which runs when it first composes or its key changes and is cancelled when it leaves.
The code
data class CheckoutForm(val email: String = "", val quantity: Int = 1) {
val isValid get() = "@" in email && quantity in 1..10
fun total(unitPrice: Double) = unitPrice * quantity
}
val CheckoutFormSaver = Saver<CheckoutForm, List<Any>>( // how rememberSaveable stores the form in the bundle
save = { listOf(it.email, it.quantity) },
restore = { CheckoutForm(it[0] as String, it[1] as Int) },
)
@Composable
fun CheckoutScreen(unitPrice: Double, onPlaceOrder: (CheckoutForm) -> Unit) {
var form by rememberSaveable(stateSaver = CheckoutFormSaver) { mutableStateOf(CheckoutForm()) }
CheckoutContent(
form = form,
total = form.total(unitPrice),
onEmailChange = { form = form.copy(email = it.trim()) },
onQuantityChange = { form = form.copy(quantity = it) },
onSubmit = { onPlaceOrder(form) },
)
}
@Composable
fun CheckoutContent( // stateless: easy to preview and test
form: CheckoutForm, total: Double,
onEmailChange: (String) -> Unit, onQuantityChange: (Int) -> Unit, onSubmit: () -> Unit,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(form.email, onEmailChange, label = { Text("Email") }, singleLine = true)
QuantityStepper(form.quantity, onQuantityChange)
Text("Total: %.2f EUR".format(total))
Button(onClick = onSubmit, enabled = form.isValid) { Text("Place order") }
}
}
@Composable
fun QuantityStepper(quantity: Int, onChange: (Int) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { onChange(quantity - 1) }, enabled = quantity > 1) { Text("-") }
Text("$quantity", Modifier.semantics { contentDescription = "Quantity $quantity" })
TextButton(onClick = { onChange(quantity + 1) }) { Text("+") }
}
}
@Composable
fun OrderPlacedMessage(orderId: String?, snackbar: SnackbarHostState) {
LaunchedEffect(orderId) { // runs again only when orderId changes
if (orderId != null) snackbar.showSnackbar("Order $orderId placed")
}
}
Try the logic
Because the form state is an immutable data class and every event is a copy, the whole flow runs as plain Kotlin:
data class CheckoutForm(val email: String = "", val quantity: Int = 1) {
val isValid get() = "@" in email && quantity in 1..10
fun total(unitPrice: Double) = unitPrice * quantity
}
fun main() {
val unitPrice = 12.5
var form = CheckoutForm()
fun render(event: String) =
println("%-22s email='%s' qty=%d total=%.2f button=%s".format(
event, form.email, form.quantity, form.total(unitPrice), if (form.isValid) "enabled" else "disabled"))
render("initial")
form = form.copy(email = "[email protected] ".trim()); render("onEmailChange")
form = form.copy(quantity = 3); render("onQuantityChange(3)")
form = form.copy(quantity = 12); render("onQuantityChange(12)")
}
Output
initial email='' qty=1 total=12.50 button=disabled onEmailChange email='[email protected]' qty=1 total=12.50 button=enabled onQuantityChange(3) email='[email protected]' qty=3 total=37.50 button=enabled onQuantityChange(12) email='[email protected]' qty=12 total=150.00 button=disabled
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Mutating a list inside state.
items.add(x)on amutableStateOf(mutableListOf())doesn't trigger recomposition; replace the list (items = items + x) or usemutableStateListOf. - Side effects in the composable body. A
showSnackbaror analytics call there runs on every recomposition; useLaunchedEffector an event callback. - State owned too low. If two composables need the same value, hoist it to their common parent (or the ViewModel).
- Using
derivedStateOffor everything. For simple calculations from state, a plain expression is clearer and fast.
Key points
- Keep reusable composables stateless: value in, events out.
- Model screen state as immutable data and update it with
copy. rememberSaveablefor user input; ViewModel for state that outlives the screen's composition.- Run side effects in
LaunchedEffectand other effect handlers, keyed on what should restart them.
Further reading: State and Jetpack Compose, Side-effects in Compose.
Practice questions
What is state hoisting and why is it useful?
Answer
Moving state out of a composable to its caller, so the composable receives the value and callbacks for changes (value/onValueChange). The composable becomes stateless, so it's reusable, previewable and testable, and there's a single source of truth for the state.
When does LaunchedEffect run its block again?
Answer
When it first enters the composition and whenever one of its keys changes; the previous coroutine is cancelled first. It's also cancelled when the LaunchedEffect leaves the composition. Using a constant key such as Unit runs it once per time it enters the composition.
Open in the app to mark lessons complete, take notes and track your progress.