El puente entre Compose y MVVM

El ViewModel no cambia. StateFlow, UiState, los use cases, el Repository — todo lo que aprendiste en el curso Avanzado sigue igual. Lo que cambia es cómo la View se suscribe al estado y reacciona a él.

En el View System usabas repeatOnLifecycle + collect desde un Fragment. En Compose usás collectAsStateWithLifecycle(), que convierte el StateFlow en un State<T> de Compose que dispara recomposiciones automáticamente.

collectAsStateWithLifecycle

// ViewModel — sin cambios respecto al curso Avanzado
@HiltViewModel
class ProductosViewModel @Inject constructor(
    private val getProductos: GetProductosDisponiblesUseCase
) : ViewModel() {

    val uiState: StateFlow<ProductosUiState> = getProductos()
        .map { ProductosUiState(productos = it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = ProductosUiState(isLoading = true)
        )

    fun onAction(action: ProductosAction) { /* ... */ }
}

// Screen composable — acá es donde cambia todo
@Composable
fun ProductosScreen(viewModel: ProductosViewModel = hiltViewModel()) {

    // collectAsStateWithLifecycle es lifecycle-aware como repeatOnLifecycle
    // Deja de colectar cuando el composable sale del ciclo de vida activo
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // Pasamos el estado y las acciones al composable de contenido (stateless)
    ProductosContent(
        uiState = uiState,
        onAction = viewModel::onAction
    )
}

collectAsStateWithLifecycle vs collectAsStatecollectAsState() siempre colecta, incluso en background. collectAsStateWithLifecycle() respeta el ciclo de vida y pausa la colección cuando la pantalla no está activa. Usá siempre la versión lifecycle-aware.

El patrón Screen / Content

La separación clave en Compose con MVVM: un composable "Screen" que sabe del ViewModel, y un composable "Content" puramente stateless que solo recibe datos y lambdas:

// Screen — conoce el ViewModel, no se puede hacer Preview fácilmente
@Composable
fun ProductosScreen(
    onNavADetalle: (Int) -> Unit,
    viewModel: ProductosViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // Manejo de eventos únicos — ver siguiente sección
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(lifecycleOwner) {
        viewModel.eventos.flowWithLifecycle(lifecycleOwner.lifecycle)
            .collect { evento ->
                when (evento) {
                    is ProductosEvento.NavADetalle -> onNavADetalle(evento.id)
                }
            }
    }

    ProductosContent(
        uiState = uiState,
        onAction = viewModel::onAction
    )
}

// Content — stateless, fácil de previewar y testear
@Composable
fun ProductosContent(
    uiState: ProductosUiState,
    onAction: (ProductosAction) -> Unit
) {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Productos") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = { onAction(ProductosAction.AgregarNuevo) }) {
                Icon(Icons.Default.Add, contentDescription = "Agregar")
            }
        }
    ) { padding ->
        when {
            uiState.isLoading -> {
                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                    CircularProgressIndicator()
                }
            }
            uiState.error != null -> {
                ErrorView(
                    mensaje = uiState.error,
                    onReintentar = { onAction(ProductosAction.Recargar) }
                )
            }
            uiState.isEmpty -> {
                EmptyView(modifier = Modifier.padding(padding))
            }
            else -> {
                LazyColumn(modifier = Modifier.padding(padding)) {
                    items(uiState.productos, key = { it.id }) { producto ->
                        TarjetaProducto(
                            producto = producto,
                            onClick = { onAction(ProductosAction.SeleccionarProducto(producto.id)) }
                        )
                    }
                }
            }
        }
    }
}

// Preview de Content — funciona perfectamente con datos ficticios
@Preview
@Composable
fun ProductosContentPreview() {
    MiAppTheme {
        ProductosContent(
            uiState = ProductosUiState(
                productos = listOf(
                    Producto(1, "Tablet", 299.0, 5, Categoria.ELECTRONICA),
                    Producto(2, "Notebook", 899.0, 2, Categoria.ELECTRONICA)
                )
            ),
            onAction = {}
        )
    }
}

LaunchedEffect — eventos únicos y efectos

LaunchedEffect lanza una coroutine ligada al ciclo de vida del composable. Se cancela cuando el composable sale de la composición y se reinicia cuando la key cambia:

// Caso 1: colectar eventos únicos del ViewModel (navegación, snackbars)
LaunchedEffect(Unit) {
    viewModel.eventos.collect { evento ->
        when (evento) {
            is Evento.NavADetalle -> navController.navigate("detalle/${evento.id}")
            is Evento.MostrarSnackbar -> snackbarHostState.showSnackbar(evento.mensaje)
        }
    }
}

// Caso 2: cargar datos cuando cambia un parámetro
LaunchedEffect(productoId) {
    // Se ejecuta cuando productoId cambia
    viewModel.cargarProducto(productoId)
}

// Caso 3: acción de una sola vez al entrar a la pantalla
LaunchedEffect(Unit) {
    // Unit como key garantiza que solo se ejecuta una vez
    viewModel.registrarVisualizacion()
}

No colectes flows dentro de composables sin LaunchedEffectLlamar a flow.collect { } directamente en el cuerpo de un composable lanzaría una coroutine en cada recomposición. LaunchedEffect garantiza que solo existe una coroutine activa.

Otros effect handlers

// SideEffect — sincronizar Compose con código no-Compose
// Se ejecuta en cada recomposición exitosa
SideEffect {
    analytics.setCurrentScreen("Productos")
}

// DisposableEffect — para recursos que necesitan limpieza
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_RESUME) viewModel.recargar()
    }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

// rememberCoroutineScope — para lanzar coroutines desde handlers de eventos
val scope = rememberCoroutineScope()
Button(onClick = {
    // Los clicks son eventos, no composables — necesitás un scope manual
    scope.launch { snackbarHostState.showSnackbar("Guardado") }
}) {
    Text("Guardar")
}

El flujo completo de datos

// El ciclo completo con Compose + MVVM:
//
// Usuario toca un botón
//   → onClick llama onAction(ProductosAction.EliminarProducto(id))
//   → ProductosScreen pasa la lambda: onAction = viewModel::onAction
//   → ViewModel.onAction() procesa la acción
//   → viewModelScope.launch { repo.eliminar(id) }
//   → Room actualiza la DB, el Flow emite la nueva lista
//   → StateFlow en el ViewModel se actualiza
//   → collectAsStateWithLifecycle() recibe el nuevo UiState
//   → uiState en ProductosContent cambia
//   → Compose recompone SOLO los composables que leen uiState.productos
//   → LazyColumn anima la eliminación del item