Separar UI y lógica: por qué usar ViewModel + UI State
En una app real, la UI no debería “decidir” cómo cargar datos ni contener reglas de negocio. La UI se limita a representar un estado y a emitir eventos del usuario. Para lograrlo, se suele usar:
- ViewModel: mantiene el estado de la pantalla y ejecuta la lógica (cargar, refrescar, reintentar).
- UI State: un modelo inmutable que describe lo que la pantalla debe mostrar (por ejemplo: cargando, éxito con datos, error).
- UI Events: acciones del usuario (por ejemplo: tocar “Reintentar”, “Refrescar”). La UI los envía al ViewModel.
- Side-effects: efectos que no son “dibujar UI” (mostrar un snackbar, navegar, abrir un diálogo). Se disparan desde el ViewModel y la UI los consume.
Este enfoque reduce estados inconsistentes, facilita pruebas y evita que la UI se llene de condicionales y llamadas a datos.
Modelo mental: estado de pantalla y flujo de datos
Un flujo típico es:
- La UI se suscribe a un
StateFlow<UiState>del ViewModel. - La UI dibuja según el estado actual.
- El usuario interactúa (evento) y la UI llama a un método del ViewModel.
- El ViewModel actualiza el estado (por ejemplo, pasa a Loading) y ejecuta la operación (simulada o real).
- El ViewModel emite Success o Error.
- Si hay un efecto puntual (snackbar), el ViewModel lo emite por un canal/flow de efectos y la UI lo consume una sola vez.
Flujo de trabajo recomendado (paso a paso)
Paso 1: definir el UI State
Usa un sealed interface (o sealed class) para representar estados mutuamente excluyentes: Loading, Success, Error. En Success, incluye los datos necesarios para pintar la pantalla.
data class UserItem(val id: Int, val name: String)sealed interface UsersUiState { data object Loading : UsersUiState data class Success( val items: List<UserItem>, val isRefreshing: Boolean = false ) : UsersUiState data class Error( val message: String, val canRetry: Boolean = true ) : UsersUiState}Notas prácticas:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Loadingpara la primera carga.isRefreshingpara refresco “pull-to-refresh” o botón de refrescar sin bloquear toda la pantalla.Errorcon un mensaje listo para UI (en apps grandes, suele mapearse desde errores de dominio).
Paso 2: definir eventos de usuario (opcional pero útil)
Si la pantalla tiene varias acciones, modelarlas como eventos ayuda a centralizar la lógica.
sealed interface UsersUiEvent { data object Retry : UsersUiEvent data object Refresh : UsersUiEvent}Paso 3: definir side-effects (eventos de una sola vez)
Los side-effects no deben vivir dentro del UiState si son “de una sola vez” (por ejemplo, mostrar un snackbar). Para eso, usa un SharedFlow o un Channel.
sealed interface UsersUiEffect { data class ShowSnackbar(val message: String) : UsersUiEffect}Paso 4: implementar el ViewModel
El ViewModel expone:
uiState: StateFlow<UsersUiState>effects: SharedFlow<UsersUiEffect>- métodos para manejar eventos:
onEvent(...)
En este caso práctico simularemos una carga que a veces falla, para ejercitar Loading/Success/Error y acciones de reintento/refresco.
import androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport kotlinx.coroutines.delayimport kotlinx.coroutines.flow.MutableSharedFlowimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.SharedFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.flow.asSharedFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.launchimport kotlin.random.Randomclass UsersViewModel : ViewModel() { private val _uiState = MutableStateFlow<UsersUiState>(UsersUiState.Loading) val uiState: StateFlow<UsersUiState> = _uiState.asStateFlow() private val _effects = MutableSharedFlow<UsersUiEffect>() val effects: SharedFlow<UsersUiEffect> = _effects.asSharedFlow() init { loadInitial() } fun onEvent(event: UsersUiEvent) { when (event) { UsersUiEvent.Retry -> loadInitial() UsersUiEvent.Refresh -> refresh() } } private fun loadInitial() { _uiState.value = UsersUiState.Loading viewModelScope.launch { runCatching { fetchUsersSimulated() }.onSuccess { items -> _uiState.value = UsersUiState.Success(items = items) }.onFailure { e -> _uiState.value = UsersUiState.Error(message = e.message ?: "Error desconocido") _effects.emit(UsersUiEffect.ShowSnackbar("No se pudo cargar. Intenta de nuevo.")) } } } private fun refresh() { val current = _uiState.value if (current is UsersUiState.Success) { _uiState.value = current.copy(isRefreshing = true) } viewModelScope.launch { runCatching { fetchUsersSimulated() }.onSuccess { items -> _uiState.value = UsersUiState.Success(items = items, isRefreshing = false) _effects.emit(UsersUiEffect.ShowSnackbar("Lista actualizada")) }.onFailure { e -> // Si falla el refresh, mantenemos los datos previos si existían val prev = _uiState.value if (prev is UsersUiState.Success) { _uiState.value = prev.copy(isRefreshing = false) } else { _uiState.value = UsersUiState.Error(message = e.message ?: "Error desconocido") } _effects.emit(UsersUiEffect.ShowSnackbar("Falló el refresco")) } } } private suspend fun fetchUsersSimulated(): List<UserItem> { delay(900) val fail = Random.nextFloat() < 0.35f if (fail) error("Error de red simulado") return List(10) { index -> UserItem(id = index + 1, name = "Usuario ${index + 1}") } }}Detalles importantes:
- Estado inmutable: en Success usamos
copypara cambiar solo lo necesario. - Refresh sin perder datos: si falla, se conserva la lista anterior y se apaga
isRefreshing. - Efectos separados: el snackbar se emite por
effects, no se guarda en el estado.
Conectar el ViewModel con Compose
Paso 5: recolectar el estado en la UI
En Compose, la UI observa el StateFlow y se recompone automáticamente cuando cambia. La pantalla se vuelve una función pura del estado.
import androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.PaddingValuesimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material3.Buttonimport androidx.compose.material3.CircularProgressIndicatorimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.SnackbarHostimport androidx.compose.material3.SnackbarHostStateimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.rememberimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport kotlinx.coroutines.flow.collectLatest@Composablefun UsersScreen( modifier: Modifier = Modifier, vm: UsersViewModel = viewModel()) { val state = androidx.lifecycle.compose.collectAsStateWithLifecycle(vm.uiState).value val snackbarHostState = remember { SnackbarHostState() } // Side-effects: se consumen una sola vez LaunchedEffect(Unit) { vm.effects.collectLatest { effect -> when (effect) { is UsersUiEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message) } } } Scaffold( modifier = modifier, snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { padding -> UsersContent( state = state, onRetry = { vm.onEvent(UsersUiEvent.Retry) }, onRefresh = { vm.onEvent(UsersUiEvent.Refresh) }, modifier = Modifier.padding(padding) ) }}Paso 6: UI declarativa según el estado
Separamos un composable “contenedor” (conecta ViewModel) de un composable “presentacional” (solo recibe estado y callbacks). Esto mejora testabilidad y reutilización.
@Composablefun UsersContent( state: UsersUiState, onRetry: () -> Unit, onRefresh: () -> Unit, modifier: Modifier = Modifier) { when (state) { UsersUiState.Loading -> { Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { CircularProgressIndicator() Text( text = "Cargando...", modifier = Modifier.padding(top = 12.dp) ) } } is UsersUiState.Error -> { Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = state.message, color = MaterialTheme.colorScheme.error ) if (state.canRetry) { Button(onClick = onRetry, modifier = Modifier.padding(top = 12.dp)) { Text("Reintentar") } } } } is UsersUiState.Success -> { Column(modifier = modifier.fillMaxSize()) { // Barra simple de acciones Column(modifier = Modifier.padding(16.dp)) { Button(onClick = onRefresh, enabled = !state.isRefreshing) { Text(if (state.isRefreshing) "Refrescando..." else "Refrescar") } } LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(state.items) { user -> Text(text = "${user.id}. ${user.name}") } } } } }}Observa cómo:
- La UI no llama a funciones de carga directamente; solo dispara
onRefreshoonRetry. - El estado determina si el botón está habilitado y qué texto muestra.
- La lista se renderiza únicamente cuando hay Success.
Manejo de side-effects en Compose (patrones comunes)
Cuándo usar UiState vs UiEffect
| Necesidad | Recomendación | Ejemplo |
|---|---|---|
| Representar lo que la pantalla muestra de forma persistente | UiState | Loading, lista de usuarios, indicador de refresco |
| Acción de una sola vez que no debe repetirse al recomponer | UiEffect | Snackbar, navegación, abrir un selector |
Errores típicos y cómo evitarlos
- Guardar mensajes de snackbar en el estado: al rotar o recomponer, se podría volver a mostrar. Solución: emitir un efecto.
- Hacer llamadas de red en el composable: puede ejecutarse múltiples veces. Solución: iniciar la carga en
initdel ViewModel o mediante un evento explícito. - Estados parciales inconsistentes: por ejemplo,
isRefreshingtrue sin datos. Solución: modelar estados claros (sealed) y actualizar de forma atómica.
Extensión del caso práctico: unificar eventos en una sola función
Si la pantalla crece, centralizar eventos evita que la UI conozca demasiados métodos. Ya lo hicimos con onEvent. Puedes ampliarlo con eventos como seleccionar un usuario:
sealed interface UsersUiEvent { data object Retry : UsersUiEvent data object Refresh : UsersUiEvent data class UserClicked(val id: Int) : UsersUiEvent}Y en el ViewModel emitir un efecto de navegación (sin implementarla aquí):
sealed interface UsersUiEffect { data class ShowSnackbar(val message: String) : UsersUiEffect data class NavigateToUserDetail(val id: Int) : UsersUiEffect}La UI solo recolecta el efecto y ejecuta la acción correspondiente (por ejemplo, llamar al sistema de navegación que uses).