wandres.dev
APLICACIONES GRANDES · componer módulos

Elm no tiene componentes: por qué la comunidad lo desaconseja

Casi nadie llega a Elm sin la pregunta ya formulada: cómo se hace un componente. Viene en el equipaje de React, de Vue, de Angular y de Svelte, donde el componente es la unidad de todo, de la reutilización a la organización de carpetas, y donde no saber crear uno equivale a no saber trabajar en el ecosistema. En Elm esa pregunta no tiene respuesta, y no porque falte una función que nadie escribió todavía, sino porque el concepto no existe ni en el lenguaje ni en su arquitectura, y la comunidad, empezando por el autor del lenguaje, lleva casi una década pidiendo activamente que no se reconstruya. Esta lección desmonta la recomendación hasta sus fundamentos: qué se pide de verdad cuando se pide un componente, qué parte de eso TEA ya resuelve por otros medios, qué parte es un problema que el modelo de componentes crea al mismo tiempo que lo resuelve, y qué ofrece Elm en su lugar cuando una aplicación deja de ser un ejemplo y pasa a tener decenas de miles de líneas.

⏱ 17 min

Hay una diferencia importante entre una tecnología que carece de una funcionalidad y una tecnología que la ha rechazado después de examinarla. Elm está en el segundo caso. Durante sus primeras versiones circularon tutoriales que mostraban cómo anidar unidades de estado unas dentro de otras, y la comunidad los leyó, con toda naturalidad, como el equivalente elmiano de un componente: un ejemplo canónico que había que imitar en cuanto la aplicación pasara de un contador. El resultado fue una generación de bases de código con jerarquías profundas de estado, cascadas de mensajes que subían y bajaban por niveles intermedios que no hacían nada con ellos, y una sensación general de estar peleando contra el lenguaje. La reacción no fue añadir azúcar sintáctico para abaratar ese anidamiento, sino retirar los ejemplos y explicar por qué la analogía era falsa desde el principio. Entender ese giro es entender algo más profundo que una convención de estilo: es entender que la modularidad de una aplicación puede descansar en funciones y módulos en lugar de descansar en objetos con estado propio, y que esa elección tiene consecuencias medibles sobre lo que cuesta cambiar el programa dentro de dos años.

🎯 Al terminar esta lección sabrás
  • Descomponer la palabra componente en las necesidades distintas que suele esconder y ver cómo cubre cada una la arquitectura.
  • Explicar por qué un bucle único de Model, Msg, update y view no admite islas de estado autónomo sin pagar por ellas.
  • Reconocer el anidamiento mecánico que la comunidad desaconseja y los síntomas que produce en una base grande.
  • Enunciar la alternativa oficial: funciones de vista, módulos con superficie estrecha y el estado viviendo en el modelo raíz.

La palabra esconde cuatro necesidades distintas

Cuando alguien pide un componente rara vez pide una sola cosa. Está pidiendo, mezcladas, cuatro necesidades que en los ecosistemas de objetos vienen empaquetadas de fábrica y que conviene separar antes de discutir nada. La primera es reutilizar apariencia: quiere escribir una tarjeta una vez y usarla en once pantallas. La segunda es guardar estado que solo importa dentro de un fragmento, como si un desplegable está abierto. La tercera es encapsular, es decir, impedir que el resto del programa manipule por dentro algo que debería ser opaco. La cuarta es tener una unidad de organización, un lugar donde poner el archivo. En un modelo de componentes las cuatro se resuelven con la misma construcción, y por eso parecen una sola.

