wandres.dev
INTENTS Y REDUCERS · la transición

Validación y estados de error: el fallo como dato del estado

Una excepción que se escapa del reducer es un estado que la pantalla no sabe representar. Esta lección propone tratar el error como un campo más del estado, modelado con tipos que hagan imposible la contradicción: validación sincrónica que produce mensajes por campo, resultados de operaciones envueltos en un tipo suma en vez de lanzados, y la distinción entre el error que bloquea la pantalla, el que solo afecta a un campo y el que es transitorio y debe caducar. Muestra por qué try y catch pertenecen al borde impuro y nunca al reducer, y cómo los estados imposibles se eliminan por construcción.

⏱ 18 min

En una arquitectura imperativa el error es una fuga: algo sale mal, se lanza una excepción, alguien la atrapa donde puede y muestra un mensaje. La pantalla, mientras tanto, no sabe nada; su estado sigue diciendo que se está cargando, o que todo va bien, mientras un diálogo aparece por encima contando otra historia. En MVI esa fuga no existe porque no está permitida: si algo puede salir mal, la posibilidad de que salga mal forma parte del estado, y por tanto tiene un nombre, un tipo y una representación en la interfaz. El fallo deja de ser una interrupción del flujo para convertirse en una de sus salidas legítimas. Esta lección trata de cómo se modela esa salida sin acabar con un estado lleno de banderas que se contradicen entre sí, y de por qué la validación —que es solo el error visto antes de que ocurra— vive en el mismo sitio.

🎯 Al terminar esta lección sabrás
  • Representar el fallo como campo del estado en lugar de como excepción que escapa del reducer.
  • Modelar la validación por campo y distinguirla del error global que bloquea la pantalla.
  • Envolver el resultado de operaciones impuras en un tipo suma para que el reducer reciba datos, no excepciones.
  • Eliminar estados imposibles por construcción en vez de defenderlos con condicionales dispersos.

El fallo tiene sitio reservado en el estado

Empieza por la forma del estado. La tentación es añadir un String nullable llamado error y seguir adelante; el problema aparece a la tercera regla, cuando ya conviven un error de carga, uno de cupón y uno de red, y nadie sabe cuál gana. Conviene separar por alcance desde el principio.

data class CarritoState(
    val lineas: List<Linea> = emptyList(),
    val cupon: String = "",
    val errorCupon: String? = null,       // afecta a un campo
    val fase: Fase = Fase.Inactiva,       // afecta a la pantalla entera
) {
    sealed interface Fase {
        data object Inactiva : Fase
        data object Cargando : Fase
        data class Fallida(val motivo: Motivo) : Fase
    }
}

La Fase es un tipo suma y no tres booleanos, y esa elección hace el trabajo pesado: no existe forma de estar cargando y fallido al mismo tiempo porque el tipo no lo admite. Los estados imposibles no se comprueban con condicionales defensivos ni se documentan en un comentario: se eliminan haciendo que no se puedan escribir. El error de cupón, en cambio, sí convive con cualquier fase, porque su alcance es otro; que tenga su propio campo refleja esa diferencia real.

🧩

Error de campo

Pertenece a un dato concreto y se muestra junto a él. No bloquea la pantalla ni impide otras interacciones.

🚧

Error de pantalla

Sustituye al contenido. Vive dentro de la fase, junto a cargando y a inactivo, para que solo uno pueda ser cierto.

⏱️

Error transitorio

Es un aviso que caduca. Se limpia con un intent posterior o con un identificador consumido por la vista.

🔁

Error recuperable

Lleva consigo la acción que lo repara, para que la interfaz pueda ofrecer reintentar sin adivinar qué reintenta.

Validar es reducir

La validación no merece una capa aparte: es una función pura del estado al conjunto de mensajes que ese estado incumple, y por tanto encaja de forma natural dentro del reducer. Cada vez que un intent cambia un campo, la validación se recalcula y viaja en el estado siguiente.

