El estado gigante: cuarenta campos en una sola data class
Ninguna pantalla nace con un estado de cuarenta campos: se llega ahí campo a campo, cada uno justificado en su momento y ninguno revisado después. Esta lección diagnostica el estado gigante por sus síntomas medibles —la explosión combinatoria de configuraciones imposibles, el reductor que arrastra treinta y siete campos para cambiar tres, la invalidación total del árbol de composición, el conflicto de fusión perpetuo— y establece los tres cortes que lo deshacen: por fase con una jerarquía sellada, por sección con subestados cohesivos y por pantalla cuando una misma clase servía a dos destinos. También delimita el error inverso, que consiste en fragmentar el estado en flujos independientes y reintroducir por la puerta de atrás la inconsistencia temporal que el estado único existía para eliminar.
El estado gigante no se escribe: se sedimenta. Nadie declara de golpe una data class con cuarenta propiedades; se llega ahí en once meses, con veinticinco personas distintas añadiendo cada una el campo que su historia de usuario necesitaba, todas ellas actuando de forma razonable en el momento local de su decisión. Por eso el estado gigante es el error arquitectónico más honesto del catálogo: no hay negligencia que señalar, solo la ausencia de un momento en el que alguien preguntara si el objeto seguía describiendo una cosa. Y por eso también es el más instructivo, porque obliga a distinguir entre dos preguntas que parecen la misma y no lo son: cuántos datos necesita la pantalla, que es una cuestión de requisitos, y cuántos objetos hacen falta para representarlos, que es una cuestión de diseño. Confundirlas produce siempre la misma respuesta equivocada: un objeto por pantalla, cueste lo que cueste.
- Diagnosticar el estado gigante por síntomas medibles y no por impresión estética.
- Cuantificar la explosión combinatoria y localizar las configuraciones imposibles que el tipo permite.
- Aplicar los tres cortes —por fase, por sección y por pantalla— con el criterio que decide cuál toca.
- Reconocer el error inverso: fragmentar en flujos independientes y perder la consistencia atómica.
Los síntomas antes del diagnóstico
Conviene empezar por lo observable, porque el estado gigante rara vez se percibe como un problema de modelado. Se percibe como fricción difusa repartida por sitios que parecen no tener relación entre sí, y esa dispersión es justamente lo que impide atribuirla a una causa única.
El primer síntoma es el reductor que arrastra. Para cambiar tres campos, cada reduce produce un objeto nuevo que copia los treinta y siete restantes; el coste de ejecución es despreciable, pero el coste de lectura no lo es, porque nada en la firma de la operación indica qué parte del mundo quedó tocada. El segundo es el conflicto de fusión perpetuo: la misma declaración concentra el trabajo de todas las ramas abiertas, de modo que cualquier par de tareas independientes colisiona en el mismo bloque de texto. El tercero es el test que construye el universo: comprobar una regla que depende de dos campos exige instanciar los cuarenta, y aunque los valores por defecto lo disimulen, el test deja de documentar qué importaba.
El cuarto síntoma es el más caro y el peor entendido, y es de rendimiento. En un modelo declarativo, la unidad de invalidación es el objeto que se lee. Si un componente lee el estado entero para extraer un campo, cualquier cambio en cualquiera de los otros treinta y nueve lo invalida, porque la igualdad del objeto compuesto cambió. El resultado es un árbol que se recompone entero cada vez que se pulsa una tecla en un buscador, y la causa no está en la interfaz sino en la granularidad del tipo que la alimenta.
// Sintoma tipico: un objeto que describe cuatro cosas distintas a la vez
data class PerfilState(
val cargando: Boolean = false,
val error: String? = null,
val nombre: String = "",
val avatarUrl: String? = null,
val seguidores: Int = 0,
val publicaciones: List<Post> = emptyList(),
val cargandoMas: Boolean = false,
val hayMasPaginas: Boolean = true,
val filtroActivo: Filtro = Filtro.Todas,
val textoBusqueda: String = "",
// ... y treinta mas
)
Merece la pena detenerse en por qué el cuarto síntoma se atribuye casi siempre a la herramienta equivocada. Quien lo sufre percibe una interfaz lenta, y lo primero que revisa es la interfaz: memoriza cálculos, envuelve componentes, anota tipos. Todas esas medidas actúan sobre el efecto y ninguna sobre la causa, porque la causa está a dos capas de distancia, en la declaración de un tipo que nadie asocia con el rendimiento. Es el ejemplo más limpio que ofrece esta arquitectura de una propiedad que se decide en el modelo y se paga en la vista.
El quinto síntoma es semántico y solo aparece si uno se detiene a contarlo. Un estado con cuarenta campos, aunque la mitad fueran booleanos, admite del orden de dos elevado a cuarenta configuraciones distintas. La pantalla real tiene quizá doce estados legítimos. La distancia entre ambas cifras no es una curiosidad matemática: es la superficie exacta donde viven los fallos que nadie reprodujo, porque cada configuración imposible que el tipo permite es un fallo que algún camino de ejecución acabará alcanzando.
La prueba definitiva del estado gigante es buscar pares de campos que no pueden ser verdad a la vez y comprobar que el tipo los admite. Un estado con cargando verdadero y una lista llena describe una pantalla que está cargando algo que ya tiene. Un estado con error no nulo y cargando verdadero describe un fallo en curso de resolverse. Un estado con hayMasPaginas falso y cargandoMas verdadero pide una página siguiente que ya se declaró inexistente. Ninguna de las tres combinaciones debería compilar, y las tres compilan. Cuando encuentres tres pares así, deja de discutir sobre estética: tienes una máquina de estados sin definir escondida dentro de una tupla plana.
Los tres cortes
Partir un estado no es repartir campos en cajas más pequeñas por criterio visual. Es identificar los ejes por los que el objeto varía de forma independiente y darle a cada eje su propio tipo. Hay tres ejes, y el orden en que se aplican importa porque cada uno elimina campos que habrían complicado el siguiente.
El corte por fase va primero y es el que más campos elimina. La mayoría de los estados gigantes contienen campos que solo tienen sentido durante una etapa del ciclo de la pantalla: cargando y error no coexisten con los datos, y sin embargo conviven en la misma declaración porque el tipo plano no sabe expresar exclusión. Una jerarquía sellada la expresa por construcción y con ella desaparecen las configuraciones contradictorias del apartado anterior.
sealed interface PerfilState {
data object Cargando : PerfilState
data class Fallo(val motivo: MotivoError) : PerfilState
data class Listo(
val cabecera: Cabecera,
val muro: Muro,
) : PerfilState
}
El corte por sección va después y actúa solo sobre la fase que tiene datos. El criterio no es temático sino de cohesión de escritura: dos campos pertenecen a la misma sección si casi siempre cambian en la misma operación y casi nunca por separado. Ese criterio es empírico y se puede medir sin discutir: recorre los reductores existentes y anota qué campos toca cada uno; los grupos que emergen de esa tabla son las secciones reales, no las que sugiere el diseño de la pantalla.
flowchart TB G[Estado plano con cuarenta campos] G --> F[Corte por fase con jerarquia sellada] F --> C[Cargando sin campos de contenido] F --> E[Fallo solo con el motivo] F --> D[Listo con los datos reales] D --> S1[Seccion cabecera] D --> S2[Seccion muro paginado] D --> S3[Seccion filtros y busqueda] style F fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b
Hay una objeción práctica al corte por sección que conviene atender porque se plantea siempre: la actualización anidada es más incómoda de escribir, porque exige encadenar dos copias en lugar de una. La objeción es cierta y su respuesta no es negarla sino acotarla. Una función de extensión por sección concentra el encadenamiento en un solo sitio y devuelve la comodidad al reductor, de modo que el coste queda pagado una vez por sección y no una vez por operación. Si el encadenamiento llega a tres niveles, en cambio, la incomodidad ya no es sintáctica sino una señal de que el subestado intermedio no era una sección sino una entidad con vida propia que probablemente merece su propio contenedor.
// El encadenamiento se paga una vez por seccion, no una vez por reductor
private inline fun PerfilState.Listo.conFiltros(
bloque: Filtros.() -> Filtros,
): PerfilState.Listo = copy(filtros = filtros.bloque())
El corte por pantalla es el tercero y el más fácil de pasar por alto, porque exige mirar fuera del fichero. Ocurre cuando una misma data class alimenta dos destinos de navegación que compartían origen histórico —el detalle y su versión ampliada, la lista y el panel lateral— y por eso acumula la unión de ambos conjuntos de campos. Aquí no hay refactor interno posible: los dos destinos son dos pantallas, y el estado compartido era un accidente de implementación que hay que deshacer duplicando el tipo y extrayendo a un modelo común solo lo que de verdad es común.
Corte por fase
Aplica cuando encuentres pares de campos mutuamente excluyentes. Sustituye la tupla plana por una jerarquía sellada y elimina de golpe todas las combinaciones imposibles entre etapas.
Corte por sección
Aplica cuando los reductores se agrupan de forma evidente en la tabla de campos tocados. Cada grupo se convierte en una data class anidada con su propio ciclo de actualización.
Corte por pantalla
Aplica cuando dos destinos distintos leen el mismo tipo y cada uno ignora la mitad de los campos. No es refactor de un objeto: es la separación de dos objetos que nunca debieron unirse.
El error inverso y el refactor sin ruptura
Quien ha sufrido un estado gigante tiende a corregirlo por exceso, y la corrección por exceso tiene un nombre concreto: publicar cada sección como un flujo independiente. La tentación es fuerte porque parece resolver el problema de invalidación de raíz, y en efecto lo resuelve. Lo que introduce a cambio es peor y más difícil de diagnosticar: la pérdida de atomicidad. Cuando la cabecera y el muro viajan por dos flujos distintos, la interfaz puede observar la cabecera nueva junto al muro viejo durante el intervalo que separa ambas emisiones, y aparece una clase de fallo visual que solo se manifiesta bajo carga y nunca se reproduce en el desarrollo.
La distinción clave es que partir el tipo no obliga a partir el flujo. El objetivo del refactor es un único StateFlow que transporta un objeto compuesto de subobjetos cohesivos, no varios flujos que transportan trozos. La consistencia atómica se conserva porque sigue habiendo una sola emisión por transición; la granularidad de invalidación se gana porque cada componente lee el subobjeto que le corresponde y solo se invalida cuando ese subobjeto cambia de identidad.
// Un solo flujo, varios subestados: atomicidad y granularidad a la vez
private fun cambiarBusqueda(texto: String) = intent {
reduce {
val s = state as? PerfilState.Listo ?: return@reduce state
s.copy(filtros = s.filtros.copy(texto = texto))
}
}
El refactor conviene hacerlo por partes y en un orden que mantenga el proyecto compilando en todo momento. Primero se introduce el subestado nuevo como una propiedad más, duplicando temporalmente los campos que va a absorber. Después se migran los lectores uno a uno, empezando por los de la interfaz, que son los que más se benefician. Solo cuando ningún lector usa los campos antiguos se eliminan de la declaración. El corte por fase, en cambio, no admite este gradualismo porque cambia la forma del tipo en la raíz, y es preferible hacerlo de una vez y en un cambio propio, sin mezclarlo con ninguna funcionalidad.
Cuando el equipo no se pone de acuerdo sobre si un estado es demasiado grande, sustituye la opinión por un recuento. Para cada campo, anota cuántos reductores lo escriben y cuántos componentes lo leen. Un campo escrito por un reductor y leído por un componente es un candidato limpio a vivir en un subestado con ese componente. Un campo escrito por nueve reductores y leído por todo el árbol es, casi siempre, una fase disfrazada de propiedad. La tabla completa cabe en una pantalla y suele terminar la discusión en diez minutos, porque enseña la estructura real del objeto en vez de la estructura que su autor creía tener.
Lo que no se arregla partiendo
Hay dos patologías que viajan casi siempre con el estado gigante y que sobreviven intactas a los tres cortes, porque no son problemas de descomposición sino de contenido. Conviene tratarlas en el mismo refactor, ya que ignorarlas hace que el estado vuelva a crecer con la misma velocidad que antes.
La primera es el estado derivado almacenado. Son los campos que no aportan información nueva porque son función de otros: un hayResultados que es la negación de que la lista esté vacía, un puedeContinuar que es la conjunción de tres validaciones, un totalCarrito que es la suma de las líneas. Cada uno de ellos parece una comodidad y en realidad es una obligación permanente de mantener sincronizados dos datos que dicen lo mismo. La primera vez que un reductor actualice la lista y olvide recalcular el derivado, la pantalla mostrará una contradicción interna que ninguna prueba del reductor detectará, porque el reductor hizo exactamente lo que decía hacer.
// Duplicado: dos fuentes para un mismo hecho, sincronizadas a mano
data class MalCarrito(val lineas: List<Linea>, val total: Long, val vacio: Boolean)
// Derivado: una sola fuente y propiedades calculadas que no pueden mentir
data class BienCarrito(val lineas: List<Linea>) {
val total: Long get() = lineas.sumOf { it.importe }
val vacio: Boolean get() = lineas.isEmpty()
}
La regla es guardar solo lo que no se puede reconstruir y exponer el resto como propiedad calculada. La excepción legítima aparece cuando el cálculo es caro y se repite en cada composición, y en ese caso la solución no es volver a almacenarlo en el estado sino memorizarlo en la capa que lo consume, donde su posible desincronización es imposible por construcción.
La segunda patología es la duplicación de entidades. Un estado que guarda la lista de publicaciones, la publicación seleccionada y las publicaciones destacadas contiene tres copias del mismo objeto, y en cuanto una operación actualice solo una de ellas, la pantalla mostrará dos versiones de la misma cosa a la vez. La corrección es normalizar: un único mapa de entidades por identificador y, en los demás sitios, listas de identificadores. El estado deja de ser un árbol con repeticiones y pasa a ser un grafo con una sola representación por entidad, que es exactamente la razón por la que las bases de datos relacionales se inventaron.
La normalización no es gratis: sustituye un acceso directo por una búsqueda en un mapa, obliga a manejar el caso del identificador que ya no existe y hace el estado menos legible de un vistazo. Compensa cuando la misma entidad aparece en tres o más sitios, cuando puede actualizarse desde operaciones distintas o cuando la pantalla es larga y las incoherencias serían visibles a la vez. No compensa en una pantalla de detalle que muestra una entidad y nada más, donde introduce indirección para resolver un problema que no existe. Como casi todo en esta lección, la decisión depende de contar antes de elegir: cuántas apariciones tiene la entidad y cuántos escritores la tocan.
Merece la pena resistir la lectura fácil de esta lección, que sería una regla numérica del tipo ningún estado debe pasar de diez campos. Esa regla es falsa y además peligrosa, porque un estado de veinte campos perfectamente cohesivos es sano y uno de seis que mezcla fase con contenido está roto. El número de campos es un síntoma correlacionado, no la enfermedad. La enfermedad es que el objeto ha dejado de tener un referente único: no describe una cosa del mundo sino la unión de varias cosas que casualmente se pintan juntas, y esa unión no es una entidad sino un accidente del diseño de la pantalla. En cuanto un tipo describe la unión de varias cosas, hereda automáticamente todas las patologías que hemos enumerado, y las hereda por razones estructurales y no por descuido. Hereda la explosión combinatoria porque el producto cartesiano de dos conjuntos independientes contiene todas las parejas, incluidas las que ningún mundo real produce. Hereda la invalidación total porque la igualdad de un producto es la conjunción de las igualdades de sus factores y basta con que uno cambie. Hereda el conflicto de fusión porque la localidad textual de la declaración deja de corresponderse con la localidad semántica del cambio. Y hereda la opacidad del reductor porque una operación que devuelve un producto entero no puede indicar en su tipo qué factor tocó. Las cuatro patologías son la misma propiedad matemática vista desde cuatro ángulos, y por eso los tres cortes funcionan: los tres consisten en identificar un eje de variación independiente y sacarlo del producto, ya sea convirtiéndolo en una suma cuando los valores se excluyen —eso es exactamente lo que hace la jerarquía sellada— o en un factor con nombre propio cuando coexisten pero varían por separado. Visto así, modelar el estado deja de ser una cuestión de gusto y se convierte en lo que siempre fue: la tarea de encontrar la descomposición del espacio de configuraciones que hace inexpresables las que no existen. El estado gigante es simplemente la ausencia de esa tarea, acumulada durante once meses.
- Toma la
data classde estado más grande de un proyecto tuyo y construye la tabla de campos por reductor y por lector. Señala los grupos que emergen sin que tú los impongas. - Encuentra tres pares de campos mutuamente excluyentes que el tipo admita y escribe, para cada par, la secuencia de eventos que produciría esa combinación imposible.
- Aplica el corte por fase con una jerarquía sellada y cuenta cuántos campos desaparecen sin que se pierda información.
- Convierte una sección en subestado sin partir el flujo, y explica qué garantía habrías perdido si hubieras publicado un flujo aparte.
- Justifica con la tabla del punto uno un campo que decidas dejar en la raíz, y argumenta por qué su alta conectividad lo convierte en estructura y no en contenido.