wandres.dev
APLICACIONES GRANDES · componer módulos

Componer módulos con estado: embeber un submódulo con Cmd.map y Html.map

Hay una minoría de casos en los que un fragmento de la aplicación tiene estado verdaderamente propio, efectos propios y suscripciones propias, y en los que resulta más barato darle su propio Model, su propio Msg y su propio update que dejarlo disuelto en el modelo raíz. Un selector de fecha con navegación interna de calendario, un editor enriquecido, cada página de un enrutador. Para esos casos Elm no ofrece un mecanismo especial sino dos funciones ordinarias, Cmd.map y Html.map, que traducen el vocabulario del hijo al del padre. Esta lección desarrolla el contrato completo del submódulo embebido: qué expone y qué oculta, cómo el padre lo aloja en un campo y envuelve sus mensajes en un constructor, por qué las funciones de traducción son exactamente el functor que uno esperaría, y cómo se resuelve el problema difícil de la comunicación de vuelta hacia el padre sin abrir la encapsulación.

⏱ 18 min

Aceptada la tesis de que Elm no tiene componentes, queda un residuo honesto: fragmentos que sí tienen vida interior, que mantienen información entre interacciones, que disparan peticiones por su cuenta y que se instancian más de una vez en la misma pantalla con estados independientes. Meterlos enteros en el modelo raíz es posible pero mancha ese modelo con detalles que a nadie más le importan, y obliga al update central a tratar mensajes de una granularidad que no le corresponde. Para esa minoría existe una técnica precisa, que no es una excepción a la arquitectura sino su aplicación recursiva: el submódulo repite la estructura completa a menor escala y el padre lo embebe traduciendo el vocabulario. Lo notable es que Elm no añade ninguna primitiva para hacerlo. Todo el mecanismo consiste en dos funciones que ya conocías por su forma, Cmd.map y Html.map, aplicadas al mismo problema que resuelve cualquier map del lenguaje: cambiar el tipo que hay dentro de un contenedor sin tocar el contenedor. Ver la composición de módulos como un caso más de esa idea, y no como una característica de framework, es lo que permite razonar sobre sus costes con exactitud en lugar de por intuición.

🎯 Al terminar esta lección sabrás
  • Escribir el contrato completo de un submódulo con estado: qué exporta, qué oculta y por qué su Msg debe permanecer opaco.
  • Aplicar Cmd.map, Html.map y Sub.map para traducir el vocabulario del hijo al del padre sin perder tipado.
  • Reconocer que el trámite de embebido es siempre el mismo bloque y estimar su coste por nivel y por hijo.
  • Resolver la comunicación del hijo hacia el padre sin romper la encapsulación, comparando las técnicas disponibles.

El contrato del submódulo

Un submódulo con estado no es más que una aplicación pequeña que renuncia a ser arrancada por su cuenta. Declara su propio Model, su propio conjunto de mensajes, una función de inicialización que devuelve un modelo y quizá un comando, una función de actualización con la misma forma que la del programa principal, una vista y, si lo necesita, sus suscripciones. La diferencia con la aplicación real es que no llama a Browser.element: se limita a exponer esas piezas para que otro las use. La lista de exportación es la parte que más se decide y menos se discute, y conviene fijar dos criterios desde el principio. El tipo del modelo se exporta sin sus constructores, para que nadie de fuera lo construya ni lo modifique campo a campo. El tipo de los mensajes se exporta también sin constructores, porque si el padre pudiera fabricar mensajes del hijo estaría escribiendo lógica del hijo desde fuera, que es exactamente lo que la separación pretende evitar.

module Buscador exposing (Model, Msg, init, update, view, subscriptions)


type Model
    = Model { texto : String, resultados : List Ficha, cargando : Bool }


type Msg
    = Escribio String
    | LlegoRespuesta (Result Http.Error (List Ficha))


