Flows from the data layer to the screen
Lesson 3 left a bug on purpose. Kavita has Lasalgaon onion open on the Markets tab, at ₹2,450, "the best price for onion today". On the Prices tab she taps "Refresh", and the 4 pm prices arrive: Lasalgaon ₹2,525, Nashik ₹2,560. She switches back to Markets, and the screen still says ₹2,450 and still calls Lasalgaon the best. There is one repository and it holds the truth, but this screen asked once, when it opened, and nothing has told it since. The fix is to stop asking once. This lesson changes the repository's answers from values into flows, streams of values that keep arriving for as long as somebody watches, and follows them through the use case and the ViewModels to the screen. Then it measures two mistakes that take one line to make with flows and are hard to see.
A question that keeps answering
Module 5 introduced StateFlow as a holder of one current value that others can observe. A Flow is the general idea: something you collect, which hands you values over time. Google's data-layer guide draws the line this module uses:
| The data layer exposes | For | In Mandi Bhav |
|---|---|---|
A suspend function |
A one-shot operation: do it once, get one result | refresh() |
A Flow |
Data that can change while someone is looking | todaysPrices(), price(id), pricesAt(market), week(id) |
So the interface keeps its questions and their names, and changes what they return:
interface PriceRepository {
/** Today's prices, every commodity at every market. */
fun todaysPrices(): Flow<List<CommodityPrice>>
/** One price by its id; null if there is no such price (any more). */
fun price(id: String): Flow<CommodityPrice?>
/** Today's prices at one market. */
fun pricesAt(market: String): Flow<List<CommodityPrice>>
/**
* The last seven days of modal prices for the price with [id], oldest
* first and ending on that price's date; empty if there is no history.
*/
fun week(id: String): Flow<List<DayPrice>>
/** Fetches the latest prices. Returns how many prices changed. */
suspend fun refresh(): Int
}
with kotlinx.coroutines.flow.Flow imported. This is also the shape module 8 needs: a Room query can return a Flow that emits again whenever the table changes, so the database will slot in behind this interface as it stands.
The repository, as flows
In SamplePriceRepository, the private list becomes a MutableStateFlow, and every answer is worked out from it:
private val prices = MutableStateFlow(initialPrices)
override fun todaysPrices(): Flow<List<CommodityPrice>> = prices.asStateFlow()
override fun price(id: String): Flow<CommodityPrice?> =
prices.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
override fun pricesAt(market: String): Flow<List<CommodityPrice>> =
prices.map { all -> all.filter { it.market == market } }.distinctUntilChanged()
override fun week(id: String): Flow<List<DayPrice>> =
price(id).map { price -> price?.let { SamplePrices.weekOf(it, weekOfModalPrices) }.orEmpty() }
and refresh writes to it:
override suspend fun refresh(): Int {
delay(networkDelayMillis)
val closing = closingPrices.associateBy { it.id }
var changed = 0
prices.update { current ->
val updated = current.map { closing[it.id] ?: it }
changed = updated.indices.count { updated[it] != current[it] }
updated
}
return changed
}
The pieces, each of which you will use again:
| Piece | What it does here |
|---|---|
MutableStateFlow(initial) |
Holds the current list; every collector gets it at once, then each new one |
asStateFlow() |
A read-only view, so no caller can cast it back and change the prices |
map { } |
A new flow whose values are worked out from another's |
distinctUntilChanged() |
Drops a value equal to the last one, so a screen is not told about changes that did not touch it |
update { } |
Changes a MutableStateFlow safely even if two threads try at once: it retries if the value changed under it |
The week calculation moves into SamplePrices.weekOf(price, history), a plain function, so the detail screen's preview and PriceWeekTest can still build a week without collecting a flow.
distinctUntilChanged earns its place with a test. A refresh changes three prices; Pune onion is not one of them. Watching both while a refresh runs:
@Test
fun aPriceTheRefreshDidNotChangeEmitsNothingNew() = runTest {
// Everything each flow emits while a test "screen" watches it.
val lasalgaon = mutableListOf<Long?>()
val pune = mutableListOf<Long?>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
repository.price(SamplePrices.lasalgaonOnion.id).collect { lasalgaon += it?.modalPrice }
}
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
repository.price(SamplePrices.puneOnion.id).collect { pune += it?.modalPrice }
}
repository.refresh() // three prices change; Pune onion is not one of them
assertEquals(listOf<Long?>(2_450, 2_525), lasalgaon)
assertEquals(listOf<Long?>(2_200), pune)
}
Without distinctUntilChanged, Pune onion's list is [2200, 2200]: the whole price list changed, so its map ran again and produced an equal price, and a Pune onion screen would rebuild its state for nothing. The other SamplePriceRepositoryTest tests read one value with first(), which collects until the first value and stops.
The use case, as a flow
The best market for onion changes when prices do, so FindBestMarketUseCase returns a flow too:
class FindBestMarketUseCase @Inject constructor(private val repository: PriceRepository) {
operator fun invoke(commodity: String): Flow<CommodityPrice?> =
repository.todaysPrices()
.map { prices -> bestMarketFor(prices, commodity) }
.distinctUntilChanged()
}
bestMarketFor from lesson 2 has not changed, and neither have its tests. The rule is still a plain function over a list; only the plumbing around it moved. FindBestMarketUseCaseTest checks the plumbing: Lasalgaon first, then Nashik after the fake's prices change.
The detail screen watches its price
The ViewModel now builds its state from three flows, and one of them depends on another: it cannot ask for the best onion market until it knows the price is onion.
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel(assistedFactory = PriceDetailViewModel.Factory::class)
class PriceDetailViewModel @AssistedInject constructor(
@Assisted priceId: String,
repository: PriceRepository,
findBestMarket: FindBestMarketUseCase,
) : ViewModel() {
val uiState: StateFlow<PriceDetailUiState> =
repository.price(priceId).flatMapLatest { price ->
if (price == null) {
flowOf(PriceDetailUiState.NotFound)
} else {
// The commodity is only known once the price has arrived, so
// the best-market question is asked inside, and asked again
// whenever the price changes.
combine(repository.week(priceId), findBestMarket(price.commodity)) { week, best ->
PriceDetailUiState.Found(detail = priceWeek(price, week), bestMarket = best)
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PriceDetailUiState.Loading,
)
flatMapLatesttakes each price and turns it into a whole new flow, and when the next price arrives it cancels the previous inner flow and switches to the new one. "Latest" is the point: only the newest price's question is still being asked. It is marked experimental in kotlinx.coroutines, hence the@OptIn. The course uses it anyway because no stable operator does this job, and Google's Now in Android sample uses it in both itsViewModels and its repositories.flowOf(x)is a flow of one value, for the case with nothing to watch.combinefrom module 5 joins the week and the best market into one state each time either changes.stateInwithWhileSubscribed(5_000)is module 5's, and now it matters more: while the screen is collecting, the repository is being watched; five seconds after the screen goes away, the whole chain stops.
There is a new state before anything has arrived, so the sealed interface gains one:
/** The repository has not answered yet. */
data object Loading : PriceDetailUiState
and the compiler stops at the screen's when, as lesson 4 said it would, until it has a branch:
// Draw nothing until the repository answers. From memory that
// is the next frame; a spinner would only flash.
PriceDetailUiState.Loading -> Unit
How long "loading" lasts is worth measuring rather than guessing. With a temporary SideEffect in each route logging what it drew and SystemClock.uptimeMillis():
D Flows (10995): prices drew isLoading=true prices=0 at 45048443
D Flows (10995): prices drew isLoading=false prices=9 at 45048613
D Flows (10995): detail drew Loading at 45050373
D Flows (10995): detail drew Found at 45050401
28 ms for the detail screen, under two frames; 170 ms for the prices screen at a cold start, while everything else was starting too. A progress indicator shown for 28 ms is a flicker, so the detail screen draws nothing. Module 8's database will take longer, and module 9's network much longer, and those modules decide what to show then.
The prices screen, and the limit of combine
PricesViewModel now has five inputs and a sixth on the way: the repository's prices, the query, the chip, the best market, whether a refresh is running, and the message. The first attempt joins all six:
val six = combine(flowOf(1), flowOf(2), flowOf(3), flowOf(4), flowOf(5), flowOf(6)) { a, b, c, d, e, f -> a + b + c + d + e + f }
and does not compile:
e: Combine6.kt:6:85 Argument type mismatch: actual type is 'suspend (Array<Int>, ??? (Unknown type for value parameter b), …) -> uninferred R (of fun <T, R> combine)', but 'suspend (Array<Int>) -> uninferred R (of fun <T, R> combine)' was expected.
combine has versions with named, typed parameters for two to five flows. Beyond five there is only the one that hands you an Array, and you lose the types. The better answer is usually that two of the inputs belong together. The refresh flag and its message do, so they become one private class:
/** What the refresh button has set going: whether it is running, and what to tell the user. */
private data class RefreshStatus(val isRefreshing: Boolean = false, val userMessage: String? = null)
private val query = savedStateHandle.getStateFlow(KEY_QUERY, "")
private val selectedCommodity = savedStateHandle.getStateFlow<String?>(KEY_COMMODITY, null)
private val refreshStatus = MutableStateFlow(RefreshStatus())
/** The best market for the chosen commodity; switches when the chip does. */
private val bestMarket = selectedCommodity.flatMapLatest { commodity ->
if (commodity == null) flowOf(null) else findBestMarket(commodity)
}
/** The whole screen. Each input above changes it; nothing else does. */
val uiState: StateFlow<PricesUiState> =
combine(
repository.todaysPrices(), query, selectedCommodity, bestMarket, refreshStatus,
) { prices, query, commodity, best, status ->
PricesUiState(
isLoading = false,
query = query,
selectedCommodity = commodity,
commodities = commoditiesIn(prices),
prices = filterPrices(prices, query, commodity),
bestMarket = best,
isRefreshing = status.isRefreshing,
userMessage = status.userMessage,
)
}.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),
// Until the repository answers, the filter is already known: the
// search field shows it at once, even after process death.
initialValue = PricesUiState(
isLoading = true,
query = query.value,
selectedCommodity = selectedCommodity.value,
),
)
refresh() no longer reads the prices back: the new ones arrive through repository.todaysPrices() like any other change, and the function only sets refreshStatus with update { it.copy(…) }. The private prices copy from lesson 3 is gone, and with it the last place the ViewModel held data of its own. PricesUiState gains isLoading, and the progress bar shows while isLoading || isRefreshing. The initial value carries the saved query, so after process death the search field shows "lasalgaon" from the first frame, not an empty field that fills in a moment later.
The trap: "no results" before anything has loaded
Lesson 4's noResults was prices.isEmpty(). While loading, prices is empty too, so for a moment the screen believes the filters match nothing. With the old definition and a log line in the no-results effect, a cold start:
D Flows (11118): prices drew isLoading=true prices=0 at 45082638
D Flows (11118): no-results snackbar shown at 45082786
D Flows (11118): prices drew isLoading=false prices=9 at 45082802
The "No prices match these filters." snackbar started, with its "Clear filters" action, and was cancelled 16 ms later when the prices arrived; the "No prices match" line was in the list for the first frames as well. From memory it is a flicker. On module 9's network it is two or three seconds of a farmer being told her search matched nothing before it has run, with a button offering to clear filters she never set. Empty because nothing matched and empty because nothing has loaded are different states, so the property says so:
/** The filters match nothing. Not while loading: nothing has been matched yet. */
val noResults: Boolean get() = !isLoading && prices.isEmpty()
The Markets screens get the same treatment: MarketsUiState and MarketUiState gain isLoading, their ViewModels map the repository's flows into them with stateIn, and MarketScreen only says "No prices from this market today." when it is not loading.
The second trap: a flow built on every read
A uiState that is written as a getter instead of a property looks almost identical:
// The trap: a getter, so every read builds a new flow.
val uiState: StateFlow<PriceDetailUiState>
get() = repository.price(priceId).flatMapLatest { price ->
with the rest unchanged, and the constructor parameters turned into private vals so the getter can reach them. It compiles. It shows the right price. To see the difference, the scratch copy counts how often the repository's price flow starts being collected, with onStart:
override fun price(id: String): Flow<CommodityPrice?> =
prices.map { all -> all.firstOrNull { it.id == id } }.distinctUntilChanged()
.onStart { android.util.Log.d("Flows", "price($id) collection started, ${++started} so far") }
Open Lasalgaon onion and rotate twice. With the property:
D Flows (11410): detail drew Loading
D Flows (11410): price(lasalgaon-onion-2026-11-07) collection started, 1 so far
D Flows (11410): price(lasalgaon-onion-2026-11-07) collection started, 2 so far
D Flows (11410): detail drew Found
D Flows (11410): detail drew Found
D Flows (11410): detail drew Found
Two collections, one for the price and one inside week, for the whole visit, and each rotation drew the finished screen straight away. With the getter, the same steps ended at:
D Flows (11231): price(lasalgaon-onion-2026-11-07) collection started, 12 so far
and every rotation drew Loading again before Found. Each time the route read viewModel.uiState, the getter built a brand-new chain and a new stateIn coroutine in viewModelScope, starting from Loading; collectAsStateWithLifecycle saw a different flow and started collecting it from scratch. The old chains keep running until WhileSubscribed stops them five seconds later. Build a ViewModel's flows once, in a property initialiser, never in a getter or a function the screen calls. The same goes for a composable that calls repository.price(id) in its body: it builds a new flow on every recomposition.
What the farmer sees now
The steps from the start of the lesson, on the emulator: Markets, Lasalgaon APMC, Lasalgaon onion; Prices, "Onion", "Refresh"; back to Markets. The screen that was open the whole time:
'₹2,525 per quintal'
'₹105 up on yesterday'
'Best today: Nashik APMC, ₹2,560'
Nobody reopened it. The refresh changed the repository's one MutableStateFlow; the price flow for this id emitted the new price; flatMapLatest asked the week and the best market again; combine produced a new Found; and collectAsStateWithLifecycle recomposed the screen. One Back away, Lasalgaon APMC's own list already showed ₹2,525 as well. That chain, from one source of truth to every screen watching it, is what the whole module has been building.
Testing flows
The fake repository holds its prices in a MutableStateFlow too, behind a prices property a test can set:
private val current = MutableStateFlow(prices)
/** The prices the repository holds now. Setting it is "the data changed". */
var prices: List<CommodityPrice>
get() = current.value
set(value) {
current.value = value
}
with every query answered by current.map { … }. A test that the open screen follows the data reads like the bug report it prevents:
@Test
fun anOpenScreenFollowsTheRepository() = runTest {
// Kavita has Lasalgaon onion open when the 4 pm prices arrive.
val repository = FakePriceRepository()
val viewModel = viewModelFor(SamplePrices.lasalgaonOnion.id, repository)
collectUiState(viewModel)
assertEquals(2_450L, viewModel.found().detail.price.modalPrice)
repository.prices = SamplePrices.all.map { price ->
SamplePrices.closingPrices.firstOrNull { it.id == price.id } ?: price
}
assertEquals(2_525L, viewModel.found().detail.price.modalPrice)
assertEquals(SamplePrices.closingPrices[1], viewModel.found().bestMarket) // Nashik, 2,560
}
collectUiState is module 5's pattern, a collector in backgroundScope with an UnconfinedTestDispatcher, because a WhileSubscribed flow does nothing until someone collects it; the detail tests now need MainDispatcherRule for the same reason. A test without a collector checks the other side: before anyone collects, uiState.value is Loading. The new tests: a price that disappears while open becomes NotFound; the Markets list follows the repository; the prices list follows the repository without a refresh; and while loading, the saved query is already there and noResults is false. ./gradlew testDebugUnitTest runs 58.
Check your work
Why the open screen kept ₹2,450 after lesson 3. It read the repository once, when it opened; nothing told it about the change.
When the data layer exposes a Flow and when a suspend function. A Flow for data that can change while someone watches; a suspend function for a one-shot operation such as a refresh.
What distinctUntilChanged saved. Pune onion's screen being told about a refresh that did not change Pune onion.
Why the detail ViewModel needs flatMapLatest. The best-market question depends on the commodity, which is only known once the price arrives, and must be asked again when it changes.
Why combine stopped at five. Its typed versions take two to five flows; beyond that only an untyped array version exists.
What the "no results" trap showed. A no-results snackbar started before any prices had loaded, because empty-while-loading looked like empty-because-nothing-matched.
What the getter did. Built a new flow chain on every read: twelve collections instead of two, and a Loading frame after every rotation.
Practice 4. Nothing on the open detail screen changes after the refresh. Three tests fail: anOpenScreenFollowsTheRepository with expected:<2525> but was:<2450>; aPriceThatDisappearsWhileOpenBecomesNotFound, still Found for a price that has gone; and isLoadingUntilSomebodyCollects, because the init block loads at once whether or not anyone is watching. first() collects one value and stops, so the ViewModel has the morning's price and no subscription left. Turning a flow back into a single read undoes everything this lesson did, which is why Mandi Bhav keeps first() for tests.
Practice 6. On the emulator: two new collections after ten seconds away, none after two seconds away, and no Loading frame either time. collectAsStateWithLifecycle stopped collecting when the app went to the background; five seconds later WhileSubscribed(5_000) stopped the chain, so the repository was no longer watched. On return the chain started again, while the StateFlow kept showing its last value until the new one arrived. Two seconds was inside the five-second grace, so nothing restarted. The cost is one re-read of memory, or in module 8 one database query, after a long absence; the saving is no work at all while the farmer is in another app. Worth it.
Practice
- Change the interface, the repository, the use case, the four
ViewModels andFakePriceRepositoryto flows; addLoadingand theisLoadingflags; fixnoResults. Run the 58 tests, then the open-screen check on the emulator. - Add the
SideEffectlogging to both routes and measure your own loading times, cold and warm. Remove it afterwards. - Reproduce the "no results" trap with the old
noResults, then reproduce the getter trap with theonStartcounter, and compare your counts with the ones here. - Break it on purpose: in
PriceDetailViewModel, replace the flow chain with one read,repository.price(priceId).first()insideviewModelScope.launch, stored in aMutableStateFlow. Predict what the open screen does after a refresh, and whatanOpenScreenFollowsTheRepositorysays, then run both. - Remove
distinctUntilChanged()frompriceand runaPriceTheRefreshDidNotChangeEmitsNothingNew. Read the failure and explain each value in the list. - Harder: press Home on the detail screen, wait ten seconds, and come back, with the
onStartcounter in place. Predict how many new collections you will see, and why, from whatWhileSubscribed(5_000)andcollectAsStateWithLifecycleeach do. Then check, and decide whether it is a cost worth paying.
Official documentation
- Kotlin flows on Android — Creating, transforming and collecting flows, with the data layer as the producer.
- StateFlow and SharedFlow —
stateIn,WhileSubscribed, and why aStateFlowsuits UI state. - Asynchronous Flow — The Kotlin reference: operators,
combine,flatMapLatestand cancellation. - Testing Kotlin flows on Android — Collecting in
backgroundScope, fakes that emit, and testingStateFlows.
Next: module 8, Storing Data on Device.
That is module 7. You can now:
- explain why an Android app needs an owner for its data that outlives screens, and measure when one is missing;
- sort code into UI, domain and data layers, keep dependencies pointing one way, and decide when a domain layer has been earned;
- put data behind a repository interface that is the single source of truth, and test against a fake instead of a mock;
- model a screen's state as one immutable object, a data class or a sealed interface, and show one-time messages as state the UI consumes, not as an event stream that can lose them;
- wire an app with Hilt, including
@Binds, scopes and assisted injection for navigation arguments, and read its build errors; - expose data as flows and follow it to the screen with
map,combine,flatMapLatestandstateIn, without rebuilding the chain on every read.
Mandi Bhav now has one repository behind an interface, a best-market rule two screens share, a refresh that reaches every open screen, and a message that survives rotation. Its prices still live in memory, so when Android kills the process, the 4 pm prices go with it and the morning's come back. Module 8, Storing Data on Device, fixes that: DataStore for the farmer's preferences, and Room, a database that returns flows, behind the same PriceRepository. The screens will not change.
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