RizTech Academy logo
RizTech Academy
ArchitectureLesson 4 of 635 min

Modelling UI state as one object

Count what PricesRoute now collects: the query, the chosen chip, the filtered prices, the best market, and soon whether a refresh is running and a message to show when it finishes. Six flows, collected one by one, passed down as six parameters beside five callbacks. Module 5 called this out when the screen had three, and promised the fix: one object that is everything the screen shows at one moment. This lesson builds that object for the prices screen, gives the detail and Markets screens the same shape, and then deals with the part of UI state that is not a state at all, the "3 prices updated" message that must appear exactly once. It tries the two popular ways of doing that, measures them losing the message on the emulator, and uses the one Android's own guidance recommends.

The problem with six flows

Separate flows have three costs, and the third is the one that bites.

  • Size. PricesScreen has eleven parameters after lesson 2 and twelve after lesson 3's onRefresh, and each feature adds one or two more. Every preview spells all of them out.
  • Repetition. The route collects each flow on its own line, and each one needs a stateIn with an initial value in the ViewModel.
  • Moments that never existed. Each flow is collected separately, and nothing promises that a chip change and the filtered list it causes reach the screen in the same frame. The screen can be drawn with "Onion" selected above a list that has not caught up yet. With one object, the screen only ever draws a state the ViewModel produced as a whole.

PricesUiState

Create ui/PricesUiState.kt:

package com.riztechacademy.mandibhav.ui

import com.riztechacademy.mandibhav.model.CommodityPrice

/**
 * Everything the prices screen shows, at one moment, in one object. The
 * screen draws it; it never works anything out for itself.
 */
data class PricesUiState(
    /** What the user has typed in the search field. */
    val query: String = "",
    /** The commodity chip that is selected, or null for "All". */
    val selectedCommodity: String? = null,
    /** One chip per commodity, in the order it first appears. */
    val commodities: List<String> = emptyList(),
    /** The prices to show, already filtered. */
    val prices: List<CommodityPrice> = emptyList(),
    /** The best market for the chosen commodity; null when "All" is selected. */
    val bestMarket: CommodityPrice? = null,
    /** True while a refresh is running. */
    val isRefreshing: Boolean = false,
    /** A message to show once, such as "3 prices updated.", until the screen says it has. */
    val userMessage: String? = null,
) {
    /** The filters match nothing. */
    val noResults: Boolean get() = prices.isEmpty()
}

The decisions in it:

  • A data class of vals. It is immutable: a change is a new object made with copy or a constructor, never a field set in place. Compose compares the new object with the old by equals, which a data class writes for you, so an identical state recomposes nothing.
  • Defaults on every field. A preview or a test creates the state it needs, PricesUiState(selectedCommodity = "Tomato", prices = …), and nothing else.
  • noResults is worked out, not stored. A stored copy could disagree with prices; a property cannot.
  • It holds what the screen shows, not how it shows it. A list of prices, not a list of composables; a message string, not a snackbar.

One flow out of the ViewModel

PricesViewModel keeps its inputs private and publishes one StateFlow:

private val prices = MutableStateFlow(repository.todaysPrices())
private val query = savedStateHandle.getStateFlow(KEY_QUERY, "")
private val selectedCommodity = savedStateHandle.getStateFlow<String?>(KEY_COMMODITY, null)
private val isRefreshing = MutableStateFlow(false)
private val userMessage = MutableStateFlow<String?>(null)

/** The whole screen. Each input above changes it; nothing else does. */
val uiState: StateFlow<PricesUiState> =
    combine(prices, query, selectedCommodity, isRefreshing, userMessage) {
            prices, query, commodity, refreshing, message ->
        buildUiState(prices, query, commodity, refreshing, message)
    }.stateIn(
        scope = viewModelScope,
        // Keep building for 5 s after the screen stops collecting, so a
        // rotation (a second or less) does not restart the work.
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = buildUiState(
            prices.value, query.value, selectedCommodity.value, isRefreshing.value, userMessage.value,
        ),
    )

