wandres.dev
FLOW II · StateFlow y flujos calientes

StateFlow: valor siempre presente, conflación por igualdad y las emisiones que nunca verás

StateFlow es formalmente un SharedFlow con repetición uno, capacidad extra cero y descarte del más antiguo, más dos añadidos que lo cambian todo: un valor accesible sin suspender y una conflación por igualdad estructural que suprime cualquier emisión igual a la anterior. Esta lección demuestra por qué esas dos propiedades lo convierten en el tipo correcto para estado y en el tipo equivocado para eventos, cuántas emisiones intermedias puede tragarse legítimamente sin avisar, y por qué la trampa más cara del tipo no es la conflación sino usar una clase con equals mal definido.

⏱ 23 min

La definición operativa de StateFlow<T> cabe en una línea: es un SharedFlow con repetición uno, sin capacidad extra, con política de descartar el más antiguo, y con la obligación de tener siempre un valor. Todo lo demás se deduce de ahí. Que nunca suspenda al emitir se deduce de la política de descarte; que un suscriptor nuevo reciba siempre algo se deduce de la repetición uno; que sea un observable y no una cola se deduce de que el buffer tenga tamaño uno. Lo único que no se deduce, porque es un añadido deliberado, es la conflación por igualdad: si el valor entrante es estructuralmente igual al actual, no hay emisión en absoluto. Esa cláusula es la que convierte a StateFlow en la herramienta perfecta para el estado y en una fuente de bugs irreproducibles para todo lo demás.

🎯 Al terminar esta lección sabrás
  • Derivar el comportamiento de StateFlow desde su equivalencia formal con un SharedFlow concreto.
  • Explicar la conflación por igualdad y distinguirla de la conflación por velocidad que hace conflate.
  • Enumerar los dos mecanismos por los que un recolector puede no ver un valor que sí fue publicado.
  • Escoger entre estado y evento razonando sobre idempotencia y no sobre comodidad de la API.

Un flujo que siempre tiene una respuesta

La diferencia visible con SharedFlow es la propiedad value, legible y escribible sin suspender. Eso convierte a StateFlow en un puente entre el mundo suspendido y el mundo síncrono: cualquiera puede preguntar cuál es el estado ahora sin colectar nada, y quien colecta recibe el valor actual de forma inmediata en el momento de suscribirse, antes de cualquier emisión futura.

private val _pantalla = MutableStateFlow<Pantalla>(Pantalla.Cargando)
val pantalla: StateFlow<Pantalla> = _pantalla.asStateFlow()

fun cargar(datos: List<Fila>) {
    _pantalla.value = Pantalla.Contenido(datos)   // no suspende jamás
}

Como el buffer tiene tamaño uno y la política es descartar el más antiguo, publicar es una operación total: nunca falla, nunca espera y nunca informa de que alguien se lo perdió. La contrapresión, que en SharedFlow era una decisión, aquí sencillamente no existe: el emisor va tan rápido como quiera y los recolectores ven lo que alcanzan a ver. Esa es la primera fuente de emisiones invisibles y es de diseño.

Para actualizaciones que dependen del valor anterior no basta con leer value y escribirlo, porque entre ambas operaciones cabe otra escritura. La biblioteca ofrece update, que aplica la función en un bucle de comparación e intercambio hasta que gana. Su lambda debe ser pura y barata, porque puede ejecutarse varias veces.

_contador.update { it + 1 }        // atómico y correcto bajo concurrencia
_contador.value = _contador.value + 1   // carrera clásica: no lo hagas
ℹ️
Dos conflaciones distintas con el mismo nombre

El operador conflate descarta valores porque el consumidor es lento: es conflación por velocidad. StateFlow además descarta valores porque son iguales al anterior: es conflación por igualdad. Un recolector rápido escapa de la primera; de la segunda no escapa nadie.

Igualdad estructural: la cláusula que decide todo

La regla exacta es que una asignación a value solo produce emisión si el valor nuevo no es estructuralmente igual al actual, comparado con equals. No hay forma de desactivarlo, no hay parámetro, no hay variante. Y como es equals quien decide, la corrección de tu flujo de datos queda delegada en la corrección de un método que casi nunca se revisa con esa responsabilidad en mente.

🧊

Data class bien definida

Todos los campos relevantes en el constructor primario. La igualdad significa lo que crees y la conflación te ahorra recomposiciones idénticas. Es el caso feliz.

🪤

Campos fuera del constructor

Una data class con propiedades en el cuerpo las excluye de equals. Dos estados distintos se declaran iguales y la actualización desaparece sin dejar rastro.

🧨

Estado mutable dentro

Si guardas una lista mutable y la modificas en sitio, el objeto es el mismo y equals da cierto. Publicaste, nadie se enteró, y el depurador te muestra el valor correcto.

🆔

Identidad por defecto

