Typing in the search field stutters — find the wasted recomposition and fix it
This screen shows a long order list with a search field. The list itself scrolls smoothly, but every keystroke in the field drops frames — and the longer the list, the worse it gets. Recomposition counts show OrderScreen recomposing on every character typed.
Constraints: keep the sorted-then-filtered result the user sees, do not move the data into the ViewModel, and do not do the work on another thread — fix the composition itself.
@Composable
fun OrderScreen(vm: OrderViewModel) {
val orders by vm.orders.collectAsState()
var query by remember { mutableStateOf("") }
val sorted = orders.sortedWith(orderComparator) // heavy for a long list
Column {
SearchField(value = query, onValueChange = { query = it })
LazyColumn {
items(sorted.filter { it.title.contains(query, ignoreCase = true) }) { OrderRow(it) }
}
}
}
Find and fix the bug.
Reading query in OrderScreen recomposes its whole body on every keystroke, and sorted is an ordinary local, so the list is re-sorted each time even though orders never changed. Cache the sort with remember(orders), and read query in the smallest composable that needs it.
- ✗Assuming a derivation written inside a composable runs once, not on every recomposition
- ✗Reading state at the top of a screen and expecting only the child that uses it to recompose
- ✗Treating recomposition count as free and looking only at the cost of drawing the frame
- →When is
derivedStateOfthe right tool instead of a plainremember(key)? - →Which tool gives a per-composable recomposition count, and what does a high skipped count mean?
The bug
Every keystroke writes query. That state is read straight inside OrderScreen, so the whole body of the screen recomposes — and orders.sortedWith(...) runs again with it. An ordinary local caches nothing across recompositions, so the long list is re-sorted on every character even though orders never changed.
var query by remember { mutableStateOf("") }
val sorted = orders.sortedWith(orderComparator) // ❌ recomputed on every recomposition
The fix
Cache the sort in a remember keyed on its input, so it re-runs only when orders changes. Then narrow the state read: let query live in the composable that shows it, and hand the list a ready value.
val sorted = remember(orders) { orders.sortedWith(orderComparator) } // ✅ only on new orders
val visible by remember {
derivedStateOf { sorted.filter { it.title.contains(query, ignoreCase = true) } }
}
Verify with recomposition counts (Layout Inspector or composition tracing): OrderScreen's recompositions per character should drop, and the heavy work should disappear from the frame.
The trap
A key-less remember caches forever and shows a stale list; a remember(query) puts you right back to re-sorting on every character. The key is exactly the input of the computation.