Cómo funciona LazyColumn internamente

LazyColumn solo compone y dibuja los items visibles en pantalla más un pequeño buffer fuera de pantalla. Cuando el usuario scrollea, los items que salen de pantalla se reciclan — sus composables se reutilizan para los items nuevos que entran.

Este reciclado es lo que hace a LazyColumn eficiente en memoria. Pero también es la fuente de los errores más comunes: si LazyColumn no puede determinar correctamente qué item es cuál, el reciclado lleva al estado incorrecto al composable incorrecto.

keys — el error con más impacto

Sin key, LazyColumn identifica cada item por su posición en la lista. Si la lista se reordena, inserta o elimina items, Compose asume que el item en la posición 0 sigue siendo el mismo item de antes — y le asigna el estado del item anterior. El resultado: estados mezclados, animaciones incorrectas y recomposiciones innecesarias de toda la lista.

// ❌ Sin key — identificación por posición
LazyColumn {
    items(productos) { producto ->
        ProductoCard(producto)
    }
}
// Si se inserta un item al principio de la lista:
// → Compose ve que "el item en posición 0 cambió" → recompone TODOS los items
// → Los estados locales (expansión, selección) quedan en las posiciones viejas
// → Las animaciones de inserción/eliminación no funcionan

// ✓ Con key — identificación estable
LazyColumn {
    items(
        items = productos,
        key = { it.id }  // identificador único y estable
    ) { producto ->
        ProductoCard(producto)
    }
}
// Si se inserta un item al principio:
// → Compose sabe exactamente qué item es cuál
// → Solo recompone el item nuevo
// → Los estados locales siguen en el item correcto
// → Animaciones funcionan automáticamente con Modifier.animateItem()
// Listas con múltiples tipos — key única por tipo para evitar colisiones:
sealed class ItemFeed {
    data class Post(val post: Post) : ItemFeed()
    data class Ad(val ad: Ad) : ItemFeed()
    data class Header(val titulo: String) : ItemFeed()
}

LazyColumn {
    items(
        items = feedItems,
        key = { item ->
            when (item) {
                is ItemFeed.Post   -> "post_${item.post.id}"
                is ItemFeed.Ad     -> "ad_${item.ad.id}"
                is ItemFeed.Header -> "header_${item.titulo}"
            }
        }
    ) { item -> /* ... */ }
}
// Las keys deben ser únicas GLOBALMENTE en la lista, no solo por tipo

La key debe ser estable entre recomposicionesNo uses el índice como key (key = { index, _ -> index }) — eso es equivalente a no tener key. No uses objetos que se crean en la composición (key = { it.hashCode() } en clases con identidad de objeto). La key debe ser un valor que identifica el item de forma permanente: su ID de base de datos, su URL, su slug.

contentType — reciclado entre tipos distintos

Cuando la lista tiene items de tipos visuales diferentes (cards, headers, banners), contentType le dice a Compose qué composables puede reutilizar entre sí durante el reciclado. Sin esto, puede intentar reutilizar un header como una card con resultados incorrectos.

// ❌ Sin contentType — Compose puede intentar reciclar un Header como una Card
LazyColumn {
    items(feedItems, key = { /* ... */ }) { item ->
        when (item) {
            is ItemFeed.Post   -> PostCard(item.post)
            is ItemFeed.Ad     -> AdBanner(item.ad)
            is ItemFeed.Header -> SectionHeader(item.titulo)
        }
    }
}

// ✓ Con contentType — solo se reciclan composables del mismo tipo
LazyColumn {
    items(
        items = feedItems,
        key = { item -> when (item) {
            is ItemFeed.Post   -> "post_${item.post.id}"
            is ItemFeed.Ad     -> "ad_${item.ad.id}"
            is ItemFeed.Header -> "header_${item.titulo}"
        }},
        contentType = { item -> item::class }  // el tipo de la sealed class
    ) { item ->
        when (item) {
            is ItemFeed.Post   -> PostCard(item.post)
            is ItemFeed.Ad     -> AdBanner(item.ad)
            is ItemFeed.Header -> SectionHeader(item.titulo)
        }
    }
}

Recomposición innecesaria de items

Cada vez que el ViewModel emite un nuevo estado, todos los items visibles son candidatos a recomposición. Si los parámetros del item no cambiaron, Compose los saltea — pero solo si puede determinarlo con equals(). Los tipos inestables fuerzan la recomposición aunque el contenido sea idéntico.

// ❌ List<T> como parámetro → siempre inestable → recompone en cada emisión
@Composable
fun ProductoCard(etiquetas: List<String>) { /* ... */ }

// Cada vez que el ViewModel emite cualquier cambio de estado,
// ProductoCard recompone aunque las etiquetas no hayan cambiado

// ✓ ImmutableList → estable → Compose puede saltear la recomposición
@Composable
fun ProductoCard(etiquetas: ImmutableList<String>) { /* ... */ }

// O anotar el wrapper como @Stable:
@Stable
data class ProductoUiModel(
    val id: Int,
    val nombre: String,
    val etiquetas: List<String>  // la List inestable queda encapsulada
)
// Al estar dentro de una clase @Stable, Compose confía en que equals() es correcto

// ❌ Lambda que captura el item y se recrea en cada recomposición del padre
LazyColumn {
    items(productos, key = { it.id }) { producto ->
        ProductoCard(
            producto = producto,
            onClick = { viewModel.seleccionar(producto.id) }  // nueva lambda siempre
        )
    }
}

// ✓ Pasar el callback estable desde afuera
LazyColumn {
    items(productos, key = { it.id }) { producto ->
        ProductoCard(
            producto = producto,
            onSeleccionar = onProductoSeleccionado  // referencia estable al callback
        )
    }
}

Estado local en items — el problema del reciclado

El estado local con remember dentro de un item de LazyColumn sobrevive al reciclado — pero se reasigna al nuevo item que ocupa esa posición. Sin key, el estado "expansión = true" del item que scrolleó fuera de pantalla aparece en el item nuevo que entró.

// ❌ Estado local sin key — el estado se "transfiere" al item en la misma posición
LazyColumn {
    items(comentarios) { comentario ->  // sin key
        var expandido by remember { mutableStateOf(false) }  // estado local
        ComentarioCard(comentario, expandido, onExpandir = { expandido = !expandido })
    }
}
// Si el usuario expande el item en posición 3 y scrollea,
// cuando el item de posición 3 vuelve a la pantalla (con un comentario diferente),
// aparece expandido — el estado pertenecía a la posición, no al item

// ✓ Con key — el estado sigue al item correcto
LazyColumn {
    items(comentarios, key = { it.id }) { comentario ->
        var expandido by remember { mutableStateOf(false) }
        ComentarioCard(comentario, expandido, onExpandir = { expandido = !expandido })
    }
}

// Para estado que debe persistir entre scrolls (selección, leído/no leído):
// NO usar remember local — subirlo al ViewModel
class ComentariosViewModel : ViewModel() {
    private val _expandidos = mutableStateSetOf<Int>()  // IDs expandidos

    fun toggleExpandido(id: Int) {
        if (id in _expandidos) _expandidos.remove(id) else _expandidos.add(id)
    }
    fun isExpandido(id: Int) = id in _expandidos
}

Listas anidadas — el error que paraliza el scroll

Una LazyRow dentro de una LazyColumn es un patrón muy común (estilo Netflix: filas horizontales dentro de una lista vertical). El error más frecuente: la LazyRow pierde su posición de scroll cuando scrollea la LazyColumn.

// ❌ LazyRow que pierde el scroll al salir y volver a pantalla
LazyColumn {
    items(categorias, key = { it.id }) { categoria ->
        Text(categoria.nombre)
        LazyRow {  // el estado de scroll se pierde cuando esta fila sale de pantalla
            items(categoria.items, key = { it.id }) { item ->
                ItemCard(item)
            }
        }
    }
}

// ✓ Persistir el estado de scroll de cada LazyRow:
LazyColumn {
    items(categorias, key = { it.id }) { categoria ->
        val scrollState = rememberLazyListState()
        // rememberLazyListState con key = categoria.id persiste el scroll
        // mientras el item esté en memoria (dentro del buffer de LazyColumn)

        Text(categoria.nombre)
        LazyRow(state = scrollState) {
            items(categoria.items, key = { it.id }) { item ->
                ItemCard(item)
            }
        }
    }
}

// Para persistir el scroll entre reinstanciaciones más agresivas:
// Guardar y restaurar la posición en el ViewModel
class FeedViewModel : ViewModel() {
    private val scrollPositions = mutableMapOf<Int, Int>()  // categoriaId → firstVisibleIndex

    fun getScrollPosition(categoriaId: Int) = scrollPositions[categoriaId] ?: 0
    fun saveScrollPosition(categoriaId: Int, index: Int) {
        scrollPositions[categoriaId] = index
    }
}

Column + LazyColumn anidados rompen el scrollOtro error frecuente: poner una LazyColumn dentro de una Column scrolleable (o dentro de un NestedScrollView). LazyColumn necesita saber su altura para saber cuántos items mostrar — si está dentro de algo de altura infinita, intenta componer TODOS los items a la vez, derrotando todo el propósito de lazy. Si necesitás combinar contenido estático con una lista, usá LazyColumn con item { } para el contenido estático y items { } para la lista.

// ❌ LazyColumn dentro de Column scrolleable — compone TODOS los items
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    HeaderSection()
    LazyColumn {  // altura ilimitada → compone todo → no lazy
        items(listaLarga) { item -> ItemCard(item) }
    }
}

// ✓ Todo en una sola LazyColumn con items mixtos
LazyColumn {
    item { HeaderSection() }  // contenido estático como item
    item { SubHeaderSection() }
    items(listaLarga, key = { it.id }) { item ->
        ItemCard(item)
    }
    item { FooterSection() }
}

items vs itemsIndexed — cuándo cada uno

// items — cuando no necesitás el índice (la mayoría de los casos)
LazyColumn {
    items(productos, key = { it.id }) { producto ->
        ProductoCard(producto)
    }
}

// itemsIndexed — cuando el índice es parte de la lógica de presentación
LazyColumn {
    itemsIndexed(
        items = productos,
        key = { _, producto -> producto.id }  // el key usa el item, no el índice
    ) { index, producto ->
        ProductoCard(
            producto = producto,
            posicion = index + 1,  // "1 de 50", "2 de 50"...
            mostrarDivisor = index < productos.lastIndex  // no mostrar divisor en el último
        )
    }
}

// ❌ Usar itemsIndexed y pasar el índice como key:
itemsIndexed(productos) { index, producto ->
    // key = { index, _ -> index }  ← NUNCA: el índice como key es equivalente a no tener key
}

Allocations en la composición de items

// ❌ Crear objetos costosos dentro del composable del item — se ejecuta por cada recomposición
LazyColumn {
    items(transacciones, key = { it.id }) { transaccion ->
        val formato = NumberFormat.getCurrencyInstance(Locale("es", "AR"))  // nuevo objeto siempre
        val monto = formato.format(transaccion.monto)
        TransaccionCard(monto = monto, transaccion = transaccion)
    }
}

// ✓ Cachear con remember — se crea solo cuando cambia la dependencia
LazyColumn {
    items(transacciones, key = { it.id }) { transaccion ->
        // El NumberFormat se crea UNA SOLA VEZ y se reutiliza
        val formato = remember { NumberFormat.getCurrencyInstance(Locale("es", "AR")) }
        val monto = remember(transaccion.monto) { formato.format(transaccion.monto) }
        TransaccionCard(monto = monto, transaccion = transaccion)
    }
}

// ✓ Aún mejor: mover el formateo al modelo o al ViewModel
data class TransaccionUiModel(
    val id: Int,
    val montoFormateado: String,  // ya formateado antes de llegar al composable
    val fecha: String
)

LazyColumn {
    items(transacciones, key = { it.id }) { transaccion ->
        // No hay formateo en el composable — solo presentación
        TransaccionCard(transaccion = transaccion)
    }
}

Cómo medir la performance de LazyColumn

# 1. Layout Inspector con recomposition counts
# Android Studio → View → Tool Windows → Layout Inspector
# Activar "Show recomposition counts"
# Scrollear la lista — un item que se recompone en cada frame está mal

# 2. System Trace — detectar janky frames
# Android Studio → Profiler → CPU → System Trace
# Los frames que tardan más de 16ms (o 8ms en dispositivos de 120Hz) se muestran en rojo
# Expandir el Main thread en esos frames para ver qué composable los causó

# 3. Compose compiler report — detectar tipos inestables
# build.gradle:
# kotlinOptions {
#     freeCompilerArgs += listOf(
#         "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=/tmp/compose",
#         "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=/tmp/compose"
#     )
# }
# ./gradlew assembleRelease
# Ver /tmp/compose/app_release-composables.txt
# Buscar "unstable" en los parámetros de los composables de los items

# 4. Método simple — contar con un log
@Composable
fun ProductoCard(producto: Producto) {
    // Solo en debug:
    if (BuildConfig.DEBUG) {
        SideEffect { Log.d("Recompose", "ProductoCard recompuesto: ${producto.id}") }
    }
    // Si ves este log demasiadas veces durante un scroll suave → problema de estabilidad
}