private fun buildUiState(
    prices: List<CommodityPrice>,
    query: String,
    commodity: String?,
    refreshing: Boolean,
    message: String?,
) = PricesUiState(
    query = query,
    selectedCommodity = commodity,
    commodities = commoditiesIn(prices),
    prices = filterPrices(prices, query, commodity),
    bestMarket = commodity?.let { findBestMarket(it) },
    isRefreshing = refreshing,
    userMessage = message,
)

The query and chip still live in the SavedStateHandle, so process death keeps them exactly as in module 5; uiState is built from them, not saved itself. buildUiState is called twice, for the initial value and for every change, so the two can never drift apart. The old commodities, query, selectedCommodity, visiblePrices and bestMarket properties are gone.

There is a second common way to hold UI state, one private MutableStateFlow<PricesUiState> changed with _uiState.update { it.copy(…) }. It suits state that only the ViewModel changes. Here the query and chip come from the SavedStateHandle and the prices from the repository, so combining them is simpler than copying each into a single mutable state and keeping it in step.

Module 5 set one rule for the search field: the value it shows must be set directly in onQueryChange, with no delay. It still is, but the value now reaches the field through combine. On the emulator, adb shell input text lasalgaon, nine characters as fast as adb sends them, arrived intact, and so did fifteen more in a second command, so the extra step does not drop or reorder keystrokes. Keep any slow work, a debounce or a database query, out of the path the query takes.

The route shrinks to one collection:

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

PricesScreen(
    uiState = uiState,
    onQueryChange = viewModel::onQueryChange,
    onCommodityClick = viewModel::onCommodityClick,
    onClearFilters = viewModel::clearFilters,
    onRefresh = viewModel::refresh,
    onUserMessageShown = viewModel::userMessageShown,
    onPriceClick = onPriceClick,
    modifier = modifier,
)

and PricesScreen's first parameter is uiState: PricesUiState where five parameters were. Inside, each query becomes uiState.query, prices becomes uiState.prices, and so on; val noResults = uiState.noResults. The events stay as separate lambdas: state flows down in one object, events flow up one by one, module 5's unidirectional data flow. The three previews each build a PricesUiState, and one of them now shows a state that was awkward to preview before, a refresh in progress:

uiState = PricesUiState(
    query = "pune",
    selectedCommodity = "Tomato",
    commodities = commoditiesIn(SamplePrices.all),
    prices = filterPrices(SamplePrices.all, query = "pune", commodity = "Tomato"),
    bestMarket = SamplePrices.puneTomato,
    isRefreshing = true,
),

Refreshing, and a double tap

refresh() now reports what it is doing:

/**
 * Fetches the latest prices and says how many changed. A tap while a
 * refresh is already running is ignored, so the user gets one answer.
 */
fun refresh() {
    if (isRefreshing.value) return
    isRefreshing.value = true
    viewModelScope.launch {
        try {
            val changed = repository.refresh()
            prices.value = repository.todaysPrices()
            userMessage.value = when (changed) {
                0 -> "Prices are up to date."
                1 -> "1 price updated."
                else -> "$changed prices updated."
            }
        } finally {
            isRefreshing.value = false
        }
    }
}

The screen shows a LinearProgressIndicator under the app bar while uiState.isRefreshing is true, and disables the button: TextButton(onClick = onRefresh, enabled = !uiState.isRefreshing). The finally puts isRefreshing back even if the refresh throws, which module 9's network will make possible; without it, one failed refresh would leave the bar spinning for good.

The guard in the first line is there because of a real bug. Without it, and with the button left enabled, two taps 150 ms apart on the emulator started two refreshes. The first changed three prices and set "3 prices updated."; the second found nothing new and replaced it with "Prices are up to date." The cards showed ₹2,525 under a snackbar saying nothing had changed. A farmer on a slow connection taps twice because the first tap seemed to do nothing, which is exactly when this happens.

A screen that is one of several things

