LiveData vs StateFlow — ¿cuándo usar cada uno?
LiveData sigue siendo válida y funciona bien. Pero tiene una limitación: está atada a Android (necesita un LifecycleOwner) y no puede usarse fácilmente en capas que no deberían conocer el framework (repositorios, usecases).
StateFlow es parte de Kotlin Coroutines: es puro Kotlin, más potente, composable con otros flows, y se está convirtiendo en el estándar en proyectos modernos.
- Usá LiveData: si el proyecto ya la usa, si estás aprendiendo o si el equipo la conoce mejor.
- Usá StateFlow: en proyectos nuevos, si ya usás coroutines, o si querés alinearte con las guías actuales de Google.
StateFlow
StateFlow es un flow que siempre tiene un valor actual y emite actualizaciones. Es el equivalente de MutableLiveData pero basado en coroutines:
// Antes (LiveData):
private val _contador = MutableLiveData(0)
val contador: LiveData<Int> = _contador
// Ahora (StateFlow):
private val _contador = MutableStateFlow(0)
val contador: StateFlow<Int> = _contador.asStateFlow()
Para actualizar el valor desde el ViewModel:
// Desde el hilo principal:
_contador.value = _contador.value + 1
// Dentro de una corrutina (con update para thread-safety):
_contador.update { it + 1 }
Patrón UiState sellado
En lugar de tener múltiples LiveData/StateFlows separados para loading, error y datos, el patrón moderno agrupa todo el estado de la pantalla en una sealed class:
// El estado posible de la pantalla de productos
sealed class ProductosUiState {
object Loading : ProductosUiState()
data class Success(val productos: List<Producto>) : ProductosUiState()
data class Error(val mensaje: String) : ProductosUiState()
}
// En el ViewModel: un solo StateFlow para todo el estado
private val _uiState = MutableStateFlow<ProductosUiState>(ProductosUiState.Loading)
val uiState: StateFlow<ProductosUiState> = _uiState.asStateFlow()
¿Por qué sealed class?El compilador sabe todos los subtipos posibles, así que el when es exhaustivo. Si agregás un nuevo estado y olvidás manejarlo en la UI, el compilador te avisa. No hay posibilidad de estados inconsistentes (loading + error al mismo tiempo).
ViewModel completo con StateFlow y coroutines
class ProductosViewModel(
private val repository: ProductoRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ProductosUiState>(ProductosUiState.Loading)
val uiState: StateFlow<ProductosUiState> = _uiState.asStateFlow()
init {
cargarProductos()
}
fun cargarProductos() {
viewModelScope.launch {
_uiState.value = ProductosUiState.Loading
try {
val productos = repository.obtenerProductos() // suspending
_uiState.value = ProductosUiState.Success(productos)
} catch (e: Exception) {
_uiState.value = ProductosUiState.Error(e.message ?: "Error desconocido")
}
}
}
}
viewModelScope es un CoroutineScope ligado al ciclo de vida del ViewModel: cuando el ViewModel se destruye, cancela todas sus corrutinas automáticamente.
Collectar StateFlow en el Fragment
Para observar un Flow desde la UI de forma lifecycle-safe, usás repeatOnLifecycle:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when (state) {
is ProductosUiState.Loading -> {
binding.progressBar.isVisible = true
binding.recyclerView.isVisible = false
binding.tvError.isVisible = false
}
is ProductosUiState.Success -> {
binding.progressBar.isVisible = false
binding.recyclerView.isVisible = true
adapter.submitList(state.productos)
}
is ProductosUiState.Error -> {
binding.progressBar.isVisible = false
binding.tvError.isVisible = true
binding.tvError.text = state.mensaje
}
}
}
}
}
}
repeatOnLifecycle es obligatorioSin repeatOnLifecycle, el flow sigue colectando aunque el Fragment esté en background (onStop), procesando actualizaciones innecesarias y potencialmente crasheando. repeatOnLifecycle(STARTED) pausa la colección cuando el Fragment va a onStop y la reanuda en onStart.
SharedFlow para eventos únicos
StateFlow siempre replay el último valor al nuevo colector. Para eventos que no deben repetirse (navegar a otra pantalla, mostrar un Snackbar), usás SharedFlow:
// En el ViewModel:
private val _eventos = MutableSharedFlow<UiEvento>()
val eventos: SharedFlow<UiEvento> = _eventos.asSharedFlow()
sealed class UiEvento {
data class NavVegarADetalle(val id: Int) : UiEvento()
data class MostrarError(val mensaje: String) : UiEvento()
}
fun guardarProducto(producto: Producto) {
viewModelScope.launch {
repository.guardar(producto)
_eventos.emit(UiEvento.NavVegarADetalle(producto.id))
}
}
// En el Fragment:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.eventos.collect { evento ->
when (evento) {
is UiEvento.NavVegarADetalle ->
findNavController().navigate(
HomeFragmentDirections.actionHomeToDetalle(evento.id)
)
is UiEvento.MostrarError ->
Snackbar.make(binding.root, evento.mensaje, Snackbar.LENGTH_LONG).show()
}
}
}
}