init : ( Model, Cmd Msg )
init =
    ( Model { texto = "", resultados = [], cargando = False }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg (Model estado) =
    case msg of
        Escribio nuevo ->
            ( Model { estado | texto = nuevo, cargando = True }, pedir nuevo )

        LlegoRespuesta resultado ->
            ( Model { estado | resultados = fichas resultado, cargando = False }, Cmd.none )

Obsérvese que las firmas del hijo son idénticas a las del programa principal salvo por el nombre de los tipos. Esa uniformidad no es estética: es lo que permite que el patrón sea recursivo y que la técnica de embebido no cambie según la profundidad. Un módulo escrito así puede vivir dentro de otro módulo escrito así, y el segundo dentro de un tercero, sin que ninguno necesite saber cuántos niveles tiene por encima.

💡
Un mismo submódulo, dos instancias, dos estados

La prueba definitiva de que un submódulo está bien encapsulado consiste en instanciarlo dos veces en la misma pantalla. Si el padre puede tener dos campos del mismo tipo de modelo, dos constructores de mensaje distintos que envuelvan el mismo tipo de mensaje del hijo y dos llamadas independientes a la vista, entonces el hijo no depende de nada global y su estado es realmente suyo. Si al hacerlo aparecen interferencias, casi siempre es por un identificador del documento fijado a mano dentro de la vista del hijo, que es la única variable global que suele colarse sin avisar.

El padre embebe traduciendo el vocabulario

El padre hace tres cosas y solo tres. Guarda el modelo del hijo en un campo del suyo. Declara una variante de su propio tipo de mensajes que envuelve los mensajes del hijo, y esa variante es literalmente una función de un tipo al otro. Y delega: cuando le llega un mensaje envuelto, lo desenvuelve, llama al update del hijo con el trozo de modelo que le corresponde, reconstruye su modelo con el resultado y traduce el comando devuelto al vocabulario propio antes de entregárselo al runtime. Esa última traducción es lo único que podría parecer misterioso y no lo es en absoluto: un comando es una descripción de un efecto que, cuando termine, producirá un mensaje; si el efecto va a producir un mensaje del hijo y el runtime del padre solo entiende mensajes del padre, hay que componer la función envolvente con el mensaje futuro. Eso es exactamente lo que hace Cmd.map.

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


type Msg
    = Izquierdo Buscador.Msg
    | Derecho Buscador.Msg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Izquierdo sub ->
            let
                ( nuevo, cmd ) =
                    Buscador.update sub model.izquierdo
            in
            ( { model | izquierdo = nuevo }, Cmd.map Izquierdo cmd )

        Derecho sub ->
            let
                ( nuevo, cmd ) =
                    Buscador.update sub model.derecho
            in
            ( { model | derecho = nuevo }, Cmd.map Derecho cmd )


view : Model -> Html Msg
view model =
    div []
        [ Html.map Izquierdo (Buscador.view model.izquierdo)
        , Html.map Derecho (Buscador.view model.derecho)
        ]

Las tres funciones de traducción comparten forma: reciben una función del vocabulario pequeño al grande y devuelven el contenedor con el vocabulario cambiado. Cmd.map traduce efectos pendientes, Html.map traduce el árbol de vista de modo que cualquier manejador de evento del hijo produzca ya un mensaje del padre, y Sub.map hace lo mismo con las suscripciones. Ninguna copia ni recorre nada de forma costosa en el sentido que uno temería: se limitan a componer funciones. Y el compilador verifica la traducción completa, así que es imposible olvidarse de envolver un mensaje en un sitio y no en otro, cosa que en un sistema de eventos sin tipos sería un fallo silencioso en producción.

El problema difícil: hablar hacia arriba

Todo lo anterior resuelve el flujo descendente. El ascendente es donde la técnica se pone interesante, porque el hijo, por definición, no conoce a su padre. Supongamos que el buscador debe avisar de que el usuario eligió una ficha, y que quien tiene que hacer algo con esa elección es el padre. La tentación es dar al hijo una referencia o un callback, y ambas cosas reintroducen precisamente el acoplamiento que se quería evitar. Hay tres soluciones limpias, y elegir entre ellas es una decisión de diseño con consecuencias.

La primera y más sencilla es no comunicar nada y dejar que el padre lea el estado del hijo mediante una función expuesta para eso, del estilo de una consulta que devuelve la ficha seleccionada si la hay. Funciona cuando el padre solo necesita saber, no reaccionar en el instante. La segunda es que el update del hijo devuelva, además del modelo y el comando, un valor que describa lo que ha ocurrido en términos del dominio y no de la interfaz. La tercera consiste en que el hijo reciba en su vista los constructores de mensaje del padre que debe emitir en ciertos puntos, técnica que funciona muy bien para vistas y muy mal para lógica.

-- El hijo declara que ocurrio, en terminos del dominio
type Evento
    = NadaRelevante
    | EligioFicha Ficha


update : Msg -> Model -> ( Model, Cmd Msg, Evento )


-- El padre decide que hacer con ese evento
Buscar sub ->
    let
        ( nuevo, cmd, evento ) =
            Buscador.update sub model.buscador
    in
    case evento of
        Buscador.NadaRelevante ->
            ( { model | buscador = nuevo }, Cmd.map Buscar cmd )

        Buscador.EligioFicha ficha ->
            abrirDetalle ficha { model | buscador = nuevo }

El valor de esta tercera pieza está en que el hijo no decide nada sobre el padre: describe un hecho y el padre lo interpreta. Dos padres distintos pueden reaccionar de forma distinta al mismo hecho, la firma documenta el contrato completo y el compilador obliga a tratar todos los casos. El precio es que la tupla de tres elementos se propaga por cada nivel de anidamiento, lo que constituye otro argumento, y no menor, para mantener la jerarquía plana.

flowchart LR
U[Usuario] --> HV[Vista del hijo]
HV -->|Html map| PM[Mensaje del padre]
PM --> PU[Update del padre]
PU -->|desenvuelve| HU[Update del hijo]
HU -->|modelo y comando| PU
HU -->|evento de dominio| PU
PU -->|Cmd map| RT[Runtime]
style PM fill:#89b4fa,color:#11111b
style HU fill:#a6e3a1,color:#11111b
style PU fill:#cba6f7,color:#11111b
La composición de módulos no es una característica del framework: es el mismo map de siempre aplicado a vocabularios

Merece la pena detenerse en lo que no ocurrió aquí, porque es donde está la enseñanza transferible. Elm no añadió una construcción para anidar unidades de estado. No hay una palabra reservada, ni un contexto implícito, ni un árbol de inyección, ni un sistema de eventos con burbujeo, ni un registro donde declarar hijos. Hay dos funciones cuya firma es la misma que la de cualquier otro map del lenguaje, y esa coincidencia no es casual sino estructural: un comando y un árbol de vista son contenedores parametrizados por el tipo del mensaje que producirán, y cambiar ese parámetro sin tocar la estructura interna es literalmente lo que un functor hace. Cuando comprendes eso, la composición de módulos deja de ser un tema que se estudia aparte y pasa a ser una aplicación de algo que ya sabías, lo cual tiene una consecuencia práctica: puedes predecir su comportamiento sin documentación, porque las leyes que cumple son las mismas. Mapear con la identidad no cambia nada. Mapear dos veces equivale a mapear con la composición, así que anidar tres niveles es idéntico a componer tres constructores, y de ahí se deduce, sin experimentar, que la profundidad no introduce comportamientos nuevos sino solo trabajo repetido. Compárese esto con lo que hay que aprender de memoria en un modelo de componentes con propagación de eventos, orden de montaje, reglas de captura y burbujeo, y excepciones para portales. La diferencia no está en la elegancia sino en la cantidad de reglas que hay que retener para saber qué va a pasar, y esa cantidad es la verdadera medida de la complejidad de una arquitectura. Un sistema que se explica con funciones que ya conoces te deja la memoria libre para el dominio, que es el único lugar donde tu esfuerzo produce valor.

⚔️ Embebe uno y págalo con los ojos abiertos
  1. Escribe un submódulo con estado, comando y suscripción propios, y decide su lista de exportación justificando por qué el tipo de mensajes queda opaco.
  2. Embébelo dos veces en el mismo padre con constructores distintos y comprueba que los dos estados no interfieren.
  3. Cuenta las líneas de puro trámite que añade el embebido y multiplica por el número de hijos y de niveles que tendría tu aplicación real.
  4. Sustituye una de las llamadas a Html.map por la vista del hijo sin traducir y lee con atención el error del compilador; explica qué te está demostrando sobre los vocabularios.
  5. Implementa la comunicación hacia arriba con la tupla de tres elementos y escribe dos padres que reaccionen de forma distinta al mismo evento del hijo.
  6. Reescribe el mismo caso disolviendo el submódulo en el modelo raíz y compara ambas versiones en líneas totales, en acoplamiento y en facilidad de prueba.