Una clase normal sin equals compara por referencia, así que dos instancias equivalentes siempre emiten. Funciona, pero anula el beneficio y multiplica el trabajo aguas abajo.

El caso de la mutación en sitio merece detenerse porque es el que consume tardes enteras. El valor se actualizó, la propiedad value devuelve el objeto con los datos nuevos, y sin embargo ningún recolector reaccionó: como se mutó el mismo objeto, la comparación con el anterior —que es él mismo— dio cierto y no hubo emisión. El síntoma es una interfaz que se queda congelada mostrando lo viejo mientras el estado en memoria es correcto. La cura es estructural, no táctica: el tipo de estado debe ser inmutable de arriba abajo.

// Mal: la lista es la misma instancia, equals da true, no hay emisión.
_estado.value.items.add(nuevo)

// Bien: instancia nueva, equals da false, emisión garantizada.
_estado.update { it.copy(items = it.items + nuevo) }
flowchart TD
A[Asignacion a value] --> B{El nuevo es igual al actual}
B -->|Si| C[No hay emision y nadie se entera]
B -->|No| D[Se guarda como valor actual]
D --> E{El recolector va mas rapido que el emisor}
E -->|Si| F[Ve todos los valores]
E -->|No| G[Ve solo el ultimo de la rafaga]

Por qué no sirve para eventos

Juntando las dos causas anteriores, un valor publicado en un StateFlow puede no llegar nunca a un recolector por dos motivos independientes: porque era igual al anterior, o porque llegó otro antes de que el recolector volviera a mirar. Ninguno de los dos es un fallo; ambos son el comportamiento contratado. Y ambos son inaceptables si lo que transportas es un hecho puntual.

El ejemplo canónico es el mensaje de error. Si publicas un error, el usuario lo descarta, y ocurre exactamente el mismo error otra vez, el segundo no emite: es igual al primero. Desde fuera parece que el sistema dejó de fallar. Los apaños habituales —añadir una marca de tiempo o un identificador aleatorio al modelo de estado para forzar la desigualdad— funcionan y son una confesión: estás introduciendo ruido en el tipo para desactivar una conflación que no querías, lo cual demuestra que el tipo elegido era el equivocado.

La regla de decisión es de dominio y se enuncia en una pregunta: ¿aplicar este valor dos veces seguidas es lo mismo que aplicarlo una? Si la respuesta es sí, es estado y StateFlow es correcto. Si es no, es un evento y necesitas un SharedFlow sin conflación —o un Channel si además debe consumirlo exactamente uno.

// Estado: idempotente, sobrevive a la reconstrucción de la pantalla.
val perfil: StateFlow<Perfil> = _perfil.asStateFlow()

// Evento: cada aparición cuenta, ninguna se puede conflar.
val avisos: SharedFlow<Aviso> = _avisos.asSharedFlow()

Queda un último rasgo, fácil de olvidar y con consecuencias en las pruebas: StateFlow tampoco completa nunca. Su collect no retorna, y por eso hay que acotarlo con el ámbito o con operadores como first y take. Y su igualdad como objeto es de identidad: dos StateFlow distintos con el mismo valor no son iguales entre sí, aunque distinctUntilChanged aplicado sobre uno de ellos sea, por definición, una operación sin efecto.

⚠️
No pongas distinctUntilChanged sobre un StateFlow

Ya está conflado por igualdad en la fuente. Añadir el operador no cambia nada observable y sí sugiere al lector que ahí puede haber duplicados, que es justamente lo contrario de lo que el tipo garantiza.

Derivar estado de otro estado

Aplicar un operador a un StateFlow devuelve un Flow corriente: frío, sin valor actual y sin conflación. Eso sorprende a mucha gente que espera que map sobre un estado siga siendo un estado, y es coherente: la transformación no puede saber si tu función es barata ni si su resultado tiene sentido como valor inicial, así que no lo asume.

// Esto NO es un StateFlow: es un Flow frío que se recalcula por recolector.
val nombreVisible: Flow<String> = usuario.map { it.nombre.uppercase() }

// Esto sí, y además vuelve a conflar por igualdad tras la transformación.
val nombreEstado: StateFlow<String> = usuario
    .map { it.nombre.uppercase() }
    .stateIn(ambito, SharingStarted.WhileSubscribed(5_000), "")

La recuperación de la conflación tras el map no es un detalle menor, sino el motivo principal para derivar estado con stateIn: si dos usuarios distintos comparten el mismo nombre en mayúsculas, la proyección produce dos valores iguales consecutivos y el nuevo StateFlow suprime el segundo. La conflación, en otras palabras, se aplica en el tipo de salida y no en el de entrada, así que proyectar a un tipo más pobre reduce el número de emisiones. Es una propiedad explotable: exponer a la interfaz un estado con exactamente los campos que pinta elimina emisiones que no cambian nada visible.

