Email copiado — support@tuurt.com
Cargando experiencia
kotlin · September 20, 2026 · 6 min

Recomposition in Jetpack Compose: why a whole list redraws instead of a single row

A two-hundred-item LazyColumn recomposes in full when only one row changed. We look at why position-based keys and inline lambdas break Compose's skipping, and how to fix it with key, remember, and derivedStateOf.

By Tuurt Team

Recomposition in Jetpack Compose: why a whole list redraws instead of a single row

Jetpack Compose promises that it only recomposes what changed. That promise holds up fine on simple screens and breaks down fast once you add a LazyColumn with a couple hundred rows and a piece of state that changes often, like an unread counter or a favorite toggle the user taps while still scrolling. The symptom is always the same: tap one row, and the profiler shows the entire list recomposing, not just the affected row. Understanding why means looking at how Compose actually decides what to re-run.

How Compose decides what to recompose

The Compose compiler instruments every @Composable function to track which state values it reads while running. When one of those values changes, the runtime doesn't re-run the whole hierarchy from the root: it looks up the set of recomposition scopes that actually read that value and only re-runs those, skipping everything that doesn't depend on it. In theory, that's exactly what lets tapping a MutableState<Boolean> living inside a single row of a two-hundred-item list recompose only that row.

The trouble starts when the recomposition scope turns out to be bigger than it looks. If each row's content is defined as a lambda that receives the full item from a list living in a ViewModel, and that ViewModel exposes a single StateFlow<List<Item>>, any change to any item produces a brand-new list instance — that's just how immutable flows are built — and the collectAsState() observing that flow from the parent composable fires a recomposition of that entire parent. The LazyColumn re-runs its content block with the new list, and even though each individual row might still be skippable if its own parameters didn't change, the cost of walking and diffing every row has already been paid.

Stable keys in LazyColumn

The first concrete leak is in how items get declared. Without an explicit key, LazyColumn identifies each row by position:

LazyColumn {
    items(orders) { order ->
        OrderRow(order)
    }
}

As long as the list doesn't change order or size, this causes no visible problems. But the moment an item gets inserted at the top, one gets removed from the middle, or the list gets reordered after a filter, Compose has no way of knowing that the row that used to sit at position 5 is the same entity now sitting at position 6: it just sees that position 5 holds different data than the previous run and recomposes that row as if it were brand-new content — losing, on top of that, any local state the row was holding with remember, like the scroll offset of an inner carousel or an expanded/collapsed flag, because that state is tied to position, not identity.

The fix is to declare a key based on the data's real identity:

LazyColumn {
    items(orders, key = { it.id }) { order ->
        OrderRow(order)
    }
}

With a stable key, Compose can recognize that the order with id = 42 is still the same order even after moving position, reuse its composition slot and local state, and above all, limit recomposition to the items whose content actually changed instead of treating any position shift as a brand-new row. This alone doesn't fix the full StateFlow<List<Item>> problem described above, but it's the necessary condition for any other optimization on the list to have an effect.

remember and derivedStateOf to scope what gets recalculated

remember caches the result of a computation across recompositions of the same scope, as long as its keys don't change. It's useful for keeping a LazyColumn from recalculating some expensive formatting on every recomposition of its parent, but it doesn't fix the underlying problem when what changes frequently is a value derived from state that changes even more often, like scroll position.

A typical case is showing a "scroll to top" button once the user has scrolled past a certain number of items:

val listState = rememberLazyListState()
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}

Without derivedStateOf, reading listState.firstVisibleItemIndex directly inside the composable that draws the button subscribes it to a value that changes on every pixel of scroll, and that composable recomposes dozens of times a second even though the button never actually changes its visible state. derivedStateOf wraps the read in a calculation that Compose does observe on every change to firstVisibleItemIndex, but it only notifies readers of showButton when the boolean result actually flips. The scope drawing the button stays subscribed to a fast-changing source underneath, but only recomposes when the value it actually cares about changes.

What didn't work: wrapping everything in remember without checking parameter stability

The first time we tackled this, we wrapped each row's content in a remember block that memoized the computed Modifier and the background color based on favorite state, assuming that would be enough for Compose to skip rows with no real change. It didn't help at all — the profiler kept showing the whole list recomposing on every toggle.

The actual cause was somewhere else: OrderRow took an onFavoriteClick: (Order) -> Unit lambda defined inline at the call site, inside the items block. Every recomposition of the parent produced a new instance of that lambda — without an explicit remember, the Compose compiler can only avoid recreating a lambda when it can infer that the captured variables are stable, and here the lambda captured a reference to the entire ViewModel, treated as an unstable parameter — and a new lambda on every recomposition means Compose can't treat OrderRow's parameters as stable, so every row got treated as if its parameters had changed even when the Order itself was identical to the previous run. remember around internal row values couldn't compensate for that, because the whole row was already being marked non-skippable before it even got to evaluating its content.

The fix was to hoist the lambda out of the items block and wrap it in remember at the parent composable level, capturing only a reference to a ViewModel function — which is stable across recompositions — instead of the entire ViewModel object:

val onFavoriteClick = remember(viewModel) {
    { order: Order -> viewModel.toggleFavorite(order.id) }
}

LazyColumn {
    items(orders, key = { it.id }) { order ->
        OrderRow(order, onFavoriteClick = onFavoriteClick)
    }
}

With the lambda stabilized and the id-based key already in place, the profiler finally showed what it should have shown from the start: tapping an order's favorite button recomposes that row and nothing else.

When it's worth chasing this

None of this matters for a ten-item list that rarely changes; the cost of a full recomposition there is indistinguishable in a single frame. It's worth auditing keys, lambda stability, and derivedStateOf when two conditions line up: a list of meaningful size, and state that changes noticeably more often than the visible content of each individual row does. Outside that combination, this is premature optimization that complicates the code without the user ever noticing a difference.

jetpack-compose android kotlin performance
← Back to blog