Compose State & Hoisting Explained
UI › Compose Core
Compose's whole rendering model rests on one idea: state that composables read gets tracked, and recomposition is scoped to exactly the composables that read it. An interviewer asking about Compose state is really asking whether you understand this observation mechanism, not whether you've memorized which function names exist.
mutableStateOf(value) returns a MutableState<T>, which wraps value behind a .value property. Compose's snapshot system records, during composition, which composables read that .value. When you later write a new value to it, only those specific readers get scheduled for recomposition, nothing else in the tree is touched.
That write only schedules recomposition if the new value is actually different from the one already stored. mutableStateOf defaults to structural equality: writing a value that equals the current one, by equals, is treated as a no-op and never notifies readers. If you genuinely needed every write to notify readers even when the value hasn't changed, you'd hand mutableStateOf a different policy such as neverEqualPolicy(), but that's the exception, not the default.
val count: MutableState<Int> = mutableStateOf(0)
count.value = 5 // different value -> tracked readers recompose
count.value = 5 // same value again -> no-op, no recomposition
Reading state is only half the story, that state also needs somewhere to live across recompositions, and a plain local variable is a trap.
@Composable
fun Counter() {
var count = mutableStateOf(0)
Button(onClick = { count.value++ }) {
Text("${count.value}")
}
}
Every time Counter recomposes, this line reruns and builds a brand-new MutableState, initialized straight back to 0. Tapping the button does trigger a recomposition, since count.value++ is a write, but the very state that triggered it gets thrown away in the same breath, because the next recomposition rebuilds mutableStateOf(0) from scratch before anything gets a chance to read the incremented value.
remember fixes the throwaway-state problem by storing the object in the Composition's slot table, keyed to that call site, and handing back the exact same instance on the next recomposition instead of building a new one.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
var count by remember { mutableStateOf(0) }
The by here isn't compiler magic specific to State, it's ordinary Kotlin property delegation, backed by getValue and setValue operator extension functions that Compose ships for State and MutableState. Those two imports are what let count read and write straight through to .value while remaining a fully observable State underneath.
remember also accepts one or more key arguments: remember(key) { ... }. On the first call it runs the lambda and caches the result. On every later recomposition it compares the key by equals, and only reruns the lambda, replacing the cached value, when the key has changed. Without a key, remember never recomputes on its own, it just keeps returning whatever it cached the first time, which becomes a bug the moment the cached value is supposed to depend on a parameter that can change.
remember on its own only survives recomposition, it does not survive the Composition itself being torn down. Rotate the screen, and Android recreates the activity from scratch, which throws away the whole Composition and its slot table, remember included.
rememberSaveable closes that gap. It works like remember, but also writes the value into Android's saved instance state, the same Bundle-backed mechanism behind onSaveInstanceState, so the value comes back after a configuration change or after the system kills and recreates the process in the background. Neither one survives the user actually swiping the app away or force-stopping it, that's a fresh start regardless.
var draft by remember { mutableStateOf("") } // lost on rotation
var draft by rememberSaveable { mutableStateOf("") } // survives rotation
A good rule of thumb: reach for rememberSaveable for anything a user would be annoyed to lose across a rotation, like in-progress form input, and plain remember for everything else, since it's cheaper and doesn't need to be Bundle-compatible.
That saved-instance-state mechanism is Bundle-backed under the hood, so rememberSaveable can only carry what a Bundle can carry directly: primitives, Strings, and a handful of other built-in types. An arbitrary data class doesn't qualify on its own.
data class MyFilter(val query: String, val onlyFree: Boolean)
var filter by rememberSaveable { mutableStateOf(MyFilter("", false)) }
Run that as written and it fails at runtime, because MyFilter isn't something a Bundle knows how to store. There are two ways to fix it. Make the class Parcelable, usually with the @Parcelize annotation so you don't hand-write the boilerplate, and rememberSaveable will store it automatically. Or, when you can't touch the class, pass rememberSaveable a stateSaver argument: a Saver, often built with mapSaver or listSaver, that tells it how to flatten the object down into Bundle-compatible pieces and rebuild it later.
Once state is safely remembered, the next question is where it should live relative to the composable that displays it. **State hoisting** means moving state up and out of a composable so the composable itself becomes stateless: it takes a value to render and reports changes through a callback, instead of owning any state of its own.
@Composable
fun SearchBar(value: String, onValueChange: (String) -> Unit) {
TextField(value = value, onValueChange = onValueChange)
}
@Composable
fun Screen() {
var query by remember { mutableStateOf("") }
SearchBar(value = query, onValueChange = { query = it })
}
SearchBar no longer holds any MutableState itself, it just renders whatever value it's handed and calls onValueChange when the user edits it. That makes it trivially reusable in a different screen, easy to preview with fixed sample values, easy to unit test without any Compose runtime involved, and it means there is exactly one place, Screen, that owns the real answer to what the query currently is.
Hoisting only pays off if the hoisted value stays the single source of truth. It's easy to undermine that by accident inside the composable you just made stateless.
@Composable
fun SearchField(value: String, onValueChange: (String) -> Unit) {
var internal by remember { mutableStateOf(value) }
TextField(internal, onValueChange = { internal = it })
}
This compiles, and it looks hoisted because the parameters are right there, but remember { mutableStateOf(value) } only reads value once, the first time this call site is composed. After that, internal lives its own life: it updates when the user types, through the TextField callback, but it never re-syncs when the parent later pushes a fresh value in, say after a clear button resets the parent's state. Now there are two sources of truth for the same field, and they can silently disagree.
The pattern this breaks is unidirectional data flow: state flows down through parameters, events flow back up through callbacks, and exactly one owner decides what the current value actually is. A properly hoisted composable renders the value parameter directly, TextField(value = value, onValueChange = onValueChange), with no local copy sitting in between.
Hoist state doesn't mean hoist everything to the root of the app. There are three concrete rules for exactly how far to move it:
1. Hoist to **at least the lowest common parent** of every composable that needs to read it. 2. Hoist to **at least the highest level** that ever changes it. 3. If two pieces of state always change together in response to the same event, **hoist them together**, as a pair or a small data class, not as two independently drifting variables.
These are floors, not a mandate to push everything to the top. A search query only read and changed within one screen belongs on that screen, not threaded up through the whole navigation graph. Over-hoisting past where it's actually needed just means unrelated parents recompose for state they don't care about, and callers have to thread parameters through composables that have no use for them.
Some state changes far more often than the UI decision it feeds into. Scroll offset updates on every pixel while a list scrolls, but a jump-to-top button might only need to flip visible twice in an entire scroll session, once past the first item, once back.
val listState = rememberLazyListState()
// Recomposes the reader on every scroll pixel:
val showButton = listState.firstVisibleItemIndex > 0
// Recomposes the reader only when the boolean actually flips:
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
derivedStateOf wraps a computation over other State reads and produces its own State. It still recomputes that lambda on every input change, that part isn't free, but it only notifies its own readers, and therefore only triggers their recomposition, when the computed result is actually different from before. Wrapping it in remember is required too, otherwise you'd rebuild the derivedStateOf object itself on every recomposition, the same trap as any other unremembered state.
derivedStateOf only earns its cost when the output changes noticeably less often than the inputs. If the output changes just as often as the input does, you've added a wrapper for nothing.
// Output changes on every keystroke, same rate as the inputs:
val fullName by remember {
derivedStateOf { "$firstName $lastName" }
}
// No benefit over just computing it directly:
val fullName = "$firstName $lastName"
Concatenating two names that are each edited directly in their own TextFields changes the result on essentially every keystroke, there's no gap between input rate and output rate for derivedStateOf to collapse, so plain computation is simpler and no slower.
There's a second tool for a different problem: reacting to a State change from inside suspend code, for example logging an analytics event only when the visible item index actually changes, from inside a LaunchedEffect. State reads aren't a Flow on their own, so you can't collect them directly. snapshotFlow bridges that gap: it turns reads of Compose State into a cold Flow that emits whenever the observed snapshot state changes, which you can then collect, distinctUntilChanged, or otherwise treat like any other Flow.
State observation only works on things Compose actually tracks reads and writes of. A plain Kotlin collection doesn't qualify, even when it's sitting inside remember.
// Not observable: mutating the list doesn't notify anyone
val items = remember { mutableListOf<String>() }
items.add("new") // list changes, but nothing recomposes
// Observable: Compose tracks structural changes to this list
val items = remember { mutableStateListOf<String>() }
items.add("new") // recomposes readers of items
mutableListOf() returns an ordinary ArrayList. remember still keeps that same list instance across recompositions, so the trap here is different from the earlier one, but Compose has no way to know that instance was mutated internally, since calling .add never touches a State object. mutableStateListOf, and its map and set equivalents, is a list-shaped State that Compose does track. The other fix is to keep the list immutable and hold it inside a normal MutableState, replacing the whole list on every change instead of mutating it in place.
With observation, remembering, hoisting and flow direction settled, the last question is where state should actually be owned. Three tiers cover almost everything.
Inside the composable itself, with plain remember, for small UI-element state scoped to the Composition, like whether a tooltip is currently expanded.
In a plain remembered state holder class, when there's more than one related value or some UI logic worth naming, but still no business logic and no need to survive more than what rememberSaveable already gives you.
In a ViewModel, for screen-level UI state and actual business logic, fetching from a repository, coordinating multiple data sources, because a ViewModel survives configuration changes on its own and is scoped to a ViewModelStoreOwner rather than to the Composition.
ViewModels are generally obtained once, at the screen's top composable, with viewModel(). Their state and event callbacks get passed down into child composables as plain values and lambdas from there, the same hoisting shape as everything else in this lesson, rather than passing the ViewModel reference itself deeper into the tree.
Every piece of this fits together into one story. mutableStateOf gives you state Compose can observe. remember keeps that state alive across recompositions instead of rebuilding it every time. rememberSaveable extends that survival across configuration change and process death. Hoisting moves that state up and out of the composables that merely display it, so value flows down and events flow up through onValueChange-shaped callbacks, with exactly one owner holding the real answer at any moment. derivedStateOf and snapshotFlow are refinements on top of observation itself, one collapses noisy inputs into a calmer signal inside Compose, the other lets that signal drive suspend work outside it. And the three ownership tiers, composable-local, plain holder, ViewModel, are really just different answers to how long the state needs to live and whether it carries business logic.
If an interviewer asks you to debug a composable that won't update, or to convert a stateful one into a reusable one, you're really being asked to walk this same chain: is the state observable, is it remembered, who owns it, and does exactly one place get to change it. Say that, and back it with the value plus onValueChange shape, and you've covered what they're listening for.