wandres.dev
KOTLIN PARA MVI · data y sealed classes

Modelar el estado de una pantalla: un solo data class, cero estados imposibles

Una pantalla no se modela sumando campos hasta que la interfaz se pinta, sino eligiendo el tipo cuya cardinalidad coincide con el numero de situaciones que la pantalla admite de verdad. Esta leccion introduce la aritmetica de los tipos -el producto multiplica casos, la suma los agrega- para mostrar por que un puñado de flags independientes hace explotar el espacio de estados representables y llena la aplicacion de combinaciones sin sentido. Propone el metodo inverso: enumerar primero las situaciones reales de la pantalla, colapsar las excluyentes en un tipo suma, dejar en el data class raiz solo lo verdaderamente ortogonal, distinguir el estado de presentacion del dato de dominio y decidir con criterio qué no debe vivir nunca dentro del estado.

⏱ 19 min

Casi todo el mundo modela el estado de una pantalla del mismo modo: escribe el primer campo que hace falta, luego el segundo, y va añadiendo booleanos y opcionales a medida que la interfaz se lo pide, hasta que todo se pinta. El resultado compila, funciona en el camino feliz y contiene, sin que nadie lo haya decidido, decenas de configuraciones absurdas que ningun diseñador dibujo jamas y que tarde o temprano alguien alcanzara. El metodo alternativo consiste en invertir el orden: enumerar primero las situaciones que la pantalla admite de verdad y elegir despues el tipo cuya cardinalidad coincida exactamente con esa lista. Es una disciplina que se aprende en una tarde, produce codigo mas corto y elimina de raiz una familia entera de bugs, porque los estados imposibles dejan de ser algo contra lo que uno se defiende y pasan a ser algo que el lenguaje no permite escribir.

🎯 Al terminar esta lección sabrás
  • Contar los estados representables de un modelo y compararlos con los estados realmente validos.
  • Colapsar campos mutuamente excluyentes en un tipo suma dentro de un unico data class raiz.
  • Distinguir estado ortogonal, que convive, de estado excluyente, que se agrupa.
  • Decidir qué no pertenece al estado: efectos de una sola vez, dependencias y datos derivados.

La aritmetica de los tipos: contar antes de escribir

Todo tipo tiene una cardinalidad, es decir, un numero de valores distintos que puede tomar. Boolean tiene dos; un String opcional tiene, a efectos practicos, “muchos mas uno”; un enum de tres casos tiene tres. Lo interesante es que las dos formas de combinar tipos se comportan como las dos operaciones aritmeticas que les dan nombre. Un data class es un producto: sus cardinalidades se multiplican, porque cada campo varia con independencia de los demas. Una jerarquia sellada es una suma: sus cardinalidades se agregan, porque el valor es exactamente uno de los casos.

// PRODUCTO: 2 x (muchos + 1) x (muchos + 1) combinaciones representables
data class MalEstado(
    val cargando: Boolean,
    val error: String?,
    val datos: List<Tarea>?,
)

Haz el recuento sobre ese modelo simplificando los opcionales a “presente o ausente”: salen ocho combinaciones. Ahora pregunta cuantas describen una pantalla que alguien pueda dibujar. Cargando con error presente no significa nada. Error y datos a la vez tampoco. Ni cargando, ni error, ni datos es el limbo del que ningun render sabe salir. De las ocho combinaciones representables, tres son legitimas y cinco son basura que el tipo autoriza y que el codigo debera esquivar para siempre a base de condicionales defensivos y de disciplina.

El experimento mental que hace evidente el problema es intentar escribir el render. Con el modelo anterior, la vista tiene que decidir una prioridad no escrita: ¿mira primero si hay error o primero si esta cargando? Cualquiera de las dos respuestas es una regla de negocio implicita que vive en la vista, que nadie documento y que la siguiente pantalla resolvera al reves. Los estados imposibles no solo generan bugs: reparten logica por lugares donde no deberia haberla.

