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, anddistinctUntilChangedover composition state. - Scroll position, text field value, animation progress — this is the clean way to react to any of them, instead of hand-rolling a
LaunchedEffectthat restarts on every key change. - It's a normal
Flow, socollectLatestcancels in-flight work — a prefetch request, say — when the state moves again before that work finishes.
#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 part 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.