private fun validarCupon(texto: String): String? = when {
    texto.isBlank()            -> null                       // aun no hay nada que decir
    texto.length < 4           -> "El cupon es demasiado corto"
    !texto.all(Char::isLetterOrDigit) -> "Solo letras y numeros"
    else                       -> null
}

CarritoIntent.CuponEscrito ->
    estado.copy(cupon = intent.texto, errorCupon = validarCupon(intent.texto))

Que la validación sea pura tiene una consecuencia práctica inmediata: el botón de enviar no necesita lógica propia. Su habilitación es una propiedad derivada del estado —no hay errores y los campos obligatorios están completos— y por tanto se calcula, no se guarda ni se decide en la vista. Escribirla como una propiedad val del data class la deja disponible para la interfaz y para los tests con la misma expresión, y evita la duplicación clásica en la que el botón se habilita con una condición y el envío se valida con otra ligeramente distinta.

Repara en el primer caso: un campo vacío que el usuario todavía no ha tocado no está mal, está pendiente. Confundir vacío con inválido produce esa experiencia hostil de un formulario que grita en rojo antes de que hayas escrito la primera letra. Si la distinción importa, modélala en el estado con un campo que registre si el campo fue visitado, y deja que el reducer decida cuándo el mensaje es visible. La regla general es la misma de siempre: cuando dudes entre calcularlo en la vista o guardarlo en el estado, guárdalo, porque solo lo que está en el estado es comprobable en un test.

💡
Validar dos veces, y que la segunda mande

La validación del reducer sirve para guiar al usuario mientras escribe, pero no es la autoridad: el servidor puede rechazar lo que tu regla aceptó, porque conoce cosas que tú no —que el cupón ya se usó, que el correo está registrado—. Modela ambas validaciones como fuentes distintas del mismo campo de error y deja que la respuesta remota sobrescriba a la local. Lo que nunca debe ocurrir es que la validación local bloquee el envío por una regla que el servidor no tiene: eso convierte un formulario en una puerta cerrada por un motivo inexistente.

Las excepciones se atrapan en el borde

El reducer nunca ve una excepción, y para que eso sea cierto alguien tiene que atraparla antes. Ese alguien es el borde impuro: la función que llama al repositorio. Su obligación es convertir el fallo en dato y volver a entrar al ciclo como un intent más.

fun aplicarCupon() = intent {
    reduce { state.copy(fase = Fase.Cargando) }
    val resultado = runCatching { repo.canjear(state.cupon) }
    val siguiente = resultado.fold(
        onSuccess = { Resultado.Ok(it) },
        onFailure = { Resultado.Error(mapearMotivo(it)) },
    )
    reduce { reducir(state, CarritoIntent.CuponRespondido(siguiente)) }
}
flowchart TD
A[Intent del usuario] --> B[Borde impuro llama al repositorio]
B --> C{Exito o fallo}
C -->|exito| D[Intent Respondido con datos]
C -->|fallo| E[Intent Respondido con motivo]
D --> F[Reducer puro]
E --> F
F --> G[Estado con fase y errores explicitos]
style F fill:#cba6f7,color:#11111b
style G fill:#a6e3a1,color:#11111b

El detalle que suele olvidarse es el mapeo del motivo. Una IOException no es un mensaje para el usuario; es una excepción de infraestructura que debe traducirse a un vocabulario de dominio —sin conexión, cupón caducado, servidor no disponible— antes de cruzar hacia el estado. Ese vocabulario, también un tipo suma, es lo que permite que la vista decida la presentación sin inspeccionar cadenas de texto y que los tests afirmen sobre casos y no sobre literales que cambian con la traducción.

sealed interface Motivo {
    data object SinConexion : Motivo
    data object CuponCaducado : Motivo
    data class Servidor(val codigo: Int) : Motivo
    data object Desconocido : Motivo
}

Que Motivo sea cerrado tiene el mismo efecto que tener cerrado el vocabulario de intents, aplicado al otro extremo del ciclo: el conjunto de las cosas que pueden ir mal queda escrito en un solo sitio y el compilador obliga a atenderlas todas cuando la vista decide qué pintar. El caso Desconocido no es pereza sino honestidad: existen fallos que no has previsto, y darles un miembro explícito es preferible a que se cuelen disfrazados de otro.