// La vista se ve obligada a inventar una precedencia que el tipo no fija
if (state.error != null) Aviso(state.error)
else if (state.cargando) Spinner()
else if (state.datos != null) Filas(state.datos)
else Nada()   // el limbo: ¿que se pinta aqui?

Ese cociente entre representable y valido es la metrica que conviene interiorizar. Cada campo independiente que añades multiplica el espacio; cada caso que agrupas en un tipo suma solo lo aumenta. Un estado con siete booleanos tiene ciento veintiocho configuraciones y casi con certeza describe menos de diez pantallas reales: el resto son huecos donde viven los bugs que nadie reproduce.

Del recuento al modelo: un solo data class raiz

Conviene añadir un matiz sobre la cardinalidad, para no convertirla en fetiche. Nadie pretende que un estado con un campo de texto libre tenga pocas configuraciones: un String ya aporta infinitas y eso no es un defecto, porque todas ellas son validas. Lo que la metrica mide no es el tamaño absoluto sino la proporcion de basura, es decir, cuantas de las configuraciones que el tipo autoriza describen una pantalla que alguien podria dibujar. Un estado con infinitos valores validos esta sano; uno con ocho valores de los cuales cinco son absurdos, no.

El metodo correcto empieza por la lista de situaciones, no por la lista de campos. Sientate con el diseño y enumera: la pantalla puede estar cargando por primera vez, puede haber fallado, puede mostrar una lista con contenido, puede mostrar una lista vacia. Cuatro situaciones excluyentes. Esa enumeracion es el tipo suma, y escribirla es casi mecanico.

sealed interface Contenido {
    data object Cargando : Contenido
    data class Fallo(val mensaje: String, val reintentable: Boolean) : Contenido
    data object Vacio : Contenido
    data class Lista(val tareas: List<Tarea>) : Contenido
}

data class TareasState(
    val contenido: Contenido = Contenido.Cargando,
    val borrador: String = "",
    val refrescando: Boolean = false,
) {
    val puedeAnadir: Boolean get() = borrador.isNotBlank()
}

Fijate en tres decisiones. La primera: los datos viajan dentro del caso al que pertenecen. mensaje solo existe cuando hay fallo, tareas solo cuando hay lista; nadie tendra que preguntarse nunca qué significa un mensaje de error mientras se muestran datos, porque esa pregunta ya no se puede formular. La segunda: Vacio es un caso propio y no una lista de longitud cero, porque la pantalla vacia suele tener su propia ilustracion, su propio texto y su propia llamada a la accion; darle un nombre convierte una condicion implicita en un estado explicito. La tercera: puedeAnadir no se almacena, se deduce, y por eso no puede desincronizarse de borrador.

Con ese modelo, el render deja de inventar precedencias y se convierte en una correspondencia mecanica entre casos y dibujos. No hay rama final para el limbo porque el limbo no existe, y si mañana el diseño añade una situacion de sesion caducada, el compilador reclamara la rama que falta.

@Composable
fun Pantalla(state: TareasState) = when (val c = state.contenido) {
    Contenido.Cargando -> Spinner()
    Contenido.Vacio    -> EstadoVacio(onCrear = { /* ... */ })
    is Contenido.Fallo -> Aviso(c.mensaje, mostrarReintento = c.reintentable)
    is Contenido.Lista -> Filas(c.tareas)
}
flowchart TB
A[Tres flags sueltos cargando error datos] --> B[Ocho combinaciones representables]
B --> C[Cinco sin sentido que el codigo debe esquivar]
D[Un data class raiz con un tipo suma dentro] --> E[Cuatro situaciones representables]
E --> F[Cuatro validas y el render las cubre todas]
style A fill:#f38ba8,color:#11111b
style C fill:#f38ba8,color:#11111b
style D fill:#a6e3a1,color:#11111b
style F fill:#a6e3a1,color:#11111b

Ortogonal frente a excluyente: el criterio para decidir dónde va cada cosa

