Porting a RecyclerView deletes four classes and keeps one idea
A RecyclerView migration removes most of its own machinery, because Compose's composition already does recycling and diffing. What survives is stable identity — getItemId becomes key — and knowing that makes the port mechanical.

Day 90 of 100. The most common single migration, and the one where the mapping is least one-to-one.
The symptom
A port that recreates the old structure in the new framework:
class OrderAdapter : ComposeAdapter<Order>() { … } // no
Or, more subtly, a LazyColumn that keeps the old mental model:
LazyColumn {
items(orders.size) { index ->
OrderRow(orders[index])
}
}
Index-based items, no key, and the row reading from an outer list. It renders. Then
Day 23's identity problems arrive — expansion state on the wrong row, scroll jumps on
insert, animations attaching to the wrong item.
Why the obvious approach fails
The obvious approach is to translate each class:
| RecyclerView | "equivalent" |
|---|---|
Adapter |
… a wrapper class? |
ViewHolder |
… a composable? |
DiffUtil.ItemCallback |
… a comparator? |
LayoutManager |
… a parameter? |
Three of those four have no equivalent because the problem they solved no longer exists. Looking for a mapping produces a port that carries machinery for a problem Compose already handles.

The actual mechanism
What each piece was for, and what replaced it:
Adapter existed to bind data to recycled views on demand. LazyColumn's DSL is that
binding — items(orders) { order -> … } is the adapter, expressed as a lambda.
ViewHolder existed to cache findViewById lookups across recycling. Compose has no
view lookups, so there's nothing to cache. The composable is the row.
DiffUtil existed to compute a minimal set of change notifications. Compose's
composition already diffs — that's what recomposition is — so the whole
areItemsTheSame/areContentsTheSame protocol collapses into the key and the normal
equality Day 79 described.
LayoutManager existed to abstract the arrangement. That's now the choice of composable:
LazyColumn, LazyRow, LazyVerticalGrid, LazyVerticalStaggeredGrid.
What survives is stable identity. getItemId() and areItemsTheSame were both answering
"is this the same item", and so is key:
LazyColumn {
items(orders, key = { it.id }) { order ->
OrderRow(order, onSelect = { onSelect(order.id) })
}
}
That's the whole port for a simple list. Four classes to one lambda, with the one idea worth keeping made into a parameter.
The mapping table
For a mechanical port:
| RecyclerView | Compose |
|---|---|
LinearLayoutManager |
LazyColumn / LazyRow |
GridLayoutManager |
LazyVerticalGrid(GridCells.Fixed(n)) |
StaggeredGridLayoutManager |
LazyVerticalStaggeredGrid |
getItemId() |
key = { it.id } |
getItemViewType() |
contentType = { it::class } |
| Multiple view types | when inside the item lambda |
| Header / footer | item { } before / after items() |
ItemDecoration (dividers) |
HorizontalDivider() in the item, or Arrangement.spacedBy |
ItemAnimator |
Modifier.animateItem() |
ItemTouchHelper swipe |
SwipeToDismissBox (Day 73) |
addOnScrollListener |
snapshotFlow { listState.firstVisibleItemIndex } |
scrollToPosition |
listState.scrollToItem(index) |
smoothScrollToPosition |
listState.animateScrollToItem(index) |
contentType is the one most ports miss — Day 23's parameter, and the direct heir of
getItemViewType. Without it a mixed-type feed loses composition reuse.
Paging
Paging 3 has a Compose artifact, so a paged list doesn't need rearchitecting:
val orders = viewModel.pagedOrders.collectAsLazyPagingItems()
LazyColumn {
items(orders.itemCount, key = orders.itemKey { it.id }) { index ->
orders[index]?.let { OrderRow(it) }
}
when (orders.loadState.append) {
is LoadState.Loading -> item { LoadingRow() }
is LoadState.Error -> item { RetryRow(onRetry = orders::retry) }
else -> Unit
}
}
itemKey and itemContentType are the helpers that supply Day 23's parameters from the
paging source. The nullable orders[index] is the placeholder case — a page not yet
loaded — and handling it is what gives you the loading row for free.
The pattern that improves in the port
Worth noticing, because it's an argument for porting rather than wrapping. Several things
that were awkward in RecyclerView become trivial:
Multiple view types — a when in the item lambda, rather than an integer type, a
factory switch and parallel view holders.
Nested lists — a LazyRow inside a LazyColumn item, rather than a nested
RecyclerView with shared pools and scroll-position bookkeeping.
Sticky headers — stickyHeader { } in the DSL, rather than an ItemDecoration that
draws over the list. Sections too: items() called repeatedly with a stickyHeader
between them is a grouped list, where the adapter version needed a flattened list plus a
position-to-section lookup.
Empty and error states — an if around the LazyColumn, rather than a view that
toggles visibility outside the adapter.
That last one is the quiet win. Adapter-based lists tend to push empty states into the containing fragment, which is why they're the state most often forgotten (Day 85).
When to keep the RecyclerView
Being honest about the exception. A RecyclerView inside a ComposeView is fine and
sometimes right:
- The adapter is large, well-tested, and the screen isn't otherwise changing.
- It uses a third-party library with no Compose equivalent.
- The list has genuinely exotic behaviour — a custom
LayoutManagerdoing something no lazy layout models.
What isn't a good reason is "it works" alone. A RecyclerView embedded in Compose carries
Day 89's boundary cost, and it keeps four classes alive that the port would delete.
How to prove it
The identity check is the one that matters, and it's the same as Day 23's:
@Test fun stateFollowsTheItem() = runComposeUiTest {
var orders by mutableStateOf(listOf(a, b, c))
setContent { OrderList(orders) }
onNodeWithText("Order B").performClick() // expand B
orders = listOf(b, c) // remove A
onNodeWithText("Order B").assertIsDisplayed()
onNodeWithTag("expanded-B").assertExists() // still B that's expanded
}
Against an index-keyed port, that fails — which is precisely the bug getItemId was
preventing in the old code, so the test is checking that the port kept the property that
mattered.
The performance comparison is worth running once too, because expectations are often
wrong in both directions. A ported list is usually comparable rather than dramatically
faster — RecyclerView was well optimised — and where Compose wins is the code you no
longer maintain. Day 78's Macrobenchmark on the before and after settles it in a way that
opinion doesn't.
What this generalizes to
The lesson for any framework migration: port the requirements, not the classes. Four of
the five pieces of a RecyclerView existed to solve problems — view lookup cost, manual
diffing, recycling bookkeeping — that the new framework solves structurally. Translating
them produces a faithful reproduction of accidental complexity.
The way to find the one piece that survives is to ask what each class was for rather than
what it did. getItemId was for identity, and identity is still needed. ViewHolder was
for findViewById, and there is no findViewById.
The same question is worth asking about your own abstractions during a port. A
BaseListFragment or a GenericAdapter<T> usually exists to remove boilerplate that the
new framework doesn't generate — so it has nothing left to remove, and porting it forward
carries a solution to a problem that no longer exists.
Tomorrow, Day 91 closes the interop pillar with navigation — the migration where the two systems have to coexist longest.
Day 90 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: RecyclerView to LazyColumn.