The ViewModel is not the default place for state. It's the place for state that outlives the screen.

Hoisting to a ViewModel is a different decision from hoisting to a parent composable. The dividing question is lifetime, and getting it wrong in either direction has a specific cost.

6 min read
androidcomposekotlinarchitecture

Day 12 — Screen state vs UI element state

Day 12 of 100. Yesterday's rule — hoist to the lowest common ancestor — has an unstated assumption: that the destination is inside the composition. Sometimes it isn't, and knowing when is the difference between a codebase that survives a rotation and one that quietly loses work.

The symptom

A checkout screen. The user fills in an address, rotates the phone, and the form is empty.

@Composable
fun CheckoutScreen() {
    var address by remember { mutableStateOf("") }
    var promoCode by remember { mutableStateOf("") }
    var isSubmitting by remember { mutableStateOf(false) }
    …
}

Everything here is correctly hoisted by yesterday's rule. CheckoutScreen is the lowest common ancestor of every reader. And all of it vanishes on rotation, because the composition was torn down with the Activity and rebuilt from scratch — Day 8's point about composition lifetime and Android lifetime being different clocks.

Why the obvious fix fails

The obvious fix is rememberSaveable:

var address by rememberSaveable { mutableStateOf("") }

This survives rotation, and it's the wrong tool for this particular value. Saved state goes into the Bundle, which is small (an enforced limit, and exceeding it crashes the process on Android 7+), serialised on the main thread, and meant for transient UI state — scroll position, an expanded flag, a partially-typed field.

More to the point, it doesn't help with any of the things a checkout screen actually needs: cancelling the in-flight submit when the user leaves, sharing the draft with a confirmation screen, or testing the submit logic without a UI. rememberSaveable answers "how do I not lose this on rotation", when the real question is "what owns this".

Screen state lives in a ViewModel and survives configuration change; UI element state lives in the composition

The actual mechanism

There are two categories of state, and the official docs name them precisely:

UI element state — state a widget needs to do its job. Whether a dropdown is open, a scroll position, an animation's progress, whether a tooltip is showing. It is meaningless outside the UI, and it dies with the UI without anyone caring.

Screen (UI) state — what the screen is about. The address being entered, the list of items, whether a request is in flight, the error to display. It is the application's business, temporarily rendered.

The dividing question is lifetime: should this survive a configuration change and a trip through the back stack? If yes, it belongs in a ViewModel. If no, it belongs in the composition.

class CheckoutViewModel(private val repo: OrderRepo) : ViewModel() {
    private val _uiState = MutableStateFlow(CheckoutUiState())
    val uiState: StateFlow<CheckoutUiState> = _uiState.asStateFlow()

    fun onAddressChange(value: String) {
        _uiState.update { it.copy(address = value) }
    }

    fun submit() = viewModelScope.launch {
        _uiState.update { it.copy(isSubmitting = true) }
        val result = repo.placeOrder(_uiState.value.toOrder())
        _uiState.update { it.copy(isSubmitting = false, error = result.errorOrNull()) }
    }
}
@Composable
fun CheckoutScreen(vm: CheckoutViewModel = viewModel()) {
    val state by vm.uiState.collectAsStateWithLifecycle()

    // UI element state stays local — it has no business in the ViewModel
    var promoExpanded by remember { mutableStateOf(false) }

    CheckoutContent(
        state = state,
        promoExpanded = promoExpanded,
        onPromoToggle = { promoExpanded = !promoExpanded },
        onAddressChange = vm::onAddressChange,
        onSubmit = vm::submit,
    )
}

Rotation now changes nothing the user can see. The ViewModel instance is retained across the configuration change, viewModelScope keeps the in-flight submit alive, and the recreated composition simply re-subscribes to the same StateFlow.

Both directions are wrong

Everything in the ViewModel. A dropdownExpanded: Boolean in uiState means every menu toggle emits a new state object, which recomposes everything that collects it. It also drags UI concerns into a class you wanted to unit-test, and produces the UiState data classes with twenty-two fields that make Compose codebases unpleasant to work in.

