Android – State and Recomposition

August 14, 20268 min readUpdated 10/11/2026

The last lesson's composables drew fixed content. Real screens change: a size is selected, a quantity goes up, the menu finishes loading. In Compose you never reach into the UI to change it — you change state, and the composables that read that state run again.

This lesson is about which kind of state to use and where it should live. There are five tools, and one rule that decides between them.

What recomposition actually is

Compose tracks which composables read which pieces of observable state. When a piece of state changes, Compose schedules every composable that read it to run again, and skips the ones that did not. That re-run is recomposition.

Two consequences follow, and both trip people up:

  • Only observable state triggers it. Changing an ordinary var changes nothing on screen. The value must be a Compose State — or a flow converted into one.
  • A recomposition re-runs the function from the top. Any local variable is recreated. That is why state that must persist across recompositions needs remember.

remember and mutableStateOf

mutableStateOf(x) creates an observable box. remember { … } stores a value in the composition so the next recomposition gets the same instance back instead of a fresh one. Together they are local, private state for one composable:

var password by remember { mutableStateOf("") }

The by keyword is a Kotlin property delegate: reading password reads the box's value, assigning to it writes the value — and writing is what schedules the recomposition. Without by you would write password.value everywhere.

remember survives recomposition. It does not survive the Activity being destroyed — which on Android happens every time the phone rotates. For a password field that is exactly right, and the source says why in a comment: a password has no business being written to disk.

rememberSaveable

For everything else the customer would be annoyed to lose, there is rememberSaveable. It stores the value in the Activity's saved-instance Bundle, which the system keeps across rotation and across the process being killed in the background:

var section by rememberSaveable { mutableStateOf(ProductType.PIZZA) }

The menu remembers whether you were looking at pizzas or drinks when you turn the phone. The cost of rememberSaveable is a constraint: the value must fit in a Bundle — primitives, strings, enums, Parcelables. That is why the open builder sheet is tracked by the product's id, not the product:

var openProductId by rememberSaveable { mutableStateOf<UuidString?>(null) }

The full story of rotation and process death is the next lesson; the rule for now is use rememberSaveable for anything the user chose, and remember for anything you can recompute.

State hoisting

The quantity stepper is used in two places — the builder, where the quantity is a local choice, and the cart, where it belongs to a cart line in a store. If the stepper owned its number, it could serve neither. So it owns nothing:

