Eventos: toda interacción produce un mensaje
En la mayoría de las tecnologías de interfaz, un manejador de evento es una función que se ejecuta cuando el usuario actúa, y dentro de esa función cabe cualquier cosa: mutar estado, lanzar una petición, escribir en el almacenamiento local, llamar a tres servicios y navegar a otra pantalla. Elm invierte por completo esa relación. Un manejador no ejecuta lógica, declara qué mensaje se emite, y ese mensaje viaja al runtime para volver a entrar por update, que es el único lugar donde se decide qué significa. Esta lección recorre esa inversión desde las funciones básicas de elm/html hasta sus consecuencias arquitectónicas. Estudia las firmas de onClick, onInput y onSubmit y por qué difieren exactamente en lo que difieren; reconstruye el ciclo completo del campo controlado, en el que la vista nunca es la dueña del texto que muestra; y llega hasta el mecanismo general, donde todo evento se define decodificando el objeto que el navegador entrega, de modo que la frontera con el mundo externo queda sellada por el mismo sistema de tipos que protege el resto del programa.
Pulsar un botón parece la operación más simple de una interfaz, y sin embargo es donde se juega casi toda la arquitectura. En el modelo habitual, el manejador que se asocia a esa pulsación es una función capaz de hacer cualquier cosa: modificar variables que viven fuera de su alcance, disparar peticiones, tocar el almacenamiento, provocar navegaciones y llamar a otros manejadores. El resultado es que el comportamiento de una aplicación queda esparcido por sus hojas, y que responder a la pregunta qué pasa cuando el usuario pulsa aquí exige leer el árbol entero, porque el efecto puede estar en cualquier nodo. Elm hace lo contrario, y lo hace con una restricción que parece pobre hasta que se ve funcionar: el manejador no puede ejecutar nada. Lo único que puede hacer es nombrar el mensaje que se emitirá. Ese mensaje sale de la vista, entra en el runtime y vuelve a la aplicación por la puerta de update, que es el sitio único y central donde se decide qué significa cada cosa que el usuario hace. La vista, entonces, no reacciona a los eventos: los traduce a vocabulario.
- Leer las firmas de
onClick,onInputyonSubmity explicar por qué unas reciben un mensaje y otra recibe una función. - Reconstruir el ciclo completo de un campo controlado y localizar en él quién es el dueño del texto.
- Definir eventos personalizados con
Html.Events.ony un decodificador que extrae lo que interesa del objeto del navegador. - Controlar la propagación y el comportamiento por defecto con las variantes que devuelven decisiones junto al mensaje.
El manejador declara vocabulario, no comportamiento
Las tres funciones de uso diario del módulo de eventos dicen todo lo que hay que saber sobre el diseño. La que atiende la pulsación recibe directamente un mensaje, porque el hecho de que alguien pulse no aporta ningún dato adicional: el evento es la información completa. La que atiende la escritura recibe en cambio una función que va de una cadena a un mensaje, porque el evento sí trae un dato, el texto actual del campo, y hay que decir cómo se empaqueta ese dato dentro del vocabulario de la aplicación. La que atiende el envío de un formulario vuelve a recibir un mensaje directo y además cancela el comportamiento por defecto del navegador, que consistiría en recargar la página.
onClick : msg -> Attribute msg
onInput : (String -> msg) -> Attribute msg
onSubmit : msg -> Attribute msg
type Msg
= Incrementado
| TextoEscrito String
| FormularioEnviado
view : Model -> Html Msg
view model =
Html.form [ onSubmit FormularioEnviado ]
[ input [ value model.texto, onInput TextoEscrito ] []
, button [ onClick Incrementado ] [ text "Sumar" ]
]
Merece la pena detenerse en lo que ocurre al escribir un constructor sin aplicarlo del todo. En el ejemplo, TextoEscrito no es un mensaje sino una función que espera una cadena y devuelve un mensaje, y por eso encaja exactamente en la firma que pide onInput. No hay ninguna magia ni ninguna conversión implícita: los constructores de un tipo unión con datos son funciones ordinarias, y aquí se aprovechan como tales. Esa coincidencia entre lo que un evento aporta y lo que un constructor necesita es la razón de que el código de eventos en Elm sea tan corto sin dejar de estar tipado en cada punto.
Emitir, no ejecutar
Un manejador solo nombra el mensaje. No puede mutar, ni pedir datos, ni navegar: todo eso vive en update y se expresa como un Cmd.
Constructor parcial
TextoEscrito sin argumento es una función de cadena a mensaje. Encaja en onInput porque los constructores son funciones normales.
Un solo destino
Todo evento del árbol desemboca en la misma función. Para saber qué hace la aplicación se lee update, no la vista.
Trazabilidad
Como cada interacción es un valor, la secuencia de mensajes se registra, se inspecciona y se reproduce como cualquier otra lista de datos.
El campo controlado y el ciclo cerrado
El caso del campo de texto es el que mejor enseña la forma del bucle, porque es donde la intuición heredada falla más. En el modelo tradicional, el campo es el dueño de su contenido: el usuario escribe, el elemento guarda lo escrito y el programa lo consulta cuando lo necesita. En Elm el campo no es dueño de nada. El atributo que fija su contenido lee del modelo, así que lo que se ve en pantalla siempre es lo que el modelo dice. Cuando el usuario teclea, no se modifica el campo: se emite un mensaje con el texto propuesto, update decide qué hacer con él, y el nuevo modelo produce un nuevo árbol donde el campo muestra el valor resultante. El recorrido es completo y pasa por el centro, siempre.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
TextoEscrito propuesto ->
( { model | texto = String.toUpper propuesto }, Cmd.none )
FormularioEnviado ->
( { model | enviando = True }, enviar model.texto )
Incrementado ->
( { model | cuenta = model.cuenta + 1 }, Cmd.none )
Ese detalle aparentemente anecdótico del texto convertido a mayúsculas contiene toda la tesis. La transformación no ocurre en la vista ni en el elemento del documento, ocurre en el único punto donde se toma decisiones, y por eso vale igual para el texto que llega tecleado, para el que llega pegado desde el portapapeles y para el que llega de una respuesta del servidor. Validar, normalizar, truncar o rechazar una entrada dejan de ser trucos aplicados en el borde y se convierten en reglas del dominio escritas una sola vez. La regla práctica que se sigue de aquí es simple: si algo debe ocurrir cuando cambia un dato, no hay ningún lugar donde escribirlo salvo update.
flowchart LR U[Usuario teclea] --> E[onInput] E --> M[Msg con el texto] M --> R[Runtime] R --> A[update decide] A --> N[Modelo nuevo] N --> V[view] V --> P[Campo con el valor final] style M fill:#89b4fa,color:#11111b style A fill:#a6e3a1,color:#11111b style P fill:#cba6f7,color:#11111b
Es tentador leer el manejador como si fuese una llamada diferida y esperar que el efecto ocurra a continuación, en el mismo instante. No es así, y confundirlo produce código torcido. El mensaje se encola en el runtime, se procesa cuando le toca y el resultado se manifiesta en el siguiente dibujado. Por eso no tiene sentido intentar leer en la vista un valor que se acaba de emitir, ni encadenar dos mensajes esperando que el segundo vea el efecto del primero desde la vista: si dos cosas deben ocurrir en orden, ese orden se expresa en update, devolviendo el modelo ya actualizado y, si hace falta, un comando que producirá el siguiente mensaje.
Eventos personalizados: la frontera pasa por un decodificador
Las funciones cómodas del módulo cubren lo frecuente, pero no son primitivas: todas se construyen sobre un mecanismo general en el que un evento se declara con su nombre y con un decodificador que extrae del objeto entregado por el navegador exactamente el dato que interesa. Esto importa porque ese objeto es un valor del mundo exterior, sin tipo y sin garantías, y el sistema de Elm no lo deja entrar por la puerta de atrás: entra por la misma aduana que cualquier respuesta de red. Si el campo que se busca no existe o no tiene la forma declarada, el decodificador falla y sencillamente no se emite ningún mensaje.
-- El mecanismo general: nombre del evento mas decodificador
on : String -> Decoder msg -> Attribute msg
-- Un evento propio: pulsar Enter dentro de un campo
onEnter : msg -> Attribute msg
onEnter mensaje =
on "keydown"
(Decode.field "key" Decode.string
|> Decode.andThen
(\tecla ->
if tecla == "Enter" then
Decode.succeed mensaje
else
Decode.fail "otra tecla"
)
)
El detalle elegante está en el fallo del decodificador. En este diseño, fallar no es un error que haya que tratar: es la manera de decir que ese evento concreto no interesa. Pulsar cualquier tecla distinta produce un decodificador fallido, no se emite mensaje, update no se ejecuta y la vista no se recalcula. El filtrado del evento se expresa así con la misma herramienta que la extracción del dato, sin condicionales imperativos ni cancelaciones. Cuando además hace falta gobernar la propagación o el comportamiento por defecto, existen variantes que devuelven, junto al mensaje, las decisiones correspondientes; y la variante más general devuelve un registro con el mensaje y ambas banderas, de modo que también esas decisiones se calculan a partir del dato decodificado en vez de fijarse a mano.
-- Variantes que deciden junto al mensaje
stopPropagationOn : String -> Decoder ( msg, Bool ) -> Attribute msg
preventDefaultOn : String -> Decoder ( msg, Bool ) -> Attribute msg
-- Leer el valor del campo sin usar onInput
onCambio : (String -> msg) -> Attribute msg
onCambio aMensaje =
on "change" (Decode.map aMensaje (Decode.at [ "target", "value" ] Decode.string))
La restricción que impide a un manejador ejecutar lógica parece una limitación arbitraria y es, en realidad, la decisión de la que cuelga todo lo demás. Cuando un manejador puede hacer cosas, el comportamiento de la aplicación no está escrito en ningún sitio: está distribuido por las hojas del árbol, y la única forma de conocerlo es recorrerlas todas. Nadie puede responder qué hace este programa cuando el usuario pulsa aquí sin leer el nodo concreto, y nadie puede responder qué puede pasar en esta aplicación sin leerla entera, porque no existe ningún inventario. Al obligar a que la interacción produzca un valor de un tipo enumerado, Elm crea ese inventario y lo hace obligatorio: el tipo de mensajes es la lista completa y comprobada por el compilador de todo lo que puede ocurrirle a la aplicación, y el case de update es la lista completa de lo que ocurre en cada caso. Las dos listas están obligadas a coincidir. De ahí se siguen consecuencias que en otros entornos exigen infraestructura considerable y aquí son gratuitas. Registrar la actividad del usuario es acumular una lista de valores, no instrumentar llamadas. Reproducir una sesión es volver a procesar esa lista contra el estado inicial. Adjuntar a un informe de fallo la secuencia exacta que lo provocó deja de depender de que el usuario recuerde lo que hizo, porque la secuencia es un dato que ya existe. Probar la lógica de la interfaz no requiere simular pulsaciones ni renderizar nada: se le pasa un mensaje y un modelo a una función pura y se comprueba el resultado. Y hay un efecto más lento y más importante sobre el diseño: como cada interacción obliga a inventar un nombre en el vocabulario, escribir la interfaz obliga a nombrar lo que el usuario hace en términos del dominio y no de la mecánica del navegador. El tipo de mensajes acaba siendo la mejor documentación de un producto, porque es la única que el compilador mantiene actualizada.
- Escribe las firmas de
onClick,onInputyonSubmitde memoria y justifica en cada caso por qué recibe lo que recibe. - Explica por qué un constructor con dato, escrito sin aplicar, encaja en
onInputsin ninguna conversión, y qué tipo tiene exactamente en ese punto. - Construye un campo controlado que convierta a mayúsculas y comprueba que la transformación también se aplica al pegar texto desde el portapapeles.
- Define
onEntercon un decodificador y razona por qué un decodificador fallido es la forma correcta de ignorar las demás teclas. - Sustituye
onInputpor un evento propio que lea el valor conDecode.aty compara la seguridad de ambos caminos frente a un objeto inesperado. - Enumera el tipo de mensajes de una aplicación que conozcas y comprueba si sus nombres describen el dominio o describen la mecánica del navegador.