← All shorts
27Compose 1.0+2 min read

snapshotFlow: Bridging Compose State to Flow

Turn any Compose State read into a cold Flow that emits on distinct changes, with the full set of Flow operators.

Key a LaunchedEffect on a Compose object like LazyListState.layoutInfo and the whole effect restarts on every identity change. That's most scroll frames. snapshotFlow hands you a real Flow over Compose state instead, with real Flow operators to tame it.

#The old way

@Composable
fun ProductList(listState: LazyListState, onLoadMore: () -> Unit) {
    LaunchedEffect(listState.layoutInfo) {
        val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
        val total = listState.layoutInfo.totalItemsCount
        if (lastVisible >= total - 5) onLoadMore()
    }
}

#The new way

@Composable
fun ProductList(listState: LazyListState, onLoadMore: () -> Unit) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.layoutInfo }
            .map { info -> info.visibleItemsInfo.lastOrNull()?.index to info.totalItemsCount }
            .distinctUntilChanged()
            .collect { (lastVisible, total) ->
                if (lastVisible != null && lastVisible >= total - 5) {
                    onLoadMore()
                }
            }
    }
}

#Why it matters

  • A new value comes out only when the state read inside the block actually changes. That buys you map, filter, debounce, and distinctUntilChanged over composition state.
  • This is the clean way to react to scroll position, text field value, or animation progress, instead of hand-rolling a LaunchedEffect that restarts on every key change.
  • It's a normal Flow, so collectLatest cancels in-flight work when the state moves again before that work finishes. A prefetch request nobody needs anymore, for instance.

#Gotcha

snapshotFlow only produces values while something is actively collecting. It also tracks only the State objects that were actually read on the last successful run of its block.

That second constraint is the one that bites: state read behind an early return or an untaken branch won't trigger future emissions, so a conditional read inside the block can silently stop the flow from reacting to changes you expected it to catch.

androidcomposecoroutines

Related shorts