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
varchanges nothing on screen. The value must be a ComposeState— 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.