Al combinar varios StateFlow con combine, el resultado también deja de ser estado y hay que volver a fijarlo. Ahí aparece un matiz temporal importante: mientras el resultado combinado sea un flujo frío, cada recolector paga la combinación entera, y como combine emite por cada cambio de cualquiera de sus fuentes, el estado derivado pasa por combinaciones intermedias antes de estabilizarse. Con stateIn esas intermedias siguen existiendo, pero solo se calculan una vez y quien llegue tarde ve directamente la última.

Y hay un matiz de rendimiento que suele ir en dirección contraria a la intuición: como equals se evalúa en cada publicación, un estado con estructuras grandes paga una comparación profunda por intento de escritura, incluso cuando el resultado es que no hay que emitir. Con listas de miles de elementos que se republican en bucle, esa comparación puede costar más que la emisión que evita. La solución no es abandonar la inmutabilidad sino reducir la granularidad de lo que se publica: si solo cambia un campo, el estado que lo contiene debería ser lo bastante pequeño como para que compararlo sea trivial.

// Comparar esto en cada publicación es caro y ocurre siempre.
data class Estado(val filas: List<Fila>, val seleccion: Int)

// Separar lo que cambia rápido de lo que cambia despacio abarata la conflación.
data class Estado(val filas: ListaEstable, val seleccion: Int)
💡
Un estado por pantalla, no un flujo por campo

Combinar diez StateFlow en la capa de presentación multiplica las emisiones intermedias. Modelar un único estado inmutable con diez campos y actualizarlo con update produce una sola emisión coherente por cambio, y la conflación por igualdad hace el resto.

La conflación por igualdad no es una optimización de rendimiento: es una afirmación semántica según la cual dos valores indistinguibles son el mismo hecho, y esa afirmación es falsa para todo aquello que ocurre en vez de ser

Merece la pena mirar de frente lo que esa cláusula está diciendo, porque se presenta como una amabilidad de la biblioteca —te ahorro recomposiciones inútiles— y en realidad es una ontología impuesta. Al comparar con equals antes de emitir, StateFlow declara que el mundo que modela está hecho de configuraciones y no de sucesos: si la configuración no cambió, no hay nada que contar, porque el valor de un estado es su contenido y dos contenidos idénticos son literalmente el mismo estado. Para todo lo que efectivamente es una configuración —el usuario que ha iniciado sesión, la lista visible, la posición del reproductor, el modo oscuro— esa afirmación es exacta y además muy valiosa, porque convierte la idempotencia en una propiedad del transporte: puedes republicar el estado completo tantas veces como quieras, desde tantos sitios como quieras, y el sistema converge sin trabajo redundante, lo que a su vez te permite reconstruir cualquier consumidor en cualquier momento sin coordinación. Ese es el motivo real por el que el tipo existe y por el que las interfaces declarativas se apoyan en él: la conflación por igualdad es lo que hace que volver a pintar lo mismo sea gratis y que un observador que llega tarde no necesite historia, porque el presente contiene todo lo que necesita saber. El problema aparece cuando alguien mete en ese transporte algo que no es una configuración sino un suceso —un error, una navegación, un mensaje, una vibración—, porque entonces la misma cláusula que garantizaba la convergencia empieza a borrar información: el segundo error idéntico no es el mismo error, la segunda pulsación no es la misma pulsación, y sin embargo equals no puede distinguirlos porque la diferencia entre ambos no está en su contenido sino en su ocurrencia, que es precisamente lo que un valor no representa. Ahí es donde nacen los apaños con identificadores aleatorios y marcas de tiempo, y donde conviene detenerse a leer lo que ese apaño confiesa: si tienes que añadirle a tu valor un campo cuyo único propósito es que dos valores iguales dejen de serlo, lo que estás construyendo a mano es la ausencia de conflación, es decir, un SharedFlow. La elección entre los dos tipos no es de estilo ni de comodidad, y la pregunta que la resuelve no menciona la palabra flujo: pregunta si aplicar el valor dos veces seguidas equivale a aplicarlo una. Todo lo idempotente es estado; lo demás ocurre, y lo que ocurre se cuenta, no se guarda.

⚔️ Provoca las pérdidas a propósito
  1. Publica en un MutableStateFlow<Int> la secuencia uno, uno, dos con un recolector que imprime. Cuenta las impresiones y explica la que falta.
  2. Emite mil valores en un bucle apretado con un recolector que tarda un milisegundo. Cuenta cuántos ve y comprueba que el último siempre está.
  3. Modela un estado con una data class que tenga una propiedad declarada en el cuerpo y no en el constructor. Cámbiala, publica y observa el silencio. Arréglalo moviendo la propiedad.
  4. Guarda una MutableList dentro del estado, añádele un elemento en sitio y publica el mismo objeto. Explica por qué el depurador te da la razón y la interfaz no.
  5. Toma un StateFlow de errores de tu proyecto real, dispara el mismo error dos veces seguidas y documenta lo que ve el usuario. Migra ese caso a SharedFlow y vuelve a medirlo.