Zygote
App architectureintermediate

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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 independent StateFlows.
  • 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 SavedStateHandle for 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#

Loading diagram…
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 = true with 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:

  1. stateIn with WhileSubscribed(5_000) keeps the upstream alive across a rotation but stops it when the UI is gone for good.
  2. The query lives in SavedStateHandle, so it survives process death as well as rotation.
  3. Events are methods, not exposed state. The UI calls them; it never writes.
  4. catch converts 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#

StateRotationProcess deathBack navigation
remembernonono
rememberSaveableyesyesno
ViewModel fieldyesnono
SavedStateHandleyesyesno
Repository / databaseyesyesyes
Navigation back stackyesyes (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:

LayerExampleLifetimeSurvives process death
Databasea note rowuntil deletedyes
Repository cachean in-memory listthe processno
ViewModelNotesUiStatethe screenno (unless in SavedStateHandle)
Compositionremember { }the compositionno

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#

Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

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#

Curiosity corner#

  • StateFlow conflates. 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 as StateFlow.
  • SharingStarted.WhileSubscribed(5_000) exists because a rotation takes a few hundred milliseconds; stopping immediately would restart the stream on every rotation.
  • SavedStateHandle is a Map, not a database. Everything in it crosses a Binder transaction at some point, so it must stay small.

Try it#

Find state that should be hoisted

grep -rn "remember " --include=*.kt . | wc -l

Check for plain collectAsState in Compose code

grep -rn "collectAsState()" --include=*.kt .

Check yourself#

Check yourself

1 / 12

Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?

0 answered · 0 correct

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.

  1. docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
  2. docsLifecycle of composablesdeveloper.android.comaccessed 2026-10-27↩
  3. docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
  4. docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
  5. docsJetpackdeveloper.android.comaccessed 2026-10-27↩
  6. docsAndroidX রিলিজ | Jetpackdeveloper.android.comaccessed 2026-10-27↩