wandres.dev
HTML COMO FUNCIÓN · la vista pura

Html.map: traducir el vocabulario de una vista hija

Un tipo de mensajes es un vocabulario cerrado, y ese cierre plantea de inmediato el problema de la composición: si un módulo hijo emite sus propios mensajes y el padre solo entiende los suyos, sus árboles no encajan por tipo. La solución de Elm es una función de una sola línea que reetiqueta el árbol entero. Esta lección la estudia a fondo. Empieza por la firma y por lo que significa aplicar una función a la posición de los mensajes de una estructura ya construida, es decir, por reconocer que el árbol de vista es un funtor. Sigue con el patrón completo de composición: un módulo autónomo que ignora quién lo usa, un padre que envuelve sus mensajes en un constructor propio y un update que desenvuelve y delega. Después trata el caso de las colecciones, donde el constructor lleva además un identificador para saber qué elemento habló. Y cierra con los límites y los costes: la traducción unidireccional, el diálogo hacia arriba cuando el hijo necesita que el padre haga algo que le excede, y la interacción del reetiquetado con la memoización de la vista.

⏱ 18 min

El precio de tener un vocabulario cerrado y comprobado por el compilador aparece en cuanto una aplicación crece más allá de un solo archivo. Un módulo que representa un contador, un campo de búsqueda o una tabla ordenable quiere ser autónomo: define su propio modelo, su propio tipo de mensajes y su propia vista, y lo hace sin saber nada de quién lo va a usar, porque esa ignorancia es justo lo que lo hace reutilizable. Pero entonces su vista tiene el tipo de sus mensajes, la vista del padre tiene el tipo de los suyos, y ambos árboles no encajan: el compilador rechaza colocar un hijo dentro del padre porque un fragmento que emite un vocabulario ajeno no puede vivir dentro de un árbol que promete emitir el propio. La tentación es fusionar los dos tipos en uno solo y renunciar a la autonomía. La respuesta correcta cabe en una línea y es una de las piezas más elegantes de la arquitectura: una función que recorre el árbol construido y traduce cada mensaje que pudiera emitir, dejando intacta la estructura. Con ella, componer vistas deja de ser un problema de tipos y se convierte en un problema de traducción.

🎯 Al terminar esta lección sabrás
  • Leer la firma de Html.map y describir con precisión qué transforma y qué deja intacto de un árbol.
  • Aplicar el patrón completo de composición: hijo autónomo, constructor envolvente en el padre y delegación en update.
  • Componer colecciones de vistas hijas usando un constructor que incorpore el identificador del elemento.
  • Reconocer los límites del reetiquetado y las alternativas cuando el hijo necesita algo del padre.

Reetiquetar sin tocar la estructura

La firma dice que, dada una función que traduce mensajes de un vocabulario a otro, se obtiene una función que traduce árboles enteros. Lo importante es entender qué hace y qué no hace. No reconstruye el documento, no altera atributos ni hijos, no cambia el orden ni la forma: recorre el árbol y sustituye, en cada punto donde había un manejador capaz de emitir un mensaje del vocabulario de origen, un manejador que emitirá el resultado de aplicarle la función. La estructura visible queda idéntica y solo cambia lo que ese árbol puede decir cuando alguien interactúe con él.

Html.map : (a -> b) -> Html a -> Html b


-- El modulo hijo no sabe nada del padre
module Contador exposing (Model, Msg, update, view)

type Msg
    = Mas
    | Menos


view : Model -> Html Msg
view model =
    div [ class "contador" ]
        [ button [ onClick Menos ] [ text "-" ]
        , span [] [ text (String.fromInt model.valor) ]
        , button [ onClick Mas ] [ text "+" ]
        ]

Esta operación es exactamente la que en el nivel de los tipos parametrizados se llamaba aplicar una función dentro de un contenedor, y el árbol de vista se comporta como los contenedores que ya conoces: componer dos traducciones seguidas equivale a traducir una sola vez con la composición de ambas, y traducir con la función identidad no cambia nada. Reconocerlo tiene valor práctico y no solo estético, porque autoriza a razonar sobre árboles reetiquetados sin abrirlos: si dos capas de la aplicación envuelven sucesivamente el mismo fragmento, el resultado no depende de cuántas capas haya sino de la composición de sus traductores.

