---
name: kotlin-compose
description: >
  Jetpack Compose avanzado: state hoisting, remember vs rememberSaveable,
  derivedStateOf, Navigation Compose con type-safe routes, LazyColumn con keys,
  side effects, animaciones y optimización de recomposición con @Stable.
  Cargar cuando se implementen pantallas Compose, se optimice recomposición,
  o se configure Navigation Compose en proyectos Android.
version: "1.0.0"
herramientasPermitidas: [Read, Glob, Grep]
exclusiones:
  - "No cargar para lógica de negocio, ViewModels o coroutines fuera de Compose — para eso cargar `kotlin-experto`."
  - "No cargar para escribir tests de UI con ComposeTestRule — para testing cargar `kotlin-testing`."
  - "No cargar para errores de compilación Kotlin o Compose — para build errors cargar `build-errors-kotlin`."
  - "No cargar para SwiftUI o React Native — Compose es específico de Android/Kotlin; para iOS cargar `swift-patrones`."
evolvable: true  # default para skill estandar
---
# Kotlin Compose — State, Navigation y Performance

## Cuándo NO cargar

- La pregunta es sobre lógica de negocio, ViewModels, coroutines o Hilt fuera de Compose — para eso cargar `kotlin-experto`.
- La tarea es escribir tests de UI con `ComposeTestRule` — para testing cargar `kotlin-testing`.
- Los errores son de compilación Kotlin o Compose (generación de código de Hilt, errores de BOM) — cargar `build-errors-kotlin`.
- La UI es SwiftUI, React Native o Flutter — Compose es específico de Android/Kotlin.

## State Hoisting — Regla fundamental

El estado debe vivir en el nivel más bajo que lo necesita, pero no más abajo.
Hoistear hacia arriba cuando más de un composable lo necesita.

```kotlin
// MAL — estado interno, no testeable, no reutilizable
@Composable
fun BuscadorFacturas() {
    var query by remember { mutableStateOf("") }
    // query solo visible aquí
}

// BIEN — estado hoisted, el llamador controla
@Composable
fun BuscadorFacturas(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier
    )
}

// Llamador (o ViewModel) posee el estado
@Composable
fun FacturasScreen(viewModel: FacturasViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    BuscadorFacturas(
        query = uiState.query,
        onQueryChange = viewModel::onQueryChange
    )
}
```

---

## remember vs rememberSaveable

```kotlin
// remember — sobrevive recomposición, NO sobrevive rotación de pantalla
var expandido by remember { mutableStateOf(false) }

// rememberSaveable — sobrevive recomposición Y rotación
// Usar para estado de UI efímero que el usuario espera que persista
var seleccionado by rememberSaveable { mutableStateOf("") }

// rememberSaveable con Saver personalizado para objetos complejos
var filtros by rememberSaveable(stateSaver = FiltrosSaver) {
    mutableStateOf(FiltrosFactura())
}

val FiltrosSaver = Saver<FiltrosFactura, Bundle>(
    save = { filtros ->
        Bundle().apply {
            putString("estatus", filtros.estatus)
            putString("desde", filtros.desde?.toString())
        }
    },
    restore = { bundle ->
        FiltrosFactura(
            estatus = bundle.getString("estatus"),
            desde = bundle.getString("desde")?.let { LocalDate.parse(it) }
        )
    }
)
```

---

## derivedStateOf — Computaciones derivadas

```kotlin
// derivedStateOf solo recompone cuando el RESULTADO cambia,
// no cuando la fuente cambia. Ideal para filtros y búsquedas.
@Composable
fun FacturasList(facturas: List<Factura>, query: String) {
    // Sin derivedStateOf: recompone en CADA keystroke aunque el resultado sea igual
    // Con derivedStateOf: recompone solo cuando la lista filtrada cambia
    val facturasFiltradas by remember(facturas) {
        derivedStateOf {
            if (query.isBlank()) facturas
            else facturas.filter {
                it.folio.contains(query, ignoreCase = true) ||
                it.cliente.contains(query, ignoreCase = true)
            }
        }
    }

    LazyColumn {
        items(facturasFiltradas, key = { it.id }) { factura ->
            FacturaItem(factura)
        }
    }
}
```

---

## Navigation Compose — Type-safe Routes

```kotlin
// Definir rutas como objetos serializables (Navigation 2.8+)
@Serializable object RutaFacturas
@Serializable data class RutaDetalleFactura(val id: String)
@Serializable object RutaCrearFactura

@Composable
fun AppNavGraph(navController: NavHostController = rememberNavController()) {
    NavHost(navController, startDestination = RutaFacturas) {
        composable<RutaFacturas> {
            FacturasScreen(
                onFacturaClick = { id ->
                    navController.navigate(RutaDetalleFactura(id))
                },
                onCrearClick = { navController.navigate(RutaCrearFactura) }
            )
        }
        composable<RutaDetalleFactura> { backStackEntry ->
            val ruta = backStackEntry.toRoute<RutaDetalleFactura>()
            DetalleFacturaScreen(facturaId = ruta.id)
        }
        composable<RutaCrearFactura> {
            CrearFacturaScreen(onGuardado = { navController.popBackStack() })
        }
    }
}
```