En Elm las cuatro existen, pero cada una tiene su propia herramienta, y la potencia del diseño está justamente en que no comparten mecanismo. Reutilizar apariencia es escribir una función que devuelve Html msg, sin ceremonia ni registro. Encapsular es declarar un módulo que expone un tipo sin sus constructores y las funciones que lo manipulan, de modo que nadie de fuera pueda construir un valor inválido. Organizar es crear un archivo, porque en Elm un módulo es un archivo y nada más. Y guardar estado es añadir un campo al modelo, en el nivel donde ese estado tenga sentido. Ninguna de las cuatro necesita a las otras tres, así que reutilizar una vista no obliga a darle estado, y darle estado a algo no obliga a inventarle un ciclo de vida.

La separación se aprecia mejor cuando se escribe una misma pieza con las cuatro herramientas puestas una al lado de otra. Lo que en un modelo de componentes sería un solo archivo con una clase, un estado interno, un método de dibujo y una interfaz de propiedades, aquí se descompone en cosas que no se parecen entre sí y que se pueden adoptar por separado: puedes tener la función sin el módulo, el módulo sin el campo en el modelo, y el campo sin nada más. Esa independencia es lo que hace que el coste de cada decisión sea proporcional a lo que realmente necesitas, en vez de venir en un paquete indivisible.

-- Reutilizar apariencia: una funcion, nada mas
verPrecio : Centimos -> Html msg


-- Encapsular: un modulo que no deja construir un valor invalido
module Centimos exposing (Centimos, deEntero, aTexto)


-- Guardar estado: un campo y sus mensajes, en el nivel que corresponda
type alias Model =
    { cesta : Cesta, cuponAbierto : Bool }
🌳

Reutilizar apariencia

Una función que devuelve Html msg. Se compone, se pasa como argumento, se guarda en una lista. Nada que registrar.

🔒

Encapsular

Un módulo que expone un tipo sin constructores. La invariante la garantiza la lista de exportación, no la disciplina del equipo.

🗂️

Organizar

Un archivo por módulo, nombrado por dominio. La jerarquía de carpetas es de nombres, no de estado.

🧮

Guardar estado

Un campo más en el modelo, con sus variantes en Msg. El estado no se esconde en un nodo del árbol de vista.

Por qué el bucle no admite islas autónomas

La arquitectura de Elm tiene una propiedad que suele enunciarse como eslogan y que en realidad es una restricción técnica dura: existe un solo estado, un solo punto donde ese estado cambia y una sola descripción de la interfaz derivada de él. De ahí salen las garantías que hacen atractivo al lenguaje. El depurador puede reconstruir cualquier instante de la sesión porque tiene la lista completa de mensajes y una función pura que los aplica. El compilador puede exigir exhaustividad al tratar los mensajes porque el vocabulario está cerrado. Una prueba puede ejercitar toda la lógica sin montar un entorno de ejecución porque update no depende de nada externo. Un componente con estado propio, entendido como un nodo del árbol de vista que guarda información que el modelo no conoce, rompe las tres a la vez: hay estado fuera del modelo, hay cambios fuera de update y hay historia que el registro de mensajes no contiene.

Merece la pena insistir en que esta no es una objeción de pureza doctrinal. La consecuencia práctica de tener estado fuera del modelo es concreta y se paga a diario: un fallo reportado por un usuario deja de ser reproducible a partir de la secuencia de mensajes, porque falta información que solo existía dentro de un nodo del árbol; una prueba de un flujo completo necesita simular interacciones en lugar de aplicar mensajes; y la respuesta a la pregunta de por qué la pantalla muestra esto pasa a depender de un historial invisible. Todo lo que hace agradable depurar una aplicación en Elm descansa en que esa información no exista en ningún otro sitio.

Se puede, desde luego, anidar unidades completas de la arquitectura: un submódulo con su propio Model, su propio Msg y su propio update, embebido en el padre. Eso no rompe ninguna garantía, porque el estado del hijo sigue estando dentro del estado del padre y sus mensajes siguen pasando por el bucle. Lo que ocurre es que se paga, y el precio se ve mejor escrito que explicado.

-- El coste fijo de cada nivel de anidamiento
type alias Model =
    { buscador : Buscador.Model }