La pregunta operativa, ante cada campo nuevo, es una sola: ¿este dato puede coexistir con todos los demas, o es alternativo a alguno? Si coexiste, es ortogonal y su sitio es el data class raiz. Si es alternativo, es excluyente y debe entrar como caso de un tipo suma. Aplicar esa pregunta con honestidad resuelve la mayor parte de las dudas de modelado, y su virtud es que se responde mirando el diseño en vez de discutiendo sobre arquitectura: basta con imaginar las dos cosas dibujadas a la vez en la misma pantalla y ver si el resultado tiene sentido.

Escrito como codigo, el criterio produce estados que se leen como una descripcion del diseño y no como un inventario de variables.

data class BusquedaState(
    val consulta: String = "",              // ortogonal: persiste entre situaciones
    val refrescando: Boolean = false,       // ortogonal: se dibuja sobre el contenido
    val resultados: Resultados = Resultados.Inicial,  // excluyente: tipo suma
)

En el ejemplo anterior, borrador es ortogonal: el texto que el usuario esta escribiendo tiene sentido tanto si la lista carga como si falla, y por eso no debe vivir dentro de ningun caso. refrescando tambien lo es, y de hecho ilustra una distincion util: la carga inicial y el refresco no son el mismo fenomeno. La primera es excluyente con mostrar contenido —o hay spinner o hay lista— y por eso es un caso de Contenido. El segundo convive con el contenido, porque el indicador de refresco se dibuja sobre una lista ya visible, y por eso es un booleano en la raiz. Dos cosas que en el modelo ingenuo eran el mismo campo cargando resultan ser, examinadas, de naturaleza distinta.

Hay un caso intermedio que despista a mucha gente: el dato que solo tiene sentido en algunos casos pero debe sobrevivir al cambio de caso. Piensa en el texto de busqueda cuando la pantalla pasa de mostrar resultados a mostrar un error: si el texto vive dentro del caso de resultados, al fallar la peticion desaparece y el usuario pierde lo que habia escrito. La pregunta correcta no es solo si coexiste, sino si debe persistir a traves de las transiciones; lo que persiste va a la raiz, aunque momentaneamente no se pinte.

El error opuesto tambien existe y conviene nombrarlo: la sobre-suma. No todo debe ser un tipo sellado. Si una pantalla tiene doce campos de formulario que se editan libremente, convertirlos en jerarquia produce un monstruo con centenares de casos que solo describe combinaciones. El producto es la herramienta correcta para lo que varia junto e independientemente; la suma, para lo que se excluye. Modelar bien no es maximizar los tipos suma, es hacer coincidir la forma del tipo con la forma del problema.

Cuando la pantalla es grande, la herramienta que falta es la composicion: agrupar en sub-estados con nombre los conjuntos de campos que varian juntos y pertenecen a la misma zona de la interfaz. Un estado de checkout con veinte campos planos es ilegible; el mismo estado con tres sub-estados —direccion, pago y resumen— se lee como el diseño se ve, cada sub-estado se prueba por separado y las funciones que lo transforman tienen un alcance evidente. El precio es el copy anidado de la primera leccion, y es un precio razonable mientras el anidamiento no pase de dos niveles.

Qué no pertenece al estado

Un estado bien modelado se define tanto por lo que contiene como por lo que rechaza. El criterio de admision se enuncia en una pregunta: ¿la vista necesita este dato para dibujarse en este instante? Si la respuesta es no, sea cual sea la razon, el dato tiene otro sitio. Hay tres categorias que se cuelan una y otra vez pese a fallar esa prueba.

🤖

Eventos de una sola vez

Navegar, mostrar un aviso efimero, disparar una vibracion. No son propiedades de la pantalla sino sucesos, y guardarlos como campo produce el bug de que se repiten al rotar el dispositivo, porque el estado se restaura y el suceso vuelve a ejecutarse. Su sitio es el canal de efectos.

🧮

Datos derivados

Totales, filtros aplicados, banderas de validez. Si se pueden calcular a partir de otros campos, almacenarlos duplica una verdad y crea la posibilidad de que las dos copias discrepen. Su sitio es una propiedad con get() o, si el calculo es caro, una memoizacion explicita.

🔌

Dependencias y entidades de dominio

