UI state and events
A screen has exactly one state object and two directions of data. Getting that shape right removes most of the bugs that look like lifecycle problems.
is usually presented as a diagram with two arrows. It is really a rule about ownership: the is the only thing that writes state, and the UI is the only thing that reads it. Everything else follows.
TL;DR#
- Model the screen as one immutable
UiState, not a handful of independentStateFlows. - State flows down, as parameters. Events flow up, as lambdas. A composable never reaches for the ViewModel directly.
- The ViewModel survives configuration changes but not process death. Use
SavedStateHandlefor what must survive both. - Model one-off actions (navigate, show a snackbar) as events, not as part of the state — or make them idempotent state.1
- Collect with
collectAsStateWithLifecycle()so work stops when the UI is not visible.2
Mental model#
Think of a vending machine.
The machine holds the stock and the prices (state). You press a button (event). The machine decides whether to dispense, refuse, or show an error, and then it updates its own display (new state). You never reach inside to move the snacks.
Where the analogy breaks#
A vending machine's state is internal and hidden. A ViewModel's state is published — it is an observable value the UI renders. The important part is not encapsulation for its own sake, but that there is exactly one writer, so the screen can never show two contradictory truths.
The big picture#
Text description of this diagram
The unidirectional data flow loop drawn as four nodes — ViewModel, UI, user and event — connected by four edges. State flows from the ViewModel to the UI. The UI renders. The user produces a gesture. The event flows back up to the ViewModel, which validates it and emits new state, closing the loop. Each step of the player highlights one edge and explains what happens there, ending with the observation that only the composables that read the changed values recompose.
The loop is a cycle, but the data only ever travels one way around it. That is the whole idea.
Deep dive#
One state object#
@Immutable
data class NotesUiState(
val isLoading: Boolean = false,
val notes: ImmutableList<Note> = persistentListOf(),
val error: String? = null,
val query: String = "",
) {
val isEmpty: Boolean get() = !isLoading && notes.isEmpty() && error == null
}Why one object instead of four StateFlows:
- Impossible states cannot be represented.
isLoading = truewith a populated list and an error is representable in four independent flows; it is a visible bug in one object. - Recomposition is coherent. One emission means one frame, with a consistent snapshot.3
- Testing is a value comparison.
assertEquals(expected, actual)instead of collecting four flows.
Modeling alternatives that are sometimes better:
// A sealed hierarchy when the states are mutually exclusive.
sealed interface NotesUiState {
data object Loading : NotesUiState
data class Ready(val notes: ImmutableList<Note>) : NotesUiState
data class Error(val message: String) : NotesUiState
}Use a sealed hierarchy when the states genuinely exclude each other; use a data class when the screen has orthogonal flags (loading and a list and an error banner).
The ViewModel is a state producer, not a state holder#
class NotesViewModel(
private val repository: NotesRepository,
private val handle: SavedStateHandle,
) : ViewModel() {
private val query = handle.getStateFlow(KEY_QUERY, "")
val state: StateFlow<NotesUiState> = query
.debounce(250)
.flatMapLatest { q -> repository.observe(q) }
.map { notes -> NotesUiState(notes = notes) }
.catch { e -> emit(NotesUiState(error = e.message)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NotesUiState(isLoading = true),
)
fun onQueryChange(value: String) {
handle[KEY_QUERY] = value
}
fun onDelete(note: Note) {
viewModelScope.launch { repository.delete(note) }
}
}Four things worth noticing:
stateInwithWhileSubscribed(5_000)keeps the upstream alive across a rotation but stops it when the UI is gone for good.- The query lives in
SavedStateHandle, so it survives process death as well as rotation. - Events are methods, not exposed state. The UI calls them; it never writes.
catchconverts a failure into state. An error is something the screen renders, not an exception the UI has to handle.
State down, events up#
@Composable
fun NotesRoute(viewModel: NotesViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
NotesScreen(
state = state,
onQueryChange = viewModel::onQueryChange,
onDelete = viewModel::onDelete,
)
}
@Composable
fun NotesScreen(
state: NotesUiState,
onQueryChange: (String) -> Unit,
onDelete: (Note) -> Unit,
) {
Column {
SearchField(value = state.query, onValueChange = onQueryChange)
when {
state.isLoading -> Loading()
state.error != null -> ErrorPane(state.error)
state.isEmpty -> EmptyState()
else -> NotesList(state.notes, onDelete)
}
}
}NotesScreen is stateless: it takes values and lambdas. That has three consequences that matter
more than the pattern's elegance:
- It can be previewed with any state, including error and empty.
- It can be tested without a ViewModel, a database or a dispatcher.
- It cannot accidentally depend on something the caller did not provide.
One-off events: the hard part#
Navigation, snackbars and toasts are not state — they happen once. Modeling them as state produces the classic bug: rotate the screen and the snackbar appears again, because the state still says "show".
Three approaches, in increasing order of robustness:
// 1. A Channel of events. Consumed once, but lost if nobody is listening.
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// 2. A state field with an explicit acknowledgement.
data class UiState(val message: String? = null)
fun onMessageShown() { _state.update { it.copy(message = null) } }
// 3. Model navigation as state: the destination is part of the state.
data class UiState(val destination: Destination? = null)For navigation, option 3 is generally best in Compose: the back stack is state, and the UI reacts to a change in the graph. For transient messages, option 2 is the most robust because the acknowledgement is explicit and survives recomposition.
What survives what#
| State | Rotation | Process death | Back navigation |
|---|---|---|---|
remember | no | no | no |
rememberSaveable | yes | yes | no |
ViewModel field | yes | no | no |
SavedStateHandle | yes | yes | no |
| Repository / database | yes | yes | yes |
| Navigation back stack | yes | yes (restored) | n/a |
The rule that follows: transient UI state in remember, screen state in the ViewModel, durable
state in the repository, and anything small that must survive process death in SavedStateHandle.
var scrollPosition by rememberSaveable { mutableIntStateOf(0) }rememberSaveable writes into the same saved-state bundle the activity uses, under a key derived from
the composition position. It is right for scroll positions, expanded panels and draft text — not for a
list of a thousand items.
Where the state object lives, layer by layer#
A common source of confusion is that "state" appears at four different layers, each with its own lifetime:
| Layer | Example | Lifetime | Survives process death |
|---|---|---|---|
| Database | a note row | until deleted | yes |
| Repository cache | an in-memory list | the process | no |
| ViewModel | NotesUiState | the screen | no (unless in SavedStateHandle) |
| Composition | remember { } | the composition | no |
The rule that follows is mechanical: put each value at the lowest layer that satisfies its
lifetime. A scroll position belongs in the composition (or rememberSaveable); the selected note
belongs in the ViewModel; the note's content belongs in the database. Putting a database row in the
ViewModel duplicates it; putting a scroll position in the database is absurd. The four-layer table is
the whole decision procedure.
Previews as a design tool#
A stateless screen with a state parameter is previewable in every state, which is not a testing convenience but a design one:
@Preview(name = "Empty")
@Composable
private fun EmptyPreview() {
NotesTheme { NotesScreen(state = NotesUiState(), onQueryChange = {}, onDelete = {}) }
}
@Preview(name = "Error")
@Composable
private fun ErrorPreview() {
NotesTheme { NotesScreen(state = NotesUiState(error = "Offline"), onQueryChange = {}, onDelete = {}) }
}Seeing the empty and error states side by side with the happy path is how designers catch the states that get forgotten. A screen that cannot be previewed in its error state is usually a screen whose error state was never designed.
Collecting correctly#
// Wrong: keeps collecting while the UI is in the background.
val state by viewModel.state.collectAsState()
// Right: stops at STOPPED, restarts at STARTED.
val state by viewModel.state.collectAsStateWithLifecycle()collectAsStateWithLifecycle comes from lifecycle-runtime-compose, stable since 2022. It uses
repeatOnLifecycle internally, which is why it is the recommended form on Android and why the plain
collectAsState is mostly for non-Android targets.2
When the state object gets too big#
A single UiState is right until it is not. The failure mode is a screen with forty fields where most
of them are irrelevant to most of the screen — a list, a filter panel, a detail pane and a dialog all
in one object. Then every change recomposes everything that reads the object, because the object is
the parameter.
The fix is to split by independent region, not by field type: ListState, FilterState,
DetailState. Each region gets its own state object and its own composable that takes only that
object. The single-state rule applies per screen region, not per screen file.
The diagnostic is simple: if two parts of the screen never change together, they should not share a state object. If they always change together, splitting them adds churn for nothing.
The events question, settled#
The channel-versus-state debate has a simple resolution: ask what happens if the event is delivered twice, or not at all.
- A snackbar may be shown twice or missed. Model it as state with an acknowledgement.
- A navigation action must happen exactly once. Model it as state (a destination field) that the UI consumes and clears, or navigate from the UI in response to a state transition.
- An analytics event may be duplicated; a payment must not.
That question is more useful than any pattern name, because it forces you to think about the failure mode rather than the plumbing.
Testing the state layer#
@Test
fun `query emits results`() = runTest {
val repository = FakeNotesRepository(
notes = listOf(Note("1", "Shopping"), Note("2", "Work")),
)
val viewModel = NotesViewModel(repository, SavedStateHandle())
viewModel.onQueryChange("shop")
advanceTimeBy(300) // past the debounce
assertEquals(
NotesUiState(notes = persistentListOf(Note("1", "Shopping"))),
viewModel.state.first { !it.isLoading },
)
}That test runs on the JVM in milliseconds because the ViewModel depends on an interface, not on
Context. It is the payoff for the whole architecture: the state layer is testable without an
emulator.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 4 | 1.6 Donut |
|
| 5 | 2.0 Eclair |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 13 | 3.2 Honeycomb |
|
| 14 | 4.0 Ice Cream Sandwich |
|
| 16 | 4.1 Jelly Bean |
|
| 17 | 4.2 Jelly Bean |
|
| 18 | 4.3 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 22 | 5.1 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 27 | 8.1 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 32 | 12L |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
| 37 | 17 Cinnamon Bun |
|
The shape stabilized with Architecture Components in 2017 (a state holder that survives rotation) and
was completed by Compose in 2021 (a UI that is a function of that state). The UiState object pattern
is the 2020s polishing of the same idea.
Under the hood#
- Activity.javathe component whose recreation the ViewModel exists to survive
- Bundle.javathe container SavedStateHandle writes into
- View.javawhat a Compose UI is ultimately hosted in
Curiosity corner#
StateFlowconflates. A slow collector sees the latest value, not every value. That is usually right for UI, and wrong for events — which is why events are not modeled asStateFlow.SharingStarted.WhileSubscribed(5_000)exists because a rotation takes a few hundred milliseconds; stopping immediately would restart the stream on every rotation.SavedStateHandleis aMap, not a database. Everything in it crosses a Binder transaction at some point, so it must stay small.
Try it#
grep -rn "remember " --include=*.kt . | wc -l
grep -rn "collectAsState()" --include=*.kt .
Check yourself#
Check yourself
Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
- docsLifecycle of composablesdeveloper.android.comaccessed 2026-10-27↩
- docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
- docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩
- docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