Model: un único estado inmutable como salida
El Model de MVI es un unico UiState inmutable que concentra todo lo que la pantalla necesita para dibujarse: una sola fuente de la verdad de la que la vista es funcion pura. Esta leccion muestra como se expresa en Kotlin con una data class de campos val, como copy produce estados nuevos con structural sharing en vez de mutar, y como modelar el contenido con tipos suma elimina de raiz los estados imposibles que aparecen cuando isLoading, error y datos conviven como campos independientes. Explica por que el estado se expone como StateFlow con valor inicial obligatorio y por que las anotaciones Immutable y Stable importan para que Compose no recomponga de mas.
Si el intent es la entrada del ciclo, el Model —el estado— es su salida, y en MVI esa salida es una sola cosa: un valor inmutable que contiene absolutamente todo lo que la pantalla necesita para pintarse en un instante dado. No varios observables sueltos que la vista combina, no un flag por aquí y una lista por allá, sino un único objeto que es, literalmente, una fotografía de la interfaz. La regla que gobierna este objeto es la misma que rige un reducer de Redux: es inmutable, nunca se muta, y cada cambio produce un ejemplar nuevo. De esa inmutabilidad, que parece una restricción incómoda, brotan la renderización determinista, la capacidad de comparar estados por referencia y la posibilidad de razonar sobre la pantalla como sobre un valor y no como sobre un proceso.
- Diseñar un
UiStatecomodata classinmutable que sea la única fuente de la verdad de la pantalla. - Producir estados nuevos con
copyen vez de mutar, apoyándote en el structural sharing del nivel 7. - Eliminar los estados imposibles modelando el contenido con un tipo suma en vez de flags sueltos.
- Exponer el estado como
StateFlowcon valor inicial y entender por qué@Immutableimporta en Compose.
Un solo objeto, una sola verdad
El estado de MVI se escribe como una data class de campos val. Que todos los campos sean val no es un detalle de estilo: es lo que garantiza que, una vez creado, ese estado no cambiará jamás. Si algo debe cambiar, no se toca el objeto: se fabrica otro con copy. La data class regala además equals y hashCode estructurales, que serán la clave para que Compose y las pruebas comparen estados con sentido.
@Immutable
data class TareasState(
val borrador: String = "",
val contenido: Contenido = Contenido.Cargando,
) {
// Estado derivado: se calcula al vuelo, no se almacena ni se duplica
val puedeAnadir: Boolean get() = borrador.isNotBlank()
}
Presta atención a puedeAnadir. Es estado derivado —una lección entera, la del nivel 6— y por eso no es un campo almacenado sino una propiedad calculada: se deduce de borrador cada vez que se lee. Guardarlo como un val más sería duplicar una verdad que ya existe y arriesgarse a que las dos copias se desincronicen. En un estado inmutable bien diseñado, cada hecho vive en un solo sitio y todo lo demás se calcula.
Estados imposibles: el pecado de los flags sueltos
El error más caro al diseñar un estado es representar una pantalla que carga datos con tres campos independientes: val cargando: Boolean, val error: String?, val datos: List<Tarea>?. Tres campos booleanos-o-nulos son ocho combinaciones, y de esas ocho la mayoría no tiene sentido: ¿qué significa cargando = true con un error no nulo? ¿O error y datos presentes a la vez? Has creado un tipo que puede expresar estados que tu interfaz no sabe pintar, y tarde o temprano el código entrará en uno de ellos.
La cura, que ya viste en el nivel 9, es modelar el contenido como un tipo suma: los estados mutuamente excluyentes se vuelven ramas de un sealed interface, y las combinaciones imposibles dejan de ser representables.
// Cargando, Error y Lista son excluyentes: solo uno puede ser cierto
sealed interface Contenido {
data object Cargando : Contenido
data class Error(val mensaje: String) : Contenido
data class Lista(val tareas: List<Tarea>) : Contenido
}
Ahora la pantalla está o cargando, o en error, o mostrando una lista: nunca dos cosas a la vez, porque el tipo no lo permite. La vista, al hacer when sobre contenido, cubre exactamente los casos que existen. Los estados imposibles no se evitan con disciplina; se vuelven inexpresables.
flowchart TB BAD[tres flags sueltos: cargando error datos] --> OCHO[ocho combinaciones, cinco sin sentido] GOOD[un tipo suma Contenido] --> TRES[tres estados, todos validos] style BAD fill:#f38ba8,color:#11111b style OCHO fill:#f38ba8,color:#11111b style GOOD fill:#a6e3a1,color:#11111b style TRES fill:#a6e3a1,color:#11111b
copy, structural sharing y la exposición como StateFlow
Producir un estado nuevo no significa copiar el mundo entero. copy crea un objeto nuevo pero comparte las referencias a las partes que no cambiaron: eso es el structural sharing del nivel 7. Cambiar el borrador de un estado con mil tareas no clona la lista; el estado nuevo apunta a la misma lista de antes. La inmutabilidad es barata precisamente porque casi todo se reutiliza.
class TareasViewModel : ViewModel() {
// Valor inicial obligatorio: un StateFlow siempre tiene un estado actual
private val _state = MutableStateFlow(TareasState())
val state: StateFlow<TareasState> = _state.asStateFlow()
}
El estado se expone como StateFlow, no como Flow a secas, por una razón de fondo: un StateFlow siempre tiene un valor actual —por eso su constructor exige uno inicial—, así que la pantalla nunca está sin estado que pintar. Se expone en su versión de solo lectura con asStateFlow, de modo que el exterior pueda observar pero solo el ViewModel pueda escribir. Una única fuente, un único escritor.
Compose decide si recomponer un @Composable comparando sus parámetros. Si le pasas un tipo que no sabe estable, se pone a la defensiva y recompone de más. Anotar tu UiState con @Immutable es una promesa al compilador: este objeto no cambiará por dentro nunca, así que compara por referencia y no recompongas si la referencia es la misma. Cuidado con las colecciones: un List de la biblioteca estándar de Kotlin no se considera estable, porque su interfaz no prohíbe la mutación. Para colecciones dentro del estado, usa los tipos de kotlinx.collections.immutable como PersistentList, que sí son estables y encajan con el structural sharing. Sin esto, un estado inmutable puede seguir provocando recomposiciones innecesarias.
El salto mental que separa a quien usa MVI de quien lo entiende está en cómo concibe el estado. La intuición imperativa lo ve como una variable que va evolucionando: empieza vacía, le añades cosas, la modificas, la vas puliendo hasta que refleja lo que hay en pantalla. Esa imagen es exactamente la que MVI destierra. Aquí el estado no evoluciona: en cada instante es un valor completo, cerrado e inmutable, una fotografía nítida de la pantalla entera, y el cambio no es una mutación de esa foto sino la sustitución de una foto por otra. La pantalla, entonces, no es un proceso que vas dirigiendo, sino una función pura aplicada a la fotografía vigente: pantalla = f(estado). Y en cuanto aceptas esa reformulación, cosas que antes eran difíciles se vuelven triviales y cosas que antes eran imposibles aparecen gratis. La renderización se vuelve determinista, porque el mismo valor produce siempre los mismos píxeles. La comparación se vuelve barata, porque comparar dos estados inmutables por referencia responde al instante si algo cambió. El viaje en el tiempo se vuelve concebible, porque si guardas la secuencia de valores puedes volver a cualquiera de ellos con solo reponerlo. Y los estados imposibles desaparecen, porque cuando modelas el estado con tipos suma en vez de flags, la combinación absurda deja de existir en el universo de lo representable. Nada de esto lo consigues vigilando una variable con más cuidado; lo consigues cambiando la categoría del estado, de proceso mutable a valor inmutable, y dejando que la vista sea su consecuencia y no su origen.
- Escribe el
UiStatede una pantalla comodata classde camposval, y marca como propiedad derivada todo lo que se pueda calcular a partir de otros campos. - Toma una pantalla que uses hoy con
cargando,errorydatossueltos, cuenta las combinaciones posibles y señala cuáles son imposibles. - Reescribe ese contenido como un
sealed interfacey comprueba que las combinaciones imposibles ya no son ni siquiera expresables. - Cambia un solo campo con
copyen un estado que contenga una lista grande y explica, apoyándote en el structural sharing, por qué no se clonó la lista. - Anota el estado con
@Immutable, sustituye unListpor unPersistentListy razona qué recomposiciones de Compose acabas de evitar.