type Msg
    = BuscadorMsg Buscador.Msg


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

Ese bloque no aporta lógica de negocio: es puro trámite de transporte, y se repite entero en cada nivel de la jerarquía y por cada hijo. Con dos niveles resulta tolerable; con cinco, un mensaje del usuario atraviesa cuatro funciones que no hacen nada con él salvo desenvolverlo y volverlo a envolver. Peor aún es el problema de la comunicación lateral: si el hijo necesita avisar al padre de algo, no basta con devolver un modelo y un comando, hay que inventar un canal adicional, y si dos hermanos deben coordinarse, el mensaje sube hasta el ancestro común y vuelve a bajar. Ninguna de esas dificultades es un fallo de Elm; son la consecuencia inevitable de haber trazado una frontera de estado donde los datos no la tenían.

⚠️
El síntoma que delata el anidamiento prematuro

Hay una prueba sencilla para saber si una jerarquía de submódulos está bien trazada. Toma un mensaje real de tu aplicación y sigue su recorrido desde que el usuario lo produce hasta la función que decide algo con él. Cuenta cuántos case lo tocan por el camino sin hacer más que reenviarlo. Si la respuesta es cero o uno, la frontera está donde debe. Si son tres o cuatro, has construido una tubería, y el modelo mental que la sostiene no es el de tu dominio sino el del árbol de carpetas que elegiste el primer día.

Lo que se propone en su lugar

Queda entonces la pregunta legítima de qué hace uno en su lugar cuando la aplicación crece y el archivo principal supera las mil líneas. La respuesta oficial existe, está escrita y es menos espectacular de lo que la gente espera, lo cual explica en parte por qué se ignora tan a menudo.

La recomendación de la comunidad, formulada con más insistencia por Evan Czaplicki en su charla sobre la vida de un archivo y recogida después en el capítulo de escalado de la guía oficial, se resume en una inversión del orden habitual. En vez de empezar decidiendo las unidades y repartir el código entre ellas, se empieza con un archivo grande y se deja que las fronteras aparezcan por sí solas cuando el código lleve tiempo suficiente estabilizado como para que sean visibles. Cuando aparecen, casi nunca coinciden con fragmentos de pantalla: coinciden con conceptos del dominio, con estructuras de datos que tienen invariantes que proteger, con procesos que tienen estados legítimos y transiciones. El módulo que emerge de ahí no expone un ciclo de vida, expone un tipo y las operaciones que lo respetan.

-- Frontera por dominio: un tipo opaco y su vocabulario
module Cesta exposing (Cesta, vacia, agregar, total, articulos)


type Cesta
    = Cesta (List Linea)


agregar : Producto -> Int -> Cesta -> Cesta
agregar producto cantidad (Cesta lineas) =
    Cesta (fusionar producto cantidad lineas)


-- La vista es otra cosa, y es solo una funcion
verCesta : Cesta -> Html msg
verCesta cesta =
    ul [] (List.map verLinea (Cesta.articulos cesta))

Hay un dato histórico que ilumina esta recomendación mejor que cualquier argumento. Los ejemplos anidados que circulaban en los primeros años no se retiraron porque estuvieran mal escritos ni porque el patrón fallara técnicamente: se retiraron porque la comunidad observó, proyecto tras proyecto, que quien los leía los interpretaba como una plantilla que había que aplicar por defecto en lugar de como una técnica para un caso raro. Es un fenómeno conocido en la enseñanza de cualquier disciplina: un ejemplo canónico no transmite solo su contenido, transmite también la sugerencia de que eso es lo normal. Y como el patrón anidado se parecía a lo que la gente ya conocía de otros ecosistemas, encontraba menos resistencia que la alternativa, que exige desaprender antes de aprender. La retirada de los ejemplos fue, en ese sentido, una decisión pedagógica, no técnica.

