State management is the single most confusing part of learning Compose for developers coming from the old View system — there's no findViewById, no manual "update the UI" calls, and the framework does something that looks like magic. It isn't magic. Here's exactly what's happening.
The core idea: UI is a function of state
In the old Android View system, you imperatively told the UI what to do: textView.text = "New value". In Compose, you describe what the UI should look like given the current state, and Compose figures out how to update the screen when that state changes. This is the same mental model as React or SwiftUI — declarative UI.
@Composable
fun Greeting(name: String) {
Text("Hello, $name!")
}
This composable doesn't "update" — it just describes what to show for a given name. If name changes and Compose re-invokes this function, you get new output. That re-invocation is called recomposition.
remember and mutableStateOf
A plain var inside a composable would be reset to its initial value on every recomposition, since the function body just runs again. remember solves this by caching a value across recompositions, tied to the composable's position in the tree. mutableStateOf wraps a value so Compose can observe reads and writes of it.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) { Text("Increment") }
}
}
Here's the actual mechanism: when Text("Count: $count") is composed, Compose records that this piece of UI read the count state. When count is later written to (inside the button's onClick), Compose looks up everything that read it and schedules exactly those pieces for recomposition — not the whole screen, just the Text. This is why Compose can be efficient despite looking like it "just reruns everything."
Why remember alone isn't enough: rememberSaveable
remember survives recomposition, but not configuration changes (like screen rotation) or process death — both of which destroy and recreate the composable's position in the tree from scratch. rememberSaveable additionally saves the value into the instance state bundle Android already preserves across those events.
var count by rememberSaveable { mutableStateOf(0) }
// survives rotation; remember alone would reset to 0
Use rememberSaveable for anything the user would be annoyed to lose on rotation — form input, a counter, a selected tab. Use plain remember for derived or transient UI-only state that's fine to reset, like whether a dropdown is currently open.
State hoisting: making composables reusable
A composable that owns its own state (like Counter above) can't be controlled from outside or easily tested with different inputs. State hoisting means moving the state up to the caller and passing it down as a parameter, plus a callback for events:
// Stateless — receives value, emits events. Reusable and testable.
@Composable
fun CounterDisplay(count: Int, onIncrement: () -> Unit) {
Column {
Text("Count: $count")
Button(onClick = onIncrement) { Text("Increment") }
}
}
// The caller owns and hoists the state
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
CounterDisplay(count = count, onIncrement = { count++ })
}
This is the same pattern covered in our Learn Hub state hoisting topic — the rule of thumb is: state should live at the lowest common ancestor of every composable that reads or writes it. Don't hoist further than necessary; a composable that only one caller ever uses doesn't need its state hoisted at all.
Beyond a single screen: StateFlow and the ViewModel
Local composable state (remember) is right for UI-only concerns — is a dialog open, what's the current scroll position. For actual application data — a list of notes, a network response — that data should live in a ViewModel, not in a composable, so it survives configuration changes without needing rememberSaveable gymnastics and isn't tied to any specific screen's lifetime.
class NotesViewModel : ViewModel() {
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
}
@Composable
fun NotesScreen(viewModel: NotesViewModel = viewModel()) {
val notes by viewModel.notes.collectAsState()
// notes is now observed the same way remembered state is —
// reading it here subscribes this composable to recomposition on change
}
collectAsState() is the bridge: it turns a coroutines StateFlow into Compose's observable state system, so from the composable's perspective, reading notes works exactly like reading any other Compose state — same recomposition mechanics, different source of truth.
Common mistakes
- Using
rememberfor data that should survive rotation — userememberSaveableor a ViewModel instead. - Not hoisting state that's shared between sibling composables — leads to two composables with their own disconnected copies of "the same" data.
- Doing expensive work directly inside a composable body — composables can run many times; heavy computation belongs behind
remember(key) { ... }orderivedStateOf, not recomputed on every recomposition. - Storing a
Contextor aNavControllerinrememberacross configuration changes without care — these can leak or go stale; useLocalContext.currentfresh each composition instead of caching it long-term.