Una de las preguntas que más me hacen cuando enseño Compose es dónde debe vivir el estado de una pantalla. Al principio todo cabe en un remember, pero apenas la pantalla crece, o el usuario gira el teléfono, las cosas se complican. En este tutorial te muestro cómo usar StateFlow en Jetpack Compose junto con un ViewModel para tener un estado ordenado, que sobrevive a la rotación y que puedes probar sin abrir el emulador.
Vamos a construir algo pequeño pero útil aquí en Nicaragua: un conversor de córdobas a dólares. Tú escribes el monto y la tasa de cambio del día, y la app te dice cuántos dólares son.
Por qué no dejar todo en remember
remember guarda un valor mientras el composable esté en la composición. Si giras la pantalla, la Activity se recrea y ese valor se pierde. Existe rememberSaveable, que sí lo conserva, pero sigue mezclando la lógica con la interfaz.
El ViewModel resuelve las dos cosas: sobrevive a los cambios de configuración y te deja sacar la lógica de la UI. La documentación oficial de ViewModel lo recomienda justo para guardar el estado de pantalla. Y StateFlow es la forma natural de exponer ese estado: siempre tiene un valor actual y avisa cada vez que cambia.
Dependencias que necesitas
En el build.gradle.kts de tu módulo app agrega esto. Son las versiones estables que había en Google Maven al momento de escribir este artículo (octubre de 2026); revisa si hay alguna más nueva:
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
}
lifecycle-viewmodel-compose te da la función viewModel() y lifecycle-runtime-compose te da collectAsStateWithLifecycle(), que es la pieza clave de este tutorial.
Paso 1: un solo objeto para el estado de la pantalla
Me gusta agrupar todo lo que la pantalla necesita mostrar en una data class. Así hay una sola fuente de verdad y no tienes cinco variables sueltas.
data class ConversorUiState(
val montoTexto: String = "",
val tasaTexto: String = "",
val resultado: String? = null,
val error: String? = null
)
Fíjate que el monto y la tasa son String. Es lo que el usuario escribe en el TextField, y si lo convertimos a número de una vez, no podría borrar el campo ni escribir «36,» a medias.
Paso 2: el ViewModel con StateFlow
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.util.Locale
class ConversorViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ConversorUiState())
val uiState: StateFlow<ConversorUiState> = _uiState.asStateFlow()
fun onMontoChange(nuevoMonto: String) {
_uiState.update { it.copy(montoTexto = nuevoMonto) }
recalcular()
}
fun onTasaChange(nuevaTasa: String) {
_uiState.update { it.copy(tasaTexto = nuevaTasa) }
recalcular()
}
private fun recalcular() {
_uiState.update { estado ->
val monto = estado.montoTexto.replace(',', '.').toDoubleOrNull()
val tasa = estado.tasaTexto.replace(',', '.').toDoubleOrNull()
when {
estado.montoTexto.isBlank() || estado.tasaTexto.isBlank() ->
estado.copy(resultado = null, error = null)
monto == null || tasa == null ->
estado.copy(resultado = null, error = "Escribe solo números")
tasa <= 0.0 ->
estado.copy(resultado = null, error = "La tasa debe ser mayor que cero")
else ->
estado.copy(
resultado = String.format(Locale.US, "%.2f", monto / tasa),
error = null
)
}
}
}
}
Tres detalles que vale la pena explicar:
_uiStatees privado y mutable;uiStatees público y de solo lectura. La pantalla puede leer el estado, pero solo el ViewModel lo cambia. Es el patrón que usa Google en sus ejemplos y el que uso en mis proyectos.update { }en vez de_uiState.value = .... La funciónupdatemodifica el valor de forma atómica, así no pierdes cambios si dos actualizaciones llegan al mismo tiempo.replace(',', '.')porque mucha gente aquí escribe los decimales con coma. Es un detalle chiquito que evita reclamos.
Uso Double para que el ejemplo sea simple. Si estás haciendo algo con dinero de verdad, como facturación, usa BigDecimal.
Paso 3: leer el StateFlow en Jetpack Compose
Aquí es donde entra collectAsStateWithLifecycle(). Convierte el StateFlow en un State de Compose, y cada vez que el estado cambia, la pantalla se recompone.
@Composable
fun ConversorScreen(
viewModel: ConversorViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ConversorContent(
uiState = uiState,
onMontoChange = viewModel::onMontoChange,
onTasaChange = viewModel::onTasaChange
)
}
@Composable
fun ConversorContent(
uiState: ConversorUiState,
onMontoChange: (String) -> Unit,
onTasaChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = uiState.montoTexto,
onValueChange = onMontoChange,
label = { Text("Monto en córdobas (C$)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uiState.tasaTexto,
onValueChange = onTasaChange,
label = { Text("Tasa de cambio (C$ por US$)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
uiState.error?.let { mensaje ->
Text(text = mensaje, color = MaterialTheme.colorScheme.error)
}
uiState.resultado?.let { dolares ->
Text(
text = "Equivale a US$ $dolares",
style = MaterialTheme.typography.headlineSmall
)
}
}
}
Los imports que necesitas para esta parte son androidx.lifecycle.compose.collectAsStateWithLifecycle, androidx.lifecycle.viewmodel.compose.viewModel y androidx.compose.runtime.getValue (este último para que funcione el by).
¿Por qué collectAsStateWithLifecycle y no collectAsState?
Los dos funcionan, pero collectAsState() sigue escuchando el flujo aunque la app esté en segundo plano. Según la guía oficial de corrutinas con componentes del ciclo de vida, collectAsStateWithLifecycle() empieza a recolectar cuando el ciclo de vida está en STARTED y se detiene en STOPPED. En una pantalla que escucha la base de datos o la red, eso es batería y datos que el usuario no gasta.
Separar Screen y Content
Habrás notado que hay dos composables. ConversorScreen conoce al ViewModel; ConversorContent solo recibe datos y funciones. Esto te permite hacer previews y pruebas de UI sin crear un ViewModel, y es lo que la documentación llama elevación de estado (state hoisting).
Paso 4: mostrar la pantalla
En tu MainActivity solo tienes que llamar la pantalla:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
ConversorScreen()
}
}
}
}
}
Corre la app, escribe un monto y la tasa del día (la oficial la publica el Banco Central de Nicaragua) y gira el teléfono. Los datos siguen ahí.
Bonus: probar el ViewModel sin emulador
Como la lógica está en el ViewModel, puedes probarla con una prueba unitaria normal en src/test:
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ConversorViewModelTest {
@Test
fun convierte_cordobas_a_dolares() {
val viewModel = ConversorViewModel()
viewModel.onMontoChange("1000")
viewModel.onTasaChange("40")
assertEquals("25.00", viewModel.uiState.value.resultado)
viewModel.onTasaChange("0")
assertNull(viewModel.uiState.value.resultado)
}
}
Uso una tasa de 40 solo porque da números redondos; no es la tasa real.
Lo que sigue
Este mismo patrón es el que vas a usar cuando la pantalla lea datos de Room o de una API: el ViewModel expone un StateFlow y la UI solo lo observa. Si quieres ver otro ejemplo de manejo de estado, tengo el repositorio JetpackComposeStateManagementExample en mi GitHub, y en mi canal de YouTube hay tutoriales de Room, navegación y MVVM con Compose. Y si quieres aprender Android desde cero con un camino ordenado, eso es lo que cubro en mi curso Android & Kotlin Masterclass en Udemy.
Si lo pruebas y te sale algún error, déjamelo en los comentarios y lo vemos.