🏷️

Solo cambia la etiqueta

La estructura, los atributos y los hijos permanecen. Lo único que se transforma es el vocabulario de los mensajes que el árbol puede emitir.

🧩

El hijo ignora al padre

Un módulo de vista define su tipo de mensajes sin saber quién lo consumirá, y por eso puede usarse dos veces en la misma pantalla.

🔗

Composición de traductores

Dos reetiquetados seguidos equivalen a uno con la composición de ambas funciones. Anidar capas no multiplica la complejidad del razonamiento.

🚚

Su pareja en los efectos

Cmd.map hace lo mismo con los comandos que devuelve el hijo, de modo que la delegación queda completa en las dos direcciones del bucle.

El patrón completo: envolver arriba, desenvolver abajo

La composición tiene tres piezas que siempre aparecen juntas. El padre guarda el modelo del hijo dentro del suyo, declara un constructor que envuelve los mensajes ajenos dentro del vocabulario propio y usa ese mismo constructor como traductor al insertar la vista. Cuando el mensaje envuelto llega a update, la rama correspondiente lo desenvuelve, se lo entrega al update del hijo junto con la porción de modelo que le corresponde, y coloca el resultado de vuelta en su sitio. El constructor sirve así de puente en las dos direcciones: envuelve al bajar por la vista y desenvuelve al subir por la actualización.

type alias Model =
    { izquierdo : Contador.Model, derecho : Contador.Model }


type Msg
    = MsgIzquierdo Contador.Msg
    | MsgDerecho Contador.Msg


view : Model -> Html Msg
view model =
    div [ class "panel" ]
        [ Html.map MsgIzquierdo (Contador.view model.izquierdo)
        , Html.map MsgDerecho (Contador.view model.derecho)
        ]


update : Msg -> Model -> Model
update msg model =
    case msg of
        MsgIzquierdo sub ->
            { model | izquierdo = Contador.update sub model.izquierdo }

        MsgDerecho sub ->
            { model | derecho = Contador.update sub model.derecho }

El ejemplo enseña además por qué la autonomía del hijo era la propiedad valiosa. El mismo módulo aparece dos veces en la misma pantalla, con dos estados independientes, y ninguna de las dos copias necesita saberlo: es el padre quien las distingue, y las distingue nombrándolas en su propio vocabulario. Si el hijo hubiese conocido a su consumidor, cada uso adicional habría exigido modificarlo. Aquí no se toca una línea del módulo, porque la diferencia entre las dos instancias no vive en el hijo sino en el constructor que lo envuelve.

flowchart TD
H[Vista hija Html MsgHijo] --> W[Html map con el constructor]
W --> P[Arbol del padre Html MsgPadre]
P --> R[Runtime]
R --> U[update del padre]
U --> D[Desenvuelve y delega]
D --> S[update del hijo]
style W fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b
style S fill:#cba6f7,color:#11111b

Colecciones e identidad del emisor

El caso de una lista de vistas hijas parece más difícil y solo pide un matiz. Cuando hay muchas instancias del mismo módulo, el padre necesita saber cuál de ellas emitió el mensaje, así que el constructor envolvente lleva un dato más: el identificador del elemento. Con un identificador estable dentro del propio dato, el traductor se construye aplicándolo parcialmente; con la posición como identificador se usa la variante indexada, aunque conviene preferir el identificador propio porque la posición cambia al ordenar o al filtrar y produce confusiones difíciles de diagnosticar.

type Msg
    = MsgFila Int Fila.Msg
    | FilaEliminada Int


view : Model -> Html Msg
view model =
    ul [] (List.map filaEnvuelta model.filas)


filaEnvuelta : Fila.Model -> Html Msg
filaEnvuelta fila =
    li [] [ Html.map (MsgFila fila.id) (Fila.view fila) ]


-- Delegar al hijo correcto dentro de la coleccion
update : Msg -> Model -> Model
update msg model =
    case msg of
        MsgFila id sub ->
            { model | filas = List.map (actualizarSi id sub) model.filas }

        FilaEliminada id ->
            { model | filas = List.filter (\f -> f.id /= id) model.filas }