fun QuantityStepper(
    value: Int,
    onValueChange: (Int) -> Unit,
    label: String,
    modifier: Modifier = Modifier,
    range: IntRange = 1..20,
) {

The value comes in; changes go out through a callback. The caller decides where the number lives. This is state hoisting — moving state up to the lowest common owner and passing it down — and it is the pattern behind every Material component: a TextField does not remember what you typed, it takes value and onValueChange.

The same idea scales up to whole screens. Every screen in this app is a pair: a thin route composable that gets the ViewModel and collects its flows, and a stateless content composable that takes plain values and lambdas:

fun MenuContent(
    state: UiState<Catalogue>,
    isRefreshing: Boolean,
    onRefresh: () -> Unit,
    onRetry: () -> Unit,
    onProductClick: (Product) -> Unit,
    modifier: Modifier = Modifier,
) {

MenuContent has no idea a ViewModel exists. It can be previewed with a hand-built UiState, and UI-tested without Hilt or a network — both of which later lessons do.

A stateless composable is easier to reuse, to preview (pass any value) and to test (assert on the callback). The general shape, sometimes called unidirectional data flow, is state flows down, events flow up, and every screen in this app is built that way.

State holder classes

The pizza builder has a size, a crust, a set of toppings and a quantity, plus rules: medium by default, drinks have no crust, the price follows every tap. Five remember calls inside the sheet would work, and would put the rules where no test can reach them. Instead the state lives in a plain class:

@Stable
class PizzaBuilderState(
    val product: Product,
    private val catalogue: Catalogue,
) {
    val isPizza: Boolean = product.type == ProductType.PIZZA

    var selectedSize: SizeName by mutableStateOf(
        product.sizes.map { it.size }.let { sizes ->
            if (SizeName.MEDIUM in sizes) SizeName.MEDIUM else sizes.firstOrNull() ?: SizeName.MEDIUM
        },
    )

    var selectedCrustId: UuidString? by mutableStateOf(if (isPizza) catalogue.crusts.firstOrNull()?.id else null)

    var quantity: Int by mutableIntStateOf(1)

    private var selectedToppingIds: Set<UuidString> by mutableStateOf(emptySet())
    // …

Its properties are backed by mutableStateOf, so a composable reading state.selectedSize recomposes when it changes, exactly as if the box were local. Because it is an ordinary class, its rules are tested on the JVM with no UI at all — the testing lesson shows that suite.

It is created with remember, keyed on the product:

fun rememberPizzaBuilderState(product: Product, catalogue: Catalogue): PizzaBuilderState =
    remember(product, catalogue) { PizzaBuilderState(product, catalogue) }

The keys matter. remember(product) discards the stored value and runs the block again whenever the key changes, so opening the builder for a different pizza starts fresh instead of showing the last pizza's toppings. A new sheet for a new product, with no "reset" code to forget.

Why not a ViewModel? A ViewModel outlives its screen — that is its entire purpose. The builder's state is meaningless once the sheet closes, and should die with it. A state holder created with remember has exactly the sheet's lifetime.

derivedStateOf

The price depends on four selections. Computing it in the composable would work; it would also re-run on every recomposition, and invalidate everything that shows the price whenever any input changes:

val unitPrice: Double by derivedStateOf {
    val base = product.priceFor(selectedSize) ?: 0.0
    Money.rounded(base + (selectedCrust?.priceDelta ?: 0.0) + selectedToppings.sumOf { it.price })
}
// ...
val totalPrice: Double by derivedStateOf { Money.rounded(unitPrice * quantity) }

derivedStateOf is a cached computation over other state. It re-runs only when a state it read changes, and it notifies its own readers only when its result changes. Toggling a free topping changes an input but not the price, and the button showing the price does not recompose. Reach for it when a value is derived from state that changes more often than the value does.

Replace collections, never mutate them

The selected toppings are a Set inside a mutableStateOf, and toggling one is written in a way that looks wasteful at first:

fun toggle(topping: Topping) {
    selectedToppingIds = if (topping.id in selectedToppingIds) {
        selectedToppingIds - topping.id
    } else {
        selectedToppingIds + topping.id
    }
}

Every toggle builds a brand-new set and assigns it. That is not style — it is the only version that works. The state box notices assignments. Here is the wrong way, which compiles, runs, and does nothing visible:

// ✗ the set changes; the box still holds the same instance, so nothing recomposes
var selected by mutableStateOf(mutableSetOf<String>())
selected.add(topping.id)

The chip does not tick. Tap it again and it still does not, though the set now holds the id. The next unrelated recomposition — a rotation, a price change — suddenly shows both taps at once. It is one of the most reported Compose "bugs", and it is always this.

The rule that prevents it: hold immutable values in state and replace them. When a list is large and genuinely needs in-place edits, Compose provides mutableStateListOf and mutableStateMapOf, which are observable themselves. A Set of a few ids is not that case.

State the whole app owns

Some state belongs to no screen. The menu is needed by the home screen, the menu tab, the builder and the cart's rehydration; it is fetched once and shared. That state lives in a singleton store, and it is exposed as a StateFlow:

private val _state = MutableStateFlow<UiState<Catalogue>>(UiState.Idle)
val state: StateFlow<UiState<Catalogue>> = _state.asStateFlow()

The backing property pattern: a private, mutable flow that only the store can write, and a public, read-only view of it. A screen can observe the menu; it cannot assign to it. A StateFlow always has a current value and emits every change, which makes it the coroutine world's equivalent of an observable property.

A screen reaches the store through a ViewModel, and turns the flow into Compose state at the top of the screen:

val catalogue by viewModel.catalogue.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()

collectAsStateWithLifecycle subscribes while the screen is visible and stops when the app goes to the background, then delivers each value as Compose State. Use it rather than the older collectAsState, which keeps collecting for a screen nobody can see.

The rule

Five tools, one question: who needs this value, and for how long?

  • One composable, and it can be lost on rotation → remember.
  • One composable, and the user would notice it being lost → rememberSaveable.
  • A parent and its children → hoist it to the parent and pass it down.
  • A screen, with rules worth testing, for as long as the UI is up → a state holder class created with remember.
  • A screen, across rotation, with work that must not restart → a ViewModel.
  • Several screens → an app-wide store exposing StateFlow.

The most common mistake is the opposite of what you might expect: not too little shared state, but too much. A form's half-typed values in a global store means every screen recomposes when a letter is typed, and the form's lifetime becomes the app's. This app has exactly four app-wide stores — auth, menu, cart and toasts — and everything else is owned by the screen that uses it.

(The iOS version of this app makes the same split with different words: @State where this uses remember, and @Observable stores where this uses StateFlow. The decision about where state lives is identical, which is the part worth learning.)

Next

"Survives rotation" has come up three times now, and it deserves more than a footnote. The next lesson is the one with no iOS equivalent: what a ViewModel survives, why rotation destroys your Activity, and what happens when Android kills your process while the customer is in another app.