Gestión de estado y arquitectura con ViewModel y UI State en Kotlin

Capítulo 6

Tiempo estimado de lectura: 8 minutos

+ Ejercicio

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:

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

  • Loading para la primera carga.
  • isRefreshing para refresco “pull-to-refresh” o botón de refrescar sin bloquear toda la pantalla.
  • Error con 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 copy para 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 onRefresh o onRetry.
  • 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

NecesidadRecomendaciónEjemplo
Representar lo que la pantalla muestra de forma persistenteUiStateLoading, lista de usuarios, indicador de refresco
Acción de una sola vez que no debe repetirse al recomponerUiEffectSnackbar, 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 init del ViewModel o mediante un evento explícito.
  • Estados parciales inconsistentes: por ejemplo, isRefreshing true 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).

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la forma correcta de manejar un mensaje de snackbar para evitar que se repita al recomponer la UI en una pantalla con ViewModel y estado?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Los mensajes de una sola vez (como un snackbar) deben modelarse como side-effects y no dentro del UiState, para que no se repitan en recomposiciones. Por eso se emiten desde el ViewModel y la UI los consume una única vez.

Siguiente capítulo

Persistencia local en Android con Room y Kotlin

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
43%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.