ReadThat 10: Kotlin Flows in Practice
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.
Eagerlyis 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 usesWhileSubscribed(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 rowNo 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)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
distinctUntilChangedto 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 →