---

## LazyColumn — Keys y Performance

```kotlin
// SIEMPRE proveer key estable para evitar recomposición completa
LazyColumn(
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    // key debe ser estable y único — usar el ID del dominio
    items(facturas, key = { factura -> factura.id }) { factura ->
        FacturaItem(
            factura = factura,
            modifier = Modifier.animateItem()  // animación de inserción/eliminación
        )
    }

    // Separador sin key — usar índice si no hay ID
    itemsIndexed(secciones, key = { index, _ -> "seccion_$index" }) { _, seccion ->
        SeccionHeader(seccion)
    }
}
```

---

## Side Effects

```kotlin
@Composable
fun FacturasScreen(viewModel: FacturasViewModel = hiltViewModel()) {
    val contexto = LocalContext.current

    // LaunchedEffect — efecto async ligado al ciclo de vida del composable
    // Se re-ejecuta cuando cambia la key
    LaunchedEffect(Unit) {
        viewModel.events.collect { evento ->
            when (evento) {
                is FacturasEvent.FacturaCreada ->
                    Toast.makeText(contexto, "Factura creada", Toast.LENGTH_SHORT).show()
            }
        }
    }

    // DisposableEffect — para limpiar recursos (listeners, suscripciones)
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) viewModel.recargar()
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
    }

    // SideEffect — sincronizar estado a código no-Compose
    // Se ejecuta en CADA recomposición exitosa
    SideEffect {
        analytics.setPantallaActual("facturas")
    }
}
```

---

Para animaciones (AnimatedVisibility, animateContentSize, updateTransition) y
optimización de performance con @Stable, ImmutableList y lambdas estables, ver
[recursos/animaciones-performance.md](recursos/animaciones-performance.md).

---

## Anti-patrones

### Leer State en lambda sin capturar correctamente

```kotlin
// MAL — captura el valor en el momento de creación del composable
val contador by remember { mutableStateOf(0) }
Button(onClick = { println(contador) })  // siempre imprime el valor inicial

// BIEN — leer el estado dentro de la lambda (se evalúa al click)
val contadorState = remember { mutableStateOf(0) }
Button(onClick = { println(contadorState.value) })
```

### Función en lugar de computed state

```kotlin
// MAL — función recalcula y causa recomposición en cada frame
@Composable
fun Pantalla(facturas: List<Factura>) {
    val total = facturas.sumOf { it.total }  // recalcula siempre
    Text("Total: $total")
}

// BIEN — derivedStateOf o remember con key
val total by remember(facturas) { derivedStateOf { facturas.sumOf { it.total } } }
```

---

## Gotchas / Errores comunes no obvios

**`remember { derivedStateOf { ... } }` sin `remember` en la derivación no se recomputa**: `derivedStateOf { lista.filter { it.activo } }` sin envolver en `remember` crea un nuevo objeto `State` en cada recomposición, anulando la optimización. Fix: siempre combinar: `val filtrado by remember { derivedStateOf { lista.filter { it.activo } } }`.

**`LazyColumn` sin `key` causa que los composables se recreen en lugar de reordenarse**: al cambiar el orden de los items, Compose reutiliza los composables por posición, no por identidad — los estados internos de cada item (scroll position, animation state) se mezclan. Fix: siempre especificar `key = { item -> item.id }` en `items()` de `LazyColumn`/`LazyRow`.

**`SideEffect` se ejecuta en cada recomposición, no una sola vez**: `SideEffect { SystemUiController.setStatusBarColor(color) }` cambia el color en cada recomposición del composable, no solo al montar. Causa: `SideEffect` no tiene ciclo de vida — ejecuta en cada render exitoso. Fix: usar `LaunchedEffect(key)` con una clave estable si el efecto solo debe ejecutarse cuando cambia un valor específico.

**`@Stable` en una clase con campos `var` no previene recomposiciones**: marcar una clase como `@Stable` le dice a Compose que las comparaciones de igualdad son confiables, pero si la clase tiene `var` mutables que cambian sin notificar a Compose (no usando `mutableStateOf`), las recomposiciones no se disparan cuando deberían y la UI queda desactualizada. Fix: usar `@Stable` solo en clases verdaderamente inmutables o en clases que usan `State` de Compose para sus campos mutables.

## Checklist Compose

- [ ] Estado hoisted al nivel mínimo necesario
- [ ] rememberSaveable para estado que debe sobrevivir rotación
- [ ] derivedStateOf para computaciones sobre listas o búsquedas
- [ ] LazyColumn siempre con key = { item.id }
- [ ] Lambdas en items de LazyColumn con remember para evitar recreación
- [ ] @Stable o ImmutableList para listas en parámetros de composable
- [ ] Navigation usa type-safe routes (objetos @Serializable)