El error que debe caducar

No todos los fallos deben persistir. Un mensaje emergente que avisa de que el guardado falló es un aviso de una sola vez: si sobrevive al giro de pantalla y vuelve a aparecer, el usuario lo lee dos veces por el mismo hecho. Hay dos formas de tratarlo y conviene elegir a conciencia.

La primera es sacarlo del estado y mandarlo por un canal de efectos de un solo consumo, que es lo que Orbit ofrece con su publicación de side effects. La segunda es dejarlo en el estado pero acompañado de una identidad, de modo que la vista pueda emitir un intent que lo declare consumido.

data class Aviso(val motivo: Motivo, val id: Long)

// En el reducer
CarritoIntent.AvisoVisto ->
    if (estado.aviso?.id == intent.id) estado.copy(aviso = null) else estado

La segunda opción es más verbosa y tiene una virtud que a veces decide: el aviso sigue siendo estado, y por tanto sigue siendo comprobable en la tabla del reducer y superviviente a la recreación del proceso si se guarda. La primera es más ligera y correcta cuando el aviso no debe sobrevivir a nada. La regla práctica: si el usuario se enfadaría al no volver a verlo tras un giro de pantalla, es estado; si se enfadaría al verlo dos veces, es efecto.

Un error que no está en el estado es un estado que la pantalla no puede representar

La razón última para meter el fallo dentro del estado no es la comodidad de los tests, aunque la haya, sino una cuestión de completitud representacional. Una interfaz es una función del estado a los píxeles; si una situación del mundo no está codificada en el estado, entonces literalmente no hay ninguna imagen que le corresponda, y lo que se hace en la práctica es abrir un canal paralelo —un diálogo, un mensaje emergente, una excepción capturada en un lugar arbitrario— por el que la verdad circula por fuera del sistema. Ese canal paralelo es el origen de casi todos los bugs de interfaz que resisten: la ruleta que sigue girando bajo el mensaje de error, el mensaje que sobrevive a la rotación porque estaba en el diálogo y no en el estado, el reintento que no reintenta nada porque nadie guardó qué había fallado. Cuando el fallo es un dato, todos esos síntomas desaparecen no porque se hayan corregido, sino porque dejan de ser expresables. Hay una segunda mitad del argumento, más exigente y más rentable: modelar el error obliga a decidir su alcance, y decidir el alcance obliga a entender el dominio. ¿Este fallo invalida la pantalla o solo un campo? ¿Sobrevive a un cambio de configuración o debe morir con él? ¿Es recuperable, y si lo es, con qué acción exactamente? Son preguntas de producto que la excepción permitía aplazar indefinidamente y que el tipo suma pone sobre la mesa el primer día. Escribir sealed interface Motivo y verse forzado a enumerar sus casos es un ejercicio de diseño, no de sintaxis; el resultado es que el conjunto de las cosas que pueden ir mal queda tan cerrado, tan enumerable y tan verificable como el conjunto de las que el usuario puede hacer. Y una aplicación en la que ambos conjuntos son finitos y están escritos es, en un sentido preciso, una aplicación que se puede comprender entera.

⚔️ Convierte tus excepciones en estado
  1. Recorre una pantalla tuya y localiza cada punto donde una excepción sale del flujo; escribe qué estado debería representarla y por qué hoy no existe.
  2. Sustituye los booleanos de cargando y error por un tipo suma de fase, y comprueba cuántos condicionales defensivos puedes borrar después.
  3. Separa los errores por alcance: campo, pantalla y transitorio, y justifica la clasificación de cada uno con un caso de uso concreto.
  4. Escribe la función de validación como función pura y añade la distinción entre campo vacío no visitado y campo inválido.
  5. Define un sealed interface de motivos de dominio y el mapeo desde las excepciones de infraestructura; escribe un test que afirme sobre el motivo, nunca sobre el texto mostrado.