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.

Create a free account to save this page, take notes and track your progress. Sign in

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(...) }; use rememberSaveable for 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; use derivedStateOf only 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:

Hoisted form state and events
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 a mutableStateOf(mutableListOf()) doesn't trigger recomposition; replace the list (items = items + x) or use mutableStateListOf.
  • Side effects in the composable body. A showSnackbar or analytics call there runs on every recomposition; use LaunchedEffect or an event callback.
  • State owned too low. If two composables need the same value, hoist it to their common parent (or the ViewModel).
  • Using derivedStateOf for 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.
  • rememberSaveable for user input; ViewModel for state that outlives the screen's composition.
  • Run side effects in LaunchedEffect and 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.

Was this page helpful?