$npx -y skills add skydoves/compose-performance-skills --skill optimizing-lazy-layoutsUse this skill to fix scroll jank, lost item state, and broken animateItem() animations in LazyColumn, LazyRow, LazyVerticalGrid, and LazyHorizontalGrid. Covers stable item keys, contentType for mixed-type feeds, Modifier.animateItem() requirements, hoisting modifier chains and p
| 1 | # Optimizing Lazy Layouts — Keys, contentType, and animateItem() |
| 2 | |
| 3 | Lazy layouts compose only what's visible, but two things still cost: re-composition of items that should have been reused (missing `key`), and per-item allocation that compounds with scroll velocity (missing `contentType`, modifier chains created inside `items { }`). Both have a one-line fix. This skill teaches Claude how to apply that fix correctly and to validate that item composables are themselves skippable. Prefetch tuning is a separate concern — see `../configuring-lazy-prefetch/SKILL.md`. |
| 4 | |
| 5 | ## When to use this skill |
| 6 | |
| 7 | - The developer reports scroll jank, dropped frames, or stutter on a `LazyColumn`, `LazyRow`, `LazyVerticalGrid`, or `LazyHorizontalGrid`. |
| 8 | - Items lose scroll position, focus, or composition state on insert, remove, or reorder. |
| 9 | - A mixed-type feed (cards + headers + ads + carousels) feels sluggish even though each individual row is lightweight. |
| 10 | - `Modifier.animateItem()` was added but no animation runs on inserts or removals. |
| 11 | - The compiler report shows item composables as `unstable`/non-skippable, or `@TraceRecomposition` shows item composables recomposing on every scroll tick. |
| 12 | |
| 13 | ## When NOT to use this skill |
| 14 | |
| 15 | - The bottleneck is the prefetch window (heavy items, high-velocity scroll, want a wider ahead/behind window) → use `../configuring-lazy-prefetch/SKILL.md`. |
| 16 | - The item composable itself takes an unstable parameter (`List<Foo>`, `Flow<Foo>`, a domain `var`) → first run `../../stability/diagnosing-compose-stability/SKILL.md` and then `../../stability/stabilizing-compose-types/SKILL.md`. |
| 17 | - An animation inside an item reads `state.value` in Composition phase, recomposing the row every frame → use `../../recomposition/deferring-state-reads/SKILL.md`. |
| 18 | - Scroll position derivation (e.g. `firstVisibleItemIndex == 0`) is the hot path → use `../../recomposition/choosing-derivedstateof/SKILL.md`. |
| 19 | |
| 20 | ## Prerequisites |
| 21 | |
| 22 | - Compose Foundation **1.7+** for `Modifier.animateItem()` (the GA replacement for the experimental `animateItemPlacement`). |
| 23 | - Kotlin **2.0.0+** with `org.jetbrains.kotlin.plugin.compose` applied. Strong Skipping is on by default; non-skippable item composables become amplified at scroll speed. |
| 24 | - A real device + release build for measurement. Skydoves hot take #5: debug builds lie (Live Literals, interpreted mode). See `../../measurement/generating-baseline-profiles/SKILL.md` when ready to measure. |
| 25 | |
| 26 | ## Workflow |
| 27 | |
| 28 | - [ ] **1. Audit every `items(...)` call.** Walk every `LazyListScope.items(list)`, `items(count)`, `itemsIndexed(list)`, and the `LazyGridScope` equivalents. For each, decide: does each element have a stable identity that outlives a single composition? If yes — and it almost always does — supply `key = { it.id }` using a server-side stable ID. **MUST NOT** use the list index, `UUID.randomUUID()` evaluated per emission, or `hashCode()` of a mutable object. |
| 29 | |
| 30 | ```kotlin |
| 31 | // WRONG |
| 32 | LazyColumn { items(snacks) { snack -> SnackRow(snack) } } |
| 33 | // WRONG because: index-based identity → insert/remove discards composition state and breaks animateItem(). |
| 34 | ``` |
| 35 | |
| 36 | ```kotlin |
| 37 | // RIGHT |
| 38 | LazyColumn { |
| 39 | items( |
| 40 | items = snacks, |
| 41 | key = { it.id }, |
| 42 | contentType = { it::class }, |
| 43 | ) { snack -> |
| 44 | SnackRow(snack, Modifier.animateItem()) |
| 45 | } |
| 46 | } |
| 47 | ``` |
| 48 | |
| 49 | - [ ] **2. Add `contentType` for heterogeneous lists.** Lazy layouts maintain a per-type composition cache analogous to RecyclerView's view-type. When item N + 1 has the same `contentType` as a recycled slot, the cached composition is reused; otherwise it is discarded and rebuilt. For homogeneous lists Compose infers a single content type and `contentType` is optional. For mixed feeds (cards, headers, ads, carousels, dividers) **MUST** supply a stable type discriminator. |
| 50 | |
| 51 | - [ ] **3. Validate item composable stability.** Run `../../stability/diagnosing-compose-stability/SKILL.md`. If the item composable accepts an `unstable` parameter, no amount of `key`/`contentType` work will help — the row |