Case study

ReadThat 10: Kotlin Flows in Practice

5 min read✦ AI generated with human guidance & review

How Flow wires the production KMP client: immutable state with combine/stateIn, Room-backed Paging retained with cachedIn, off-main structural derivation, and destination-scoped flatMapLatest switching.

Part 10 of the ReadThat case study.

📲 Try it live: Download the ReadThat APK (8.3 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.

Flow is the wiring of the production KMP client: Room emits, repositories transform, the shared ViewModel/controllers derive immutable state, and Compose collects. The former standalone teaching-only :flows module has been removed; the patterns below are the live implementations that ship on Android and iOS, with their tests beside the owning modules.

Example 1: One immutable auth state with combine(...).stateIn(...)

Authentication has three independently changing inputs—secure-session state, form state, and a recovery message—but the UI receives one coherent snapshot:

val state: StateFlow<SharedAuthState> = combine(
source.session,
mutableForm,
mutableMessage,
) { session, form, message ->
SharedAuthState(session, form, source.enabled, message)
}.stateIn(
coroutineScope,
SharingStarted.Eagerly,
SharedAuthState(source.session.value, backendEnabled = source.enabled),
)

SharedAuthController.kt#L71-L85

Why each piece is there:

  • One state object, not a bundle of flows: an emission cannot pair a new session with stale form enablement or an obsolete message.
  • The initial value is meaningful: Compose can render immediately, before the first combined emission.
  • Eagerly is intentional here: auth is application-scoped, so its combined projection stays synchronized before and between auth-screen collectors. Screen-derived work uses a different policy; the detail render flow below uses WhileSubscribed(5_000).

The controller also serializes restore/auth jobs, but those commands only mutate the inputs above. There is still one public state stream and one source of UI truth.

Example 2: Observing Room

Room queries returning Flow/PagingSource are the mechanism that makes “Room is the source of truth” reactive rather than aspirational. The feed path:

@Transaction
@Query(
"""SELECT * FROM feed_groups
WHERE accountId = :accountId AND feedId = :feedId
ORDER BY sortIndex ASC"""
)
fun pagingSource(accountId: String, feedId: String): PagingSource<Int, GroupWithState>

FeedDao.kt#L17-L47 (condensed)

Room invalidates this PagingSource on any write to either joined table. That single fact powers the app’s best trick, the optimistic vote:

toggleLike(itemId)
└─ one Room transaction: update item_state + insert vote_outbox
└─ Room invalidates the PagingSource
└─ Paging re-emits the affected page
└─ Compose recomposes the one changed row

No manual list surgery, no second page list to keep consistent, and the same optimistic state row is visible in feed, MediaFeed, and detail. The shared ReadThatViewModel retains the grouped Room pages once, then maps them into independently keyed SDUI cells:

private val cachedPagedFeed = feedController.cards.cachedIn(viewModelScope)
val pagedFeedCells: Flow<PagingData<CellUi>> = cachedPagedFeed.map { paging ->
paging.flatMap { card -> card.cells }
}

ReadThatViewModel.kt#L212-L221

cachedIn matters: a new collector reuses the same loaded pages rather than building a new Pager generation. Non-paging Room observation follows the same shape elsewhere: the community drawer combines account-scoped membership, visit, and sync-state DAO flows, then publishes a retained snapshot (OfflineFirstRepository.kt#L321-L343).

Example 3: Expensive derivation only when structure changes

Comments demonstrate why not every property belongs in one giant combine. Loading flags can change frequently; flattening a deep tree is expensive. The shared projection first removes every field that cannot affect rows:

internal fun Flow<DetailState>.commentRenderLists(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): Flow<CommentRenderList> = map { state ->
DetailStructure(
tree = state.comments,
collapsed = state.collapsedCommentIds + state.autoCollapsedCommentIds,
)
}.distinctUntilChanged().map { structure ->
structure.tree?.let { CommentFlattener.flatten(it, structure.collapsed) }
?: CommentRenderList(emptyList(), 0, 0)
}.flowOn(dispatcher)

SharedDetailPresentation.kt

The ordering is the point:

  • Project first, so errors, spinners, and drafts cannot invalidate comment rows. Vote/score changes remain inside the tree and correctly do produce new rows.
  • Apply distinctUntilChanged to the small structural key.
  • Flatten only after distinctness, and move that work off the UI thread with flowOn.
  • Retain the result in the shared ViewModel with SharingStarted.WhileSubscribed(5_000), avoiding background work while spanning short collector gaps (ReadThatViewModel.kt#L258-L263).

Example 4: Switch destination-owned streams with flatMapLatest

MediaFeed, search, and community pages have destination-scoped controllers. The application ViewModel exposes only the active controller’s stream:

val pagedMediaFeed: Flow<PagingData<MediaFeedItem>> = activeMediaFeedController
.flatMapLatest { controller ->
controller?.feed ?: flowOf(PagingData.empty())
}
val sharedSearchState: StateFlow<SharedSearchUiState> = activeSearchController
.flatMapLatest { controller ->
controller?.state ?: flowOf(SharedSearchUiState())
}
.stateIn(viewModelScope, SharingStarted.Eagerly, SharedSearchUiState())

ReadThatViewModel.kt#L223-L246

flatMapLatest cancels collection from the old destination as soon as a new controller becomes active, so a late search or media emission cannot repaint a different screen. Search applies the same pattern one layer down: (query, type, sort, time, safe) maps to a request, distinctUntilChanged suppresses equivalent requests, and flatMapLatest switches the Room-backed Pager (SharedSearchController.kt#L107-L114).

These patterns are tested where they ship: auth state, feed Paging integration, and search policy.


← Part 9: Networking deep dive · Next: Part 11: Kotlin Multiplatform →