Nótese lo que ha desaparecido. No hay que decidir si la cesta es un componente de presentación o de contenedor, ni cómo notifica a su padre, ni qué ocurre cuando se monta y se desmonta. Hay un tipo que nadie puede construir mal, un puñado de funciones puras que lo transforman y, separadamente, una función que lo dibuja. La reutilización está en las funciones, la protección está en la lista de exportación del módulo y el estado está donde siempre estuvo, en el modelo. Queda todavía un caso legítimo de submódulo con estado, y existe: lo estudiaremos en la lección siguiente, junto con el criterio para reconocerlo, porque negarlo del todo sería tan dogmático como usarlo en todas partes.

flowchart TD
P[Peticion de un componente] --> R[Reutilizar apariencia]
P --> E[Encapsular invariantes]
P --> O[Organizar archivos]
P --> S[Guardar estado]
R --> F[Funcion que devuelve Html]
E --> M[Modulo con tipo opaco]
O --> A[Un archivo por modulo]
S --> D[Campo en el modelo raiz]
style P fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b
style M fill:#89b4fa,color:#11111b
style D fill:#cba6f7,color:#11111b
El componente no es una unidad natural del software: es la sombra que proyecta un modelo de ejecución concreto

Conviene decirlo sin diplomacia, porque la tesis es más fuerte de lo que parece. La unidad componente no se descubrió analizando la naturaleza de las interfaces, se dedujo de una restricción técnica muy concreta: en un entorno donde el árbol del documento es mutable y persistente, y donde el estado vive en objetos que sobreviven entre repintados, hace falta un lugar donde alojar ese estado junto al fragmento de árbol que lo consume, y ese lugar es el componente. Todo lo demás, el ciclo de vida, la reconciliación, las claves de lista, las reglas de memorización, las cascadas de invalidación, son consecuencias de haber elegido esa colocación. Y como toda abstracción que nace de una restricción, se universaliza: al cabo de unos años deja de percibirse como una decisión de ingeniería y pasa a percibirse como la forma en que las interfaces son, hasta el punto de que quien llega a un entorno donde esa restricción no existe sigue buscando la construcción que la resolvía. Elm no tiene esa restricción. El estado no vive en el árbol porque el árbol es un valor que se descarta y se vuelve a calcular; la vista no necesita memoria propia porque es una función del modelo; el ciclo de vida no hace falta porque no hay objetos que nazcan y mueran, sino descripciones que se comparan. Retirado el motivo, la construcción sobra, y lo que queda al retirarla es sorprendentemente austero: funciones para lo que se repite, módulos para lo que hay que proteger, un tipo para lo que hay que hacer imposible. La lección general trasciende a Elm por completo. Cada vez que adoptes un patrón, merece la pena preguntarse qué restricción concreta vino a resolver, porque si esa restricción no está presente en tu entorno, lo que estás importando no es la solución sino el problema.

⚔️ Desarma la palabra antes de usarla
  1. Toma tres componentes de una aplicación que conozcas y clasifica, para cada uno, cuál de las cuatro necesidades justificaba realmente su existencia.
  2. Elige el que solo reutilizaba apariencia y escribe su equivalente en Elm como una función que devuelve Html msg con parámetros explícitos.
  3. Elige uno que guardara estado interno y decide dónde viviría ese estado en el modelo raíz; escribe el campo y las variantes de Msg que harían falta.
  4. Escribe a mano el bloque de transporte de un submódulo anidado y cuenta cuántas líneas de trámite aporta por nivel; extrapola a una jerarquía de cuatro.
  5. Aplica la prueba del recorrido a un mensaje real y cuenta los case que solo lo reenvían; propón dónde recolocar la frontera.
  6. Escribe la lista de exportación de un módulo de tu dominio y justifica cada nombre que dejas fuera.
  7. Convierte una estructura de datos de tu dominio en un módulo con tipo opaco y enumera qué estados inválidos deja de poder representar tu programa.