Qué no es estado: navegar, avisar, vibrar
Una pantalla produce dos clases de salidas y solo una de ellas es estado. Esta lección construye el criterio que las separa —la prueba de la reconstrucción— y lo aplica a los cuatro sospechosos canónicos: navegar, mostrar un snackbar, vibrar y abrir el teclado. Se analiza por qué la idempotencia, y no la duración, es la propiedad que decide; por qué el estado es un predicado sobre el mundo y el efecto un acto sobre él; y qué familia de bugs aparece cuando se confunden ambas categorías, bugs que permanecen invisibles hasta que la pantalla gira, el proceso muere o el usuario vuelve atrás.
Hay una pregunta que todo programador de interfaces contesta cientos de veces al día, casi siempre sin darse cuenta de que la está contestando: ¿dónde pongo esto? El nombre del usuario va al estado sin discusión. La lista de mensajes, también. Pero cuando el login tiene éxito y hay que navegar, cuando la red falla y hay que avisar, cuando el usuario borra algo y el teléfono debe vibrar, la mano vuelve a ir al mismo sitio —al estado— porque es el único sitio que la arquitectura ofrece. Y ahí empieza el problema. Estas salidas no son cómo están las cosas: son cosas que pasan. Ocurren una vez, se consumen en el instante en que se ejecutan y su repetición no es un refresco benigno sino un fallo visible. Esta lección construye el criterio para reconocerlas antes de escribirlas, porque en cuanto una de ellas entra en el estado ya es demasiado tarde: el bug queda cableado y solo se manifestará cuando el usuario gire el teléfono.
- Formular la prueba de la reconstrucción y aplicarla para clasificar cualquier salida de una pantalla.
- Reconocer los cuatro efectos canónicos —navegar, avisar, vibrar, abrir el teclado— y qué comparten.
- Explicar por qué la idempotencia, y no la duración, es el criterio que separa estado de efecto.
- Anticipar la clase de bugs que aparece al modelar un acto como si fuera un hecho retenido.
La prueba de la reconstrucción
El criterio cabe en una pregunta y es sorprendentemente afilado: si esta pantalla se destruyera y se reconstruyera ahora mismo, desde el último estado conocido, ¿esto debería volver a ocurrir? Si la respuesta es sí, es estado: forma parte de la descripción del mundo y toda descripción debe poder repintarse las veces que haga falta. Si la respuesta es no, no es estado bajo ningún disfraz: es un efecto de una sola vez.
Aplícala con honestidad y verás que separa limpio. El indicador de carga debe volver a pintarse, porque sigue siendo verdad que estamos cargando. El texto del campo debe volver, porque el usuario lo escribió y sigue ahí. Pero “navega a la pantalla de inicio” ya se cumplió; volver a cumplirlo sería un salto de navegación fantasma. “Muestra el error de red” ya se mostró; volver a mostrarlo es un snackbar que aparece sin causa. La reconstrucción no es hipotética: en Android ocurre con cada rotación, con cada cambio de tema, con cada vuelta desde el segundo plano tras la muerte del proceso.
La formulación equivalente y más precisa es en términos de reemisión. El estado vive en un flujo conflado que, por contrato, entrega su último valor a cada nuevo suscriptor. Esa reemisión es exactamente la propiedad que hace correcto al estado y catastrófico al efecto guardado dentro de él.
Conviene practicar la prueba con casos incómodos, porque los fáciles no enseñan nada. El texto de un campo de búsqueda es estado, sin duda. El resultado de esa búsqueda, también. Pero “haz scroll hasta el primer resultado” no lo es: si al reconstruir la pantalla volviera a ejecutarse, el usuario perdería la posición a la que había llegado navegando. La distinción no está en el dato sino en la orden que lo acompaña, y por eso hay que hacerse la pregunta sobre cada salida y no sobre cada pantalla.
flowchart TD
S[Salida de la pantalla] --> P{Al reconstruir debe volver a ocurrir}
P -->|Si| E[Es estado y vive en el flujo conflado]
P -->|No| F[Es un efecto de una sola vez]
E --> R[Reemitir es correcto]
F --> U[Reemitir es un bug]
style E fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b
style U fill:#f38ba8,color:#11111bLos cuatro sospechosos
Los efectos de una sola vez no son infinitos ni exóticos: en una app móvil se repiten cuatro familias, y reconocerlas de memoria ahorra la mitad de las discusiones de diseño.
Navegar
Cambiar de destino, abrir un diálogo, cerrar la pantalla. Es una transición del grafo de navegación, no un campo de la pantalla actual. Repetirla teletransporta al usuario.
Avisar
Un snackbar, un toast, un diálogo de error. Es un mensaje entregado a un humano en un instante concreto. Repetirlo produce el clásico aviso zombi.
Vibrar
Háptica, sonido, flash. Es una orden al hardware que se ejecuta y termina. No hay ningún sentido en el que la pantalla siga vibrando como parte de su descripción.
Abrir el teclado
Pedir el foco, mostrar el teclado, hacer scroll a un elemento, copiar al portapapeles. Actos imperativos sobre la plataforma, no propiedades del modelo.
Hay una quinta familia que suele olvidarse y que causa daños silenciosos: las salidas hacia sistemas externos que no producen ningún píxel. Registrar un evento de analítica, escribir una entrada en un diario de auditoría, encolar una notificación o invalidar una caché remota son efectos de pleno derecho, y guardarlos en el estado produce métricas infladas que nadie relaciona con una rotación de pantalla. Que el usuario no vea el fallo no significa que no exista; significa que se descubrirá mucho más tarde, en una gráfica que no cuadra.
Lo que las cuatro comparten no es la brevedad. Un diálogo puede quedarse abierto minutos y una animación durar un segundo, y sin embargo el diálogo puede ser perfectamente estado —si su apertura es una propiedad reconstruible— mientras que la animación de éxito es efecto. Lo que comparten es otra cosa: son transiciones, no situaciones. Describen el paso de un mundo a otro, y un paso ya dado no se puede volver a dar; solo se puede dar otro paso igual, que es un evento distinto.
Mostrar un diálogo parece efecto —lo disparas— pero suele ser estado —permanece, sobrevive a la rotación y el usuario espera encontrarlo donde lo dejó—. La prueba de la reconstrucción lo resuelve sin ambigüedad: si al recrear la pantalla el diálogo debe seguir abierto, era estado y su apertura es un campo del modelo. Si en cambio era un aviso momentáneo que ya se leyó, era efecto. La misma pieza visual cae de un lado u otro según su semántica, no según su apariencia, y ese es justo el motivo por el que hay que decidirlo pensando y no por costumbre.
Idempotencia: el criterio real
Bajo la prueba de la reconstrucción hay una propiedad matemática que la explica: la idempotencia. Aplicar el estado a la interfaz es una operación idempotente —pintarlo una vez o cinco veces seguidas produce el mismo resultado observable—, y de esa idempotencia depende todo el modelo declarativo. Compose recompone cuando quiere, cuantas veces quiere; un sistema declarativo solo es sano si repintar es gratis.
Merece la pena enunciar la propiedad con cuidado, porque de ella depende todo lo demás: una función de pintado es idempotente cuando aplicarla dos veces con la misma entrada deja el sistema exactamente como lo dejaba una sola vez. Es la condición que permite a Compose recomponer sin coordinación, a una vista recrearse tras la rotación y a un test comparar estados enteros. Un modelo declarativo sin idempotencia no es un modelo declarativo lento: es un modelo declarativo roto.
Los efectos son precisamente las operaciones no idempotentes del sistema. Navegar dos veces apila dos destinos. Vibrar dos veces vibra dos veces. Mostrar dos snackbars muestra dos snackbars. Meter una operación no idempotente en un canal cuya semántica es la reemisión libre es una contradicción de tipos que el compilador no puede ver pero el usuario sí.
data class PagoState(
val total: Dinero = Dinero.CERO,
val procesando: Boolean = false,
val tarjetaInvalida: Boolean = false,
)
sealed interface PagoEfecto {
data class IrAlRecibo(val id: String) : PagoEfecto
data class Avisar(val mensaje: String) : PagoEfecto
data object VibrarError : PagoEfecto
}
Léelo como una declaración de idempotencia. Todo lo que está en PagoState se puede pintar mil veces sin consecuencia: el total, el spinner, el borde rojo del campo de tarjeta. Todo lo que está en PagoEfecto cambia el mundo al ejecutarse y por tanto tiene que ejecutarse exactamente una vez. Que sean dos tipos distintos, y no dos campos del mismo tipo, es lo que impide mezclarlos por descuido.
Hay un corolario que conviene enunciar porque contradice una intuición muy extendida: la duración no decide nada. Un aviso que dura tres segundos es efecto; una animación de carga que dura tres segundos es estado, porque mientras dure sigue siendo verdad que estamos cargando y repintarla es correcto. Tampoco decide la visibilidad: hay efectos que el usuario no ve —copiar al portapapeles, registrar un evento de analítica, cancelar una notificación del sistema— y siguen siendo efectos porque siguen sin ser idempotentes. El único criterio que sostiene el peso es el de la repetición inocua.
Dónde vive cada cosa en Orbit
La clasificación deja de ser un ejercicio mental en cuanto se escribe la firma del contenedor, porque Orbit exige declarar ambos tipos por separado antes de escribir una sola línea de lógica.
class PagoViewModel(
private val repo: PagoRepo,
) : ContainerHost<PagoState, PagoEfecto>, ViewModel() {
override val container = container<PagoState, PagoEfecto>(PagoState())
fun cobrar() = intent {
reduce { state.copy(procesando = true) }
when (val r = repo.cobrar(state.total)) {
is Ok -> {
reduce { state.copy(procesando = false) }
postSideEffect(PagoEfecto.IrAlRecibo(r.id))
}
is Fallo -> {
reduce { state.copy(procesando = false, tarjetaInvalida = true) }
postSideEffect(PagoEfecto.VibrarError)
}
}
}
}
Observa la coreografía del caso de fallo, porque contiene la lección entera en cuatro líneas. El borde rojo del campo de tarjeta va por reduce: es una descripción que debe sobrevivir a la rotación, porque el campo sigue siendo inválido después de girar el teléfono. La vibración va por postSideEffect: es un acto que ya se ejecutó, y volver a ejecutarlo tras la rotación sería un teléfono que vibra sin motivo. El mismo suceso —el cobro rechazado— produce a la vez un cambio de descripción y una orden puntual, y la arquitectura obliga a repartirlos por dos puertas distintas.
Hay un tercer detalle en ese fragmento que suele pasar desapercibido y que conviene señalar: el efecto se emite después de la reducción, no antes. El orden importa porque la interfaz puede consumir el efecto en cualquier momento posterior, y si lo consume antes de que el estado se haya actualizado se encontrará con una fotografía vieja. Emitir siempre después de haber dejado el estado en su forma final elimina toda una familia de incoherencias momentáneas entre lo que se ve y lo que se acaba de ejecutar.
Ese reparto simultáneo es lo más frecuente en la práctica y también lo que más cuesta interiorizar. La pregunta correcta no es “¿este suceso es estado o efecto?” sino “¿qué parte de este suceso pertenece a la descripción y qué parte es una orden?”. Casi todo suceso interesante tiene las dos mitades, y separarlas al escribirlo es más barato que descubrirlas después en un informe de fallo.
Escribir ContainerHost con sus dos parámetros de tipo obliga a nombrar, antes de programar nada, el conjunto de lo retenido y el conjunto de lo efímero. Es una decisión de diseño disfrazada de trámite sintáctico, y su valor está en el momento en que ocurre: te fuerza a clasificar en la fase de diseño, cuando todavía es gratis, en vez de en la fase de depuración, cuando ya cuesta un rediseño. Si al escribir esa firma no sabes qué efectos tiene tu pantalla, es señal de que aún no has entendido lo que la pantalla hace.
Detrás de esta clasificación hay una distinción filosófica más vieja que el software y mucho más útil de lo que parece: la que separa una proposición de un acto. El estado es una proposición: afirma algo sobre el mundo —el usuario se llama Ana, hay tres mensajes sin leer, la petición está en vuelo— y como toda proposición su valor de verdad se puede consultar tantas veces como se quiera sin alterar nada; leerla no cambia el mundo, y por eso puede reemitirse a todo el que se suscriba. El efecto es un acto: no afirma, hace —navega, avisa, vibra— y su rasgo definitorio es que ejecutarlo modifica irreversiblemente el mundo en el que se ejecuta, de modo que ejecutarlo dos veces no es redundancia sino duplicación. Cuando escribes error = "sin conexion" en el estado, estás afirmando la proposición hay un error, que es perfectamente legítima y perfectamente reemitible; pero lo que en realidad querías era el acto avisa del error, que no es reemitible en absoluto. La arquitectura no puede distinguir una de otra si ambas viven en el mismo campo, y por eso la industria pasó una década inventando envoltorios, banderas y clases de un solo uso: todos eran intentos de simular semántica de acto dentro de una estructura de proposiciones. La solución no era mejor fontanería sino aceptar que son dos categorías ontológicas distintas y darles dos canales distintos. Una pantalla no produce una salida: produce una descripción que se contempla y una lista de órdenes que se cumplen. Separarlas al escribirlas es la diferencia entre una interfaz que se puede repintar sin miedo y una que resucita fantasmas cada vez que el usuario gira el teléfono.
- Elige una pantalla real de tu app y lista todas sus salidas sin clasificarlas todavía: cada dato, cada aviso, cada transición.
- Aplica la prueba de la reconstrucción a cada una y márcala como estado o como efecto. Anota las tres que más te costó decidir.
- Para cada salida marcada como efecto, comprueba la propiedad de idempotencia: describe qué se ve exactamente si se ejecuta dos veces seguidas.
- Busca en tu pantalla algún diálogo o panel y decide con el criterio, no con la costumbre, si es estado o efecto. Justifica la elección en una frase.
- Escribe la
data classde estado y lasealed interfacede efectos resultantes, y comprueba que ningún campo del estado sirva solo para disparar algo.