@Composable — qué es realmente

Una función anotada con @Composable es una función que puede llamar a otras funciones @Composable y que puede "observar" estado de Compose. No retorna nada — su trabajo es emitir elementos de UI al árbol de composición.

// Convención de nomenclatura: PascalCase, sustantivo, no verbo
@Composable
fun TarjetaProducto(producto: Producto, onClick: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = producto.nombre,
                    style = MaterialTheme.typography.titleMedium
                )
                Text(
                    text = "$${producto.precio}",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            if (!producto.disponible) {
                Badge { Text("Sin stock") }
            }
        }
    }
}

Estado en Compose — mutableStateOf

El estado en Compose es cualquier valor que, al cambiar, debe causar una recomposición. Se crea con mutableStateOf():

// Sin remember — se reinicia en cada recomposición (¡bug!)
@Composable
fun ContadorRoto() {
    var contador = mutableStateOf(0)  // nuevo objeto en cada recomposición
    Button(onClick = { contador.value++ }) {
        Text("${contador.value}")  // siempre muestra 0
    }
}

// Con remember — sobrevive a las recomposiciones
@Composable
fun ContadorCorrecto() {
    var contador by remember { mutableStateOf(0) }
    Button(onClick = { contador++ }) {
        Text("Clicks: $contador")
    }
}

El operador by con remember { mutableStateOf() } es el combo estándar. Delega la propiedad para que puedas usar contador directamente en lugar de contador.value.

remember y rememberSaveable

// remember — sobrevive a recomposiciones, pero NO a rotaciones
var nombre by remember { mutableStateOf("") }

// rememberSaveable — sobrevive a recomposiciones Y a rotaciones/process death
// Equivalente a savedInstanceState en el mundo View
var nombre by rememberSaveable { mutableStateOf("") }

// remember también sirve para cachear cálculos caros
val listaFiltrada by remember(busqueda, lista) {
    derivedStateOf {
        lista.filter { it.nombre.contains(busqueda, ignoreCase = true) }
    }
}
// Solo recalcula cuando 'busqueda' o 'lista' cambian

remember vs rememberSaveableUsá remember para estado de UI efímero (si el botón está expandido). Usá rememberSaveable para estado que el usuario escribió o eligió y que debe sobrevivir a rotaciones. Para datos de negocio, siempre el ViewModel.

State hoisting — el patrón fundamental

State hoisting es mover el estado hacia arriba en el árbol de composables, al ancestro común más cercano que lo necesita. El composable hijo recibe el estado como parámetro y emite eventos hacia arriba mediante lambdas.

// SIN hoisting — el estado está atrapado adentro, no se puede reutilizar
@Composable
fun BuscadorAtrapado() {
    var texto by remember { mutableStateOf("") }
    TextField(
        value = texto,
        onValueChange = { texto = it }
    )
    // Nadie más puede leer 'texto'
}

// CON hoisting — el estado es controlado desde afuera
@Composable
fun Buscador(
    valor: String,           // estado baja como parámetro
    onValorCambio: (String) -> Unit  // eventos suben como lambda
) {
    TextField(
        value = valor,
        onValueChange = onValorCambio,
        placeholder = { Text("Buscar...") },
        leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
    )
}

// El padre controla el estado
@Composable
fun PantallaBusqueda() {
    var busqueda by remember { mutableStateOf("") }

    Column {
        Buscador(
            valor = busqueda,
            onValorCambio = { busqueda = it }
        )
        ResultadosBusqueda(query = busqueda)
    }
}

La regla del hoistingEl estado debe vivir en el composable de menor nivel que necesite leerlo y escribirlo. Si solo un composable lo necesita, vivirá ahí. Si dos composables hermanos lo necesitan, sube al padre. Si toda la pantalla lo necesita, sube al ViewModel.

Stateful vs Stateless composables

Esta distinción es clave para la arquitectura y la testeabilidad:

// STATELESS — no tiene estado propio, solo recibe y emite
// Fácil de testear, fácil de reutilizar, fácil de hacer Preview
@Composable
fun BotonContador(
    contador: Int,
    onIncremento: () -> Unit,
    modifier: Modifier = Modifier
) {
    Button(onClick = onIncremento, modifier = modifier) {
        Text("Clicks: $contador")
    }
}

// STATEFUL — maneja su propio estado
// Conveniente, pero menos flexible
@Composable
fun BotonContadorConEstado(modifier: Modifier = Modifier) {
    var contador by remember { mutableStateOf(0) }
    BotonContador(
        contador = contador,
        onIncremento = { contador++ },
        modifier = modifier
    )
}

// Patrón recomendado: exponé la versión stateless como API pública
// y opcionalmente ofrecé una versión stateful como conveniencia

derivedStateOf — estado derivado eficiente

derivedStateOf calcula un valor a partir de otro estado, pero solo se recompone cuando el resultado cambia, no cuando el input cambia:

@Composable
fun ListaConBusqueda(productos: List<Producto>) {
    var busqueda by remember { mutableStateOf("") }

    // SIN derivedStateOf — recalcula en cada keystroke Y recompone la lista
    val filtrados = productos.filter { it.nombre.contains(busqueda) }

    // CON derivedStateOf — solo recompone si el resultado cambia
    // Útil cuando el filtro no cambia en cada keystroke (ej: lista larga)
    val filtrados by remember(productos) {
        derivedStateOf {
            productos.filter { it.nombre.contains(busqueda, ignoreCase = true) }
        }
    }

    Column {
        TextField(value = busqueda, onValueChange = { busqueda = it })
        LazyColumn {
            items(filtrados, key = { it.id }) { producto ->
                TarjetaProducto(producto = producto, onClick = {})
            }
        }
    }
}