Repositorios, casos de uso, clientes de red y modelos crudos del servidor. El estado es lo que la vista necesita para pintarse, no el almacen de todo lo que la pantalla maneja; mezclar dominio y presentacion ata el render a la forma de la base de datos.

Un cuarto candidato al destierro, mas dificil de ver porque parece inofensivo, es el estado que en realidad pertenece a un componente: la posicion del scroll, si un desplegable esta abierto, el foco de un campo. Elevarlo todo al estado de la pantalla produce un UiState inflado que provoca recomposiciones globales por gestos puramente locales. La linea de corte util es preguntar si esa informacion sobrevive a la destruccion del proceso o influye en la logica: si no, dejala donde vive el componente.

💡
El estado de presentacion no es el modelo de dominio

Es tentador exponer directamente la entidad que llega del repositorio y ahorrarse una capa. Funciona hasta el primer cambio del servidor, momento en el que un renombrado remoto rompe el render. El estado de presentacion es una proyeccion deliberada: contiene la fecha ya formateada como el texto que se va a pintar, el precio ya convertido a la divisa del usuario, el identificador estable que la lista necesita para animar bien, y omite los quince campos del modelo de dominio que ninguna vista mira. Esa traduccion cuesta unas lineas de mapeo y compra dos cosas caras: que la vista sea trivial —solo dibuja lo que recibe, sin decidir— y que un cambio en el origen de datos se detenga en la frontera del mapeo en vez de propagarse por toda la interfaz.

Modelar el estado es decidir qué es imposible, y esa decision se toma una sola vez

Hay dos maneras de tratar con las situaciones absurdas de una interfaz, y la distancia entre ellas define el nivel de madurez de una base de codigo. La primera es defensiva y es la que casi todo el mundo practica sin haberla elegido: se asume que el estado puede contener combinaciones sin sentido y se blinda cada punto de uso contra ellas, con comprobaciones al pintar, con condiciones que ordenan la prioridad de los flags, con comentarios que explican qué combinacion no deberia ocurrir. Es un trabajo sin fin por razones estructurales: la defensa hay que repetirla en cada consumidor nuevo, se degrada cuando alguien añade un caso sin conocer las reglas no escritas, y no protege contra lo que nadie anticipo, que es precisamente lo que acaba fallando. La segunda manera es constructiva y consiste en gastar media hora, una sola vez, en elegir un tipo cuya cardinalidad coincida con el numero de situaciones reales de la pantalla. A partir de ese momento no hay nada contra lo que defenderse, porque la combinacion absurda no es un caso improbable sino un valor que no existe: no se puede construir, no se puede recibir, no se puede alcanzar por accidente ni por refactor. La diferencia entre ambos enfoques no es de estilo ni de gusto, es de coste asintotico. La defensa crece con el numero de consumidores y con el paso del tiempo; el modelado se paga una vez y su beneficio crece con esos mismos factores, porque cada consumidor nuevo hereda gratis la garantia y cada caso añadido a la jerarquia obliga al compilador a señalar los lugares afectados. Por eso conviene invertir el orden habitual del trabajo y empezar contando situaciones en lugar de escribiendo campos: los campos son faciles de añadir y dificiles de quitar, y cada uno que sobra multiplica en silencio el espacio de lo que puede ir mal.

⚔️ Cuenta los estados de una pantalla que ya tengas
  1. Toma el estado de una pantalla real y calcula su cardinalidad tratando cada booleano como dos y cada opcional como presente o ausente; anota el numero.
  2. Enumera aparte las situaciones que un diseñador dibujaria de verdad para esa pantalla y compara ambas cifras: el cociente es tu deuda de modelado.
  3. Agrupa en un tipo suma los campos mutuamente excluyentes, dejando dentro de cada caso solo los datos que ese caso necesita, y vuelve a contar.
  4. Aplica a cada campo restante la pregunta de si coexiste o se excluye, y justifica por qué la carga inicial y el refresco terminan en sitios distintos.
  5. Localiza en tu estado un evento de una sola vez, un dato derivado y una entidad de dominio filtrada, y explica adonde debe ir cada uno y qué bug concreto evitas al sacarlo.