Everything in the composition. Loses work on rotation, cancels in-flight requests on any recreation, and puts business logic somewhere it can only be tested with a UI test — which is fifty times slower than the JVM test it should have been.

The test that resolves most cases in one sentence: would a user be annoyed if this reset? Nobody notices a closed dropdown. Everyone notices a cleared address field.

One state flow, not five

Note that the ViewModel exposes one StateFlow of an immutable data class, not a flow per field:

data class CheckoutUiState(
    val address: String = "",
    val isSubmitting: Boolean = false,
    val error: String? = null,
)

Five separate flows can be observed at five different moments, so the UI can render a combination that never existed — isSubmitting = true with an error already set, because two collectors updated on different frames. One flow makes invalid combinations unrepresentable, which is the same single-source-of-truth argument as yesterday, applied one level up.

If the state has genuinely exclusive modes, model them as a sealed hierarchy rather than a bag of nullable fields:

sealed interface CheckoutUiState {
    data object Loading : CheckoutUiState
    data class Ready(val address: String, val isSubmitting: Boolean) : CheckoutUiState
    data class Failed(val message: String) : CheckoutUiState
}

Now "loading with an error showing" isn't a bug to remember not to write — it's a state the type system won't let you construct.

Collect with lifecycle, always

collectAsStateWithLifecycle() rather than collectAsState() is not a stylistic preference on Android. The plain version keeps collecting while the app is backgrounded, which keeps upstream flows hot — a location listener, a websocket, a polling loop — draining battery to update a UI nobody is looking at.

collectAsStateWithLifecycle stops collection at STOPPED and resumes at STARTED. Paired with SharingStarted.WhileSubscribed(5_000) on the stateIn side, the upstream also stops five seconds after the last subscriber leaves — long enough to survive a rotation, short enough that backgrounding actually releases the resource.

What the ViewModel does not survive

One boundary worth stating plainly, because it catches people who think a ViewModel is the answer to all persistence: it does not survive process death. The system can kill a backgrounded app to reclaim memory, and when the user returns, the ViewModel is gone along with everything in it.

For most screens that's acceptable — the user has been away, a reload is expected. For a half-finished form it isn't, and the answer is SavedStateHandle, which writes into the same bundle rememberSaveable uses:

class CheckoutViewModel(
    private val saved: SavedStateHandle,
) : ViewModel() {
    val address: StateFlow<String> = saved.getStateFlow("address", "")
    fun onAddressChange(v: String) { saved["address"] = v }
}

So the hierarchy is three levels, not two: composition (dies on rotation), ViewModel (dies on process death), saved state or disk (survives both). Each step up costs something — bundle size limits, serialisation, or I/O — which is why the default is the cheapest one that's correct rather than the most durable one available.

How to prove it

Rotation is the fastest test, and it's better as an automated one:

@Test fun addressSurvivesRecreation() {
    composeRule.onNodeWithTag("address").performTextInput("221B Baker Street")
    activityRule.scenario.recreate()
    composeRule.onNodeWithTag("address").assertTextContains("221B Baker Street")
}

For the classification itself, "Don't keep activities" in Developer Options is the blunt instrument — every navigation destroys and recreates, so anything misclassified as UI element state fails immediately rather than in the one QA session where someone happens to rotate.

What this generalizes to

The underlying principle is that ownership follows lifetime. Every layered architecture makes some version of this call: a value should be owned by the longest- lived thing that still dies when the value stops mattering. Put it lower and it dies too early; higher and it leaks past its usefulness.

Compose just makes the consequences immediate, because composition lifetime is short and visibly different from the Activity's. A framework where everything happened to live in one long-lived object let you avoid the question — and accumulate the leaks instead.

Tomorrow, Day 13: the hoisting mistakes that survive code review — over-hoisting, prop-drilling, and the callback chains that make a codebase harder to change than the one you replaced.


Day 12 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: State in Compose — ViewModels.