⚠️
La traducción es de ida y no resuelve el diálogo hacia arriba

El reetiquetado transporta mensajes del hijo al padre, pero no le da al hijo ninguna forma de pedirle algo al padre. Cuando un fragmento necesita que ocurra un efecto que le excede, por ejemplo cerrar el diálogo que lo contiene o guardar en el servidor, hay dos caminos honestos y ninguno es forzar el reetiquetado. El primero consiste en que el update del hijo devuelva, junto a su modelo, un valor que describa lo que pide, y que el padre lo interprete. El segundo consiste en que el hijo reciba como parámetro las funciones constructoras del padre para los casos que le conciernen. Lo que no funciona es que el hijo conozca el tipo de mensajes del padre: eso deshace la independencia que justificaba separar el módulo y hace imposible usarlo dos veces.

Queda un coste técnico que conviene tener presente antes de repartir la aplicación en muchas capas. Envolver un árbol implica recorrerlo para traducir sus manejadores, y esa capa de indirección interactúa con la memoización que veremos a continuación: si la función traductora se construye de nuevo en cada dibujado, por ejemplo escribiéndola como una lambda en el lugar, se pierde la estabilidad de referencia de la que depende la optimización. La disciplina que resuelve casi todos los casos es usar como traductor un constructor del tipo de mensajes, aplicado parcialmente cuando lleve identificador, y colocar el reetiquetado lo más cerca posible del punto donde se inserta la vista del hijo.

Componer interfaces es traducir vocabularios, no compartir memoria

Conviene enunciar lo que esta función de una línea resuelve, porque es un problema que el resto de la industria ha atacado con una cantidad desproporcionada de maquinaria. En cualquier sistema de componentes, la comunicación entre las partes de una interfaz se plantea como un problema de acceso: cómo hace un fragmento para llegar al estado de otro, cómo se notifica hacia arriba, cómo se evita perforar quince niveles pasando lo mismo de mano en mano. De ahí nacen los contextos implícitos, las inyecciones de dependencias, los emisores de eventos globales, los almacenes compartidos y los observables suscribibles desde cualquier punto. Todos son variantes de la misma idea: crear canales laterales por los que un fragmento alcance algo que no está en sus parámetros. Y todos comparten el mismo defecto, que es volver ilegible el grafo real de dependencias: para saber qué puede afectar a un fragmento hay que buscar por toda la aplicación quién publica en los canales a los que ese fragmento está suscrito. La composición por traducción parte de una premisa opuesta. Ningún fragmento alcanza nada; todo lo que un fragmento sabe entra por su parámetro y todo lo que un fragmento dice sale por su tipo de mensajes, y la única forma de conectar dos piezas es que alguien, por encima de ambas, escriba explícitamente la traducción entre sus vocabularios. Eso tiene un coste evidente, que es la ceremonia de envolver y desenvolver en cada capa, y una recompensa que se cobra durante toda la vida del proyecto: el grafo de comunicación de la aplicación es exactamente el árbol de llamadas de la vista, no hay aristas invisibles, y el compilador verifica cada una de esas aristas. Un módulo autónomo puede entonces instanciarse dos veces sin conflicto, moverse a otro punto del árbol sin tocarlo, probarse aislado sin montar un entorno y leerse sin buscar por ninguna parte quién más le habla, porque nadie puede hablarle salvo su padre inmediato y siempre con un traductor a la vista.

⚔️ Compón vistas traduciendo su vocabulario
  1. Escribe la firma de Html.map de memoria y enumera qué transforma y qué deja intacto de un árbol ya construido.
  2. Comprueba con dos capas anidadas que reetiquetar dos veces equivale a reetiquetar una con la composición de ambos traductores.
  3. Coloca el mismo módulo hijo dos veces en una pantalla con estados independientes y localiza el único sitio donde se distinguen.
  4. Añade una colección de hijos con un constructor que lleve identificador y explica por qué es preferible al índice de posición.
  5. Diseña una situación en la que el hijo necesite que el padre haga algo que le excede, y resuélvela sin que el hijo conozca el vocabulario del padre.
  6. Sustituye un traductor por una lambda equivalente escrita en el lugar y razona qué propiedad se pierde de cara a la memoización de la vista.