The detail screen is not a list of independent values. It is exactly one of: the price was found, or it no longer exists (module 6's case). That is a sealed type, in PriceDetailViewModel.kt:

/** What the detail screen can show: exactly one of these at a time. */
sealed interface PriceDetailUiState {
    /** The id names no price (any more). */
    data object NotFound : PriceDetailUiState

    /** The price, its week, and the best market today for its commodity. */
    data class Found(val detail: PriceWeek, val bestMarket: CommodityPrice?) : PriceDetailUiState
}

and the ViewModel exposes it with the same shape as the prices screen, one StateFlow called uiState:

val uiState: StateFlow<PriceDetailUiState> = MutableStateFlow(
    repository.price(priceId)?.let { price ->
        PriceDetailUiState.Found(
            detail = priceWeek(price, repository.week(priceId)),
            bestMarket = findBestMarket(price.commodity),
        )
    } ?: PriceDetailUiState.NotFound,
).asStateFlow()

A StateFlow that never changes looks like ceremony, and today it nearly is. It is there so every screen is read the same way, viewModel.uiState.collectAsStateWithLifecycle(), and because lesson 6 makes this one change when the price does. The screen branches with when:

when (uiState) {
    // The id came from a saved back stack, and the price it named is
    // gone. Say so, rather than crash or show an empty screen.
    PriceDetailUiState.NotFound -> Text(
        text = "This price is no longer available.",
        style = MaterialTheme.typography.bodyLarge,
        modifier = Modifier
            .padding(innerPadding)
            .padding(16.dp),
    )
    is PriceDetailUiState.Found -> PriceDetailContent(
        detail = uiState.detail,
        bestMarket = uiState.bestMarket,
        innerPadding = innerPadding,
    )
}

with the old body of the screen moved, unchanged, into private fun PriceDetailContent(detail: PriceWeek, bestMarket: CommodityPrice?, innerPadding: PaddingValues). Because the interface is sealed, the when must cover every case or it does not compile, so when lesson 6 adds a Loading state, the compiler lists every screen that has to handle it.

Which shape to use:

The screen Use Mandi Bhav
Shows several things that change independently A data class with a field for each Prices: query, chip, list, refreshing, message
Is exactly one of a few different screens A sealed interface with one class per case Detail: found, or not found
Both A data class with a sealed field Not yet

The Markets screens get the same treatment for consistency: MarketsUiState(markets) and MarketUiState(market, prices), each exposed as uiState. One wrapper around one list is small, and lesson 6 adds a loading flag to both.

One-time events

"3 prices updated." should appear once, when the refresh finishes. It is not like the query or the list, which the screen shows for as long as they are true. It feels like an event, something that happens, and the common way to send events from a ViewModel is a stream the screen listens to. Here are the two versions you will see in other codebases, tried on the emulator. In a scratch copy, the ViewModel sends the message instead of storing it:

// The trap: one-time messages as a stream of events.
private val _messages = MutableSharedFlow<String>()
val messages: Flow<String> = _messages.asSharedFlow()

with _messages.emit(message) in refresh(), and the screen collects it only while it is started, as module 5 taught for flows:

// The trap: collecting one-time events while the screen is started.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(messages, lifecycleOwner) {
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        messages.collect { message ->
            android.util.Log.d("Events", "showing \"$message\"")
            snackbarHostState.showSnackbar(message)
        }
    }
}

The second version swaps the SharedFlow for a Channel, which holds on to a message nobody has collected yet:

// The trap: one-time messages as a stream of events.
private val _messages = Channel<String>(Channel.BUFFERED)
val messages: Flow<String> = _messages.receiveAsFlow()

with _messages.send(message). Two tests for each, both ordinary afternoons for a farmer: tap "Refresh" and press Home before it finishes, then come back from Recents; and tap "Refresh" and rotate the phone while the snackbar is showing.

How the message travels Home during the refresh Rotated while the snackbar showed
SharedFlow Lost: logged sending "3 prices updated.", never shown Lost: shown, then gone after the rotation
Channel Shown on return Lost: shown, then gone after the rotation
UI state, below Shown on return Shown again after the rotation

The SharedFlow lost it on Home because nobody was collecting when it was emitted, and a SharedFlow without a replay drops what nobody receives. The Channel kept it for the return, but on rotation the message had already been taken out of the channel by the old screen, whose showSnackbar was cancelled when that screen was destroyed. Nothing can put it back. The message was delivered, the screen went away, and the message went with it.

Android's guidance on UI events is direct about this. It says events that originate in a ViewModel should always become a UI state update, and notes that Channels and other streams do not guarantee the event is delivered and processed when the ViewModel outlives the screen, which it always does. The recommended shape is the one in PricesUiState: a userMessage field that stays set until the screen reports it has been shown.

// A one-time message from the ViewModel, such as "3 prices updated.". It
// stays in the state until it has been shown, so a rotation in the
// middle shows it again rather than losing it.
uiState.userMessage?.let { message ->
    val currentOnUserMessageShown by rememberUpdatedState(onUserMessageShown)
    LaunchedEffect(message) {
        snackbarHostState.showSnackbar(message)
        currentOnUserMessageShown()
    }
}

and in the ViewModel:

/** The screen has shown [PricesUiState.userMessage]; stop asking it to. */
fun userMessageShown() {
    userMessage.value = null
}

showSnackbar suspends until the snackbar goes away, and only then does the screen call onUserMessageShown. If a rotation cancels the effect first, the message is still in the state, and the new screen's effect shows it again; if the app is in the background, collectAsStateWithLifecycle delivers the state when it returns. It is module 5's LaunchedEffect keyed on state, and the same rememberUpdatedState as the no-results snackbar. The message is state until the screen says it has been shown; after that it is gone because the screen said so, not because something was lost.

What it costs: a function and a field per kind of message, and the screen must remember to call it. Forget currentOnUserMessageShown() and the message reappears after every rotation. And it is not for everything that looks like an event: a tap that navigates is handled where the tap happens, as module 6 does with onPriceClick, because it starts in the UI. This pattern is for things the ViewModel decides.

Two tests pin it down: after a refresh that changes nine prices, uiState.value.userMessage is "9 prices updated." until userMessageShown() clears it; and a refresh that changes nothing says "Prices are up to date.". With a test for the best market on choosing a chip and the existing ones moved to uiState, PricesViewModelTest has ten tests, and ./gradlew testDebugUnitTest runs 52.

Check your work

Why one object instead of six flows. Fewer parameters and collections, and the screen only ever draws a state the ViewModel produced as a whole, never a half-updated one.

Why noResults is a property rather than a field. A stored copy could disagree with prices.

What the double tap did without the guard. Started two refreshes; the second said "Prices are up to date." over prices that had changed a moment before.

When to use a sealed interface for UI state. When the screen is exactly one of a few different things, such as found or not found.

Why the SharedFlow lost the message on Home. Nobody was collecting when it was emitted, and it has no replay.

Why the Channel lost it on rotation. The old screen had already received it, and its snackbar was cancelled with it.

What the screen must do with userMessage. Show it, and only then call onUserMessageShown.

Practice 4. On the emulator the snackbar showed and timed out, and then "3 prices updated." appeared again after the rotation, as it will after every one. The ViewModel never heard that it was shown, so as far as the state is concerned the message is still waiting. It is the bug this pattern is most often blamed for, and it is always a missing call.

Practice

  1. Add PricesUiState, the new PricesViewModel, route and screen, the progress bar and the disabled button. Give the detail and Markets screens their UI-state types. Run the 52 tests.
  2. Run the double-tap experiment: remove the guard and the enabled, tap "Refresh" twice quickly (adb shell "input tap 970 215; input tap 970 215" on a 1080-pixel-wide screen), and read the snackbar. Put both back.
  3. Build the SharedFlow version in a scratch copy and run the Home test and the rotation test. Then the Channel version. Fill in your own copy of the table.
  4. Break it on purpose: delete currentOnUserMessageShown() from the effect. Predict what happens across a rotation, then run it.
  5. Add a third state to the detail screen's sealed interface, say Withdrawn(reason: String) for a price the market has withdrawn, and build. Read where the compiler stops you, then remove it.
  6. Harder: module 10 adds price alerts, and saving one should show "Alert saved for onion at Lasalgaon above ₹2,500". Decide whether that message belongs to the alert editor's UI state or to the screen the user returns to, and what happens to it if the user presses Back before it is shown. Write the UI-state class you would use.

Official documentation

  • UI events — Why ViewModel events should become UI state, the userMessage pattern, and the note on Channels.
  • State holders and UI state — What belongs in a UI-state class and which holder owns it.
  • UI layer — UI state as one immutable object, and unidirectional data flow.
  • ViewModel: One-off event antipatterns — Not documentation, but the Android Developers team's longer argument, with the same Channel cases this lesson measured.

Next: Hilt, which replaces the hand-built container.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship