¿Qué dispara realmente una recomposición en Jetpack Compose, y cómo evitás las innecesarias?
La recomposición no es volver a renderizar todo el árbol — es Compose decidiendo, función por función, si tus parámetros siguen iguales, y la estabilidad es lo que chequea.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué dispara realmente una recomposición en Jetpack Compose, y cómo evitás las innecesarias?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué es realmente una "recomposición"
Jetpack Compose no vuelve a renderizar todo el árbol de UI cada vez que cambia un estado, como sugiere un modelo mental ingenuo. La unidad de trabajo en Compose es la función composable, y recomponer significa que Compose vuelve a invocar las funciones composable cuyos parámetros ya no puede demostrar que siguen iguales, salteando cualquier función cuyos parámetros comparan como iguales respecto de la llamada anterior. Por eso se dice que Compose hace recomposición "inteligente" o "granular": un cambio de estado tres niveles adentro del árbol puede, en principio, volver a ejecutar solo el composable hoja que lo lee, sin tocar a sus padres, hermanos ni hijos.
El plugin del compilador de Compose es lo que hace esto posible. Reescribe cada función @Composable para que reciba parámetros extra y ocultos — un Composer y una máscara de bits de "cambios" — e inserta lógica de comparación alrededor de cada punto de llamada. Cuando un composable está por ser invocado de nuevo, el runtime chequea si cada uno de sus parámetros es igual al valor que tenía en la llamada anterior. Si todos son iguales y el composable está marcado como "saltable" (algo que depende de la estabilidad, ver más abajo), Compose directamente no llama a la función y reutiliza la UI que ya había emitido.
#Los tres disparadores reales
Dejá de lado por un momento la idea vaga de "cuando cambia el estado" y nombrá los disparadores exactos:
- Un
State<T>que leíste durante la composición cambia. Este es el sistema de Snapshots:mutableStateOf,derivedStateOf,collectAsStatey compañía producen todos unState<T>, y leer su.valuedentro del cuerpo de un composable lo registra como suscriptor. Cuando el snapshot commitea un valor nuevo, se agenda la recomposición de cada suscriptor. - Su padre recompone y lo llama con argumentos distintos — y su función no es saltable para esos argumentos, así que Compose no puede probar que nada cambió.
- Un
CompositionLocalque lee cambia de valor.
Eso es toda la lista. Un detalle clave: que "el padre haya recompuesto" no es, por sí solo, motivo para que un hijo recomponga — si el padre pasa exactamente los mismos valores que la vez anterior, y el hijo es saltable, Compose lo saltea por completo. Este es el detalle que más ingenieros se equivocan, y es exactamente donde viven los bugs de "recomposición innecesaria".
#La estabilidad es lo que hace confiable a un parámetro
Para que Compose pueda saltear una llamada, tiene que confiar en que "igual" realmente significa "igual" para cada tipo de parámetro. El compilador clasifica cada tipo como estable o inestable:
- Estable: todos los tipos primitivos,
String, los tipos función (lambdas, sujetos a las reglas de memoización de abajo), y cualquier clase donde todas las propiedades seanvalestables — inferido automáticamente para clases de tu propio módulo, o declarado explícitamente con@Stable/@Immutable. - Inestable:
List,SetyMapde Kotlin puro (el compilador no puede probar que una referencia de tipoListno sea en realidad unaMutableListmutada desde otro lado, así que nunca confía en las interfaces de colección), cualquier tipointerface(el compilador no tiene idea de qué estabilidad real tiene la implementación concreta, salvo que la afirmes con@Stable), y cualquier clase que exponga una propiedadvar.
Un parámetro inestable vuelve no saltable al composable para ese parámetro: Compose se rinde al intentar compararlo y directamente vuelve a llamar a la función. Ese único hecho es la causa número uno de los bugs de "por qué esto recompone todo el tiempo" en código real.
#Un antes / después concreto
// ANTES — recompone mucho más de lo necesario.@Composablefun ScreenContainer(viewModel: ItemsViewModel) {val uiState by viewModel.uiState.collectAsStateWithLifecycle()// Se asigna una List completamente nueva en CADA recomposición de// ScreenContainer, aunque los ítems visibles no hayan cambiado en realidad.ItemList(items = uiState.items.filter { it.isVisible },onItemClick = { item -> viewModel.select(item) },)}@Composablefun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {// List<Item> es un tipo inestable para el compilador, así que toda esta// función es no saltable — recompone cada vez que lo hace su padre,// sin importar si `items` es estructuralmente la misma lista.LazyColumn {items(items, key = { it.id }) { item ->ItemRow(item = item, onClick = { onItemClick(item) })}}}data class Item(val id: String, val label: String, val isVisible: Boolean)
// DESPUÉS — entradas estables, y el filtro solo se recalcula cuando cambia la lista fuente.@Immutabledata class Item(val id: String, val label: String, val isVisible: Boolean)@Composablefun ScreenContainer(viewModel: ItemsViewModel) {val uiState by viewModel.uiState.collectAsStateWithLifecycle()// derivedStateOf recalcula en cada cambio de uiState.items, pero quien// lo lee más abajo solo recompone cuando el resultado FILTRADO difiere.val visibleItems by remember(uiState.items) {derivedStateOf { uiState.items.filter { it.isVisible }.toImmutableList() }}ItemList(items = visibleItems, onItemClick = { item -> viewModel.select(item) })}@Composablefun ItemList(items: ImmutableList<Item>, onItemClick: (Item) -> Unit) {// ImmutableList es estable, Item es @Immutable, y la lambda tiene una// captura estable — este composable ahora sí es genuinamente saltable.LazyColumn {items(items, key = { it.id }) { item ->ItemRow(item = item, onClick = { onItemClick(item) })}}}
#Tabla: qué recompone, y por qué
| Tipo de parámetro | Estabilidad para el compilador | Efecto sobre el salteo |
|---|---|---|
Int, Boolean, String | Estable | Saltable si el valor es == al de la vez anterior |
data class con solo campos val de tipos estables | Estable (inferido) | Saltable |
List<T>, Map<K, V> (kotlin.collections) | Inestable | Nunca saltable — recompone en cada recomposición del padre |
ImmutableList<T> (kotlinx.collections.immutable) | Estable | Saltable |
Tipo interface sin anotar | Inestable (desconocida) | Nunca saltable salvo que esté anotado @Stable |
| Lambda con solo capturas estables | Estable, memoizada automáticamente | Saltable |
| Lambda que captura un valor inestable | Instancia nueva en cada llamada | Nunca saltable |
#El "strong skipping mode" cambia la ecuación
Desde que el compilador de Compose se distribuye junto con Kotlin 2.0, el strong skipping mode viene activado por defecto. Hace que incluso los parámetros de tipo inestable sean saltables, cayendo en una comparación de instancia/equals() en tiempo de ejecución en lugar de negarse directamente a compararlos, y envuelve automáticamente cada parámetro lambda en remember para que ya no tengas que escribirlo a mano. Esto cierra buena parte del consejo histórico de "envolvelo vos mismo en remember" para lambdas — pero no arregla que una List se vuelva a crear con contenido igual pero instancia distinta en cada llamada: la igualdad de instancia sigue diciendo "distinto", así que el costo de una lista genuinamente nueva en cada recomposición sigue siendo real.
#La decisión que toma Compose en cada llamada
🧠 Check your understanding
0/3 · 0/3 answered1. Un composable recibe un parámetro List<String>. El contenido de la lista nunca cambia, pero el padre asigna una nueva instancia de List con los mismos elementos en cada recomposición. ¿Qué pasa?
2. ¿Qué agrega el 'strong skipping mode' sobre las reglas clásicas de estabilidad?
3. ¿Cuál de estas NO es una de las tres cosas que disparan la recomposición de un composable?