Funciones de vista reutilizables: el enfoque preferido
Si el submódulo con estado es la excepción, la función de vista es la regla, y conviene estudiarla con la misma seriedad con la que se estudia una API pública, porque eso es exactamente lo que es. Una función que recibe los datos que necesita y devuelve un árbol sin memoria propia resuelve la inmensa mayoría de los casos que en otros ecosistemas exigirían declarar un componente, y lo hace sin ciclo de vida, sin registro, sin claves de reconciliación y sin preguntas sobre dónde vive el estado. Esta lección examina cómo se diseña una de estas funciones para que envejezca bien: qué parámetros admite y en qué orden, por qué su tipo debe permanecer genérico en el vocabulario de mensajes siempre que se pueda, cómo se le pasan los constructores de mensaje del llamante en lugar de inventarle mensajes propios, cuándo conviene un registro de configuración y qué invariantes se protegen con un constructor opaco de opciones.
Hay una asimetría instructiva en cómo tratamos las funciones según la capa en que viven. Nadie discute que una función de la capa de datos debe tener parámetros bien elegidos, un tipo de retorno honesto, ninguna dependencia oculta y un nombre que diga lo que hace; sobre eso hay décadas de literatura y consenso. En cambio, las funciones que producen interfaz suelen escribirse sin ese cuidado, como si dibujar no mereciera diseño, y el resultado son vistas que reciben el modelo entero porque nadie quiso pensar qué necesitaban de verdad, que emiten mensajes de un vocabulario concreto porque se escribieron para una pantalla, y que acumulan parámetros booleanos hasta que la llamada se vuelve ilegible. En Elm el precio de esa dejadez es más visible que en otros sitios, porque no hay componente donde esconder el desorden: si la función está mal diseñada, la mala firma queda a la vista en cada llamada. La contrapartida es que las herramientas para hacerlo bien son las mismas que ya dominas, y que una vista bien diseñada se comporta como cualquier otra función bien diseñada, es decir, se compone, se prueba, se refactoriza y sobrevive a los cambios de la pantalla que la vio nacer.
- Diseñar la firma de una función de vista eligiendo los datos mínimos que necesita en lugar de recibir el modelo completo.
- Mantener el tipo genérico en el vocabulario de mensajes y saber cuándo esa generalidad deja de ser posible.
- Pasar constructores de mensaje del llamante para que la vista emita sin conocer el vocabulario de ninguna pantalla.
- Elegir entre parámetros posicionales, registro de configuración y constructor opaco según el número de opciones y sus invariantes.
La firma es la interfaz pública
La primera decisión, y la que más consecuencias tiene, es qué recibe la función. La tentación de pasarle el modelo completo es enorme porque siempre funciona y nunca hay que volver a tocar la firma. También es la que garantiza que la vista quede atada para siempre a esa aplicación concreta, que no pueda probarse sin construir un modelo entero y que cualquier lectura del código exija averiguar qué campos usa realmente. La alternativa cuesta un minuto más y cambia la naturaleza del código: pedir exactamente los datos que se dibujan. Una función que recibe un texto y una lista de opciones es reutilizable, comprobable de un vistazo y su firma documenta su alcance. Cuando la lista de parámetros empieza a crecer, eso no es un fallo del método sino una señal legítima de que hay un concepto sin nombre esperando a ser extraído en un tipo.
La segunda decisión es el tipo de retorno, y aquí Elm ofrece una distinción que conviene explotar. Un valor de tipo Html msg con el parámetro sin instanciar es una vista que no emite mensajes propios y por tanto encaja en cualquier aplicación. Mantener esa generalidad todo lo que se pueda es la disciplina más rentable que existe en esta capa, porque es una promesa verificada por el compilador: quien la lea sabe, sin abrir la implementación, que ese fragmento no puede disparar nada. En cuanto una vista necesita reaccionar a la interacción del usuario deja de ser genérica por sí sola, pero incluso entonces conviene no atarla, y ahí entra la técnica central de esta lección.
-- Ata la vista a una aplicacion concreta
verFiltro : Model -> Html Msg
-- Generica y sin memoria: encaja en cualquier vocabulario
verEtiqueta : String -> Html msg
verEtiqueta texto =
span [ class "etiqueta" ] [ text texto ]
-- Emite, pero sin conocer el vocabulario de nadie
verInterruptor : Bool -> (Bool -> msg) -> Html msg
verInterruptor activo alCambiar =
button
[ classList [ ( "activo", activo ) ]
, onClick (alCambiar (not activo))
]
[ text
(if activo then
"Encendido"
else
"Apagado"
)
]
La tercera firma es el patrón que sustituye, en la práctica, a la mayor parte de los componentes de otros ecosistemas. La vista no inventa un mensaje ni lo declara: recibe del llamante una función que construye el mensaje adecuado, y como el parámetro de tipo queda libre, la misma vista sirve en una pantalla de ajustes, en un panel de administración y en un formulario de registro, cada uno con su propio vocabulario. No hay Html.map, no hay traducción, no hay estado que sincronizar y no hay nada que inicializar.
Datos mínimos
Pide lo que dibujas, no el modelo entero. La firma documenta el alcance y la prueba se escribe en una línea.
Genérica por defecto
Deja el parámetro de mensaje libre mientras puedas. Es una promesa comprobada de que ese fragmento no dispara nada.
Constructores prestados
Recibe la función que fabrica el mensaje. La vista emite sin conocer el vocabulario de ninguna pantalla.
Registro cuando crece
Cuatro parámetros o más, o varios del mismo tipo, piden un registro con nombres en vez de posiciones.
Del parámetro suelto al registro de configuración
Las firmas posicionales envejecen mal en cuanto aparecen tres o cuatro parámetros, y peor todavía si dos tienen el mismo tipo, porque entonces el compilador deja de protegerte contra el intercambio accidental y el error se manifiesta como un comportamiento raro en lugar de como un fallo de compilación. El remedio estándar es un registro que da nombre a cada opción. Gana legibilidad en la llamada, permite añadir campos sin romper el orden y, sobre todo, convierte la firma en documentación: quien lee la definición del tipo del registro sabe qué se puede configurar sin leer una sola línea del cuerpo.
Hay un escalón más, para cuando la configuración tiene invariantes que no se pueden expresar como campos independientes. Si dos opciones son mutuamente excluyentes, o si una solo tiene sentido cuando otra está activa, el registro público permite construir combinaciones sin sentido y la vista se llena de casos defensivos. La solución idiomática consiste en exportar el tipo de configuración sin sus constructores y ofrecer un valor inicial junto a funciones que lo modifican, de manera que las combinaciones ilegales sean inexpresables. Las bibliotecas maduras del ecosistema usan este patrón con notable consistencia, y su ventaja no es la comodidad de la llamada encadenada sino que la superficie de estados posibles queda bajo control del autor del módulo.
module Boton exposing (Config, primario, secundario, deshabilitado, ver)
type Config msg
= Config { estilo : Estilo, activo : Bool, alPulsar : msg }
primario : msg -> Config msg
primario alPulsar =
Config { estilo = Primario, activo = True, alPulsar = alPulsar }
deshabilitado : Config msg -> Config msg
deshabilitado (Config c) =
Config { c | activo = False }
ver : Config msg -> String -> Html msg
ver (Config c) etiqueta =
button
[ classList [ ( "primario", c.estilo == Primario ) ]
, disabled (not c.activo)
, onClick c.alPulsar
]
[ text etiqueta ]
La progresión tiene un orden natural que conviene no saltarse. Empieza con parámetros posicionales mientras sean dos o tres y de tipos distintos, porque es la forma más ligera y la que menos código exige. Pasa al registro cuando aparezca el cuarto parámetro, o antes si dos comparten tipo y podrían confundirse. Sube al tipo opaco solo cuando existan combinaciones de opciones que no deban poder escribirse, porque ese escalón cuesta un módulo entero y una función por cada opción configurable. Adoptar el tercer nivel desde el principio es el error más frecuente de quien ha leído el código de una biblioteca pública y lo imita en una aplicación que no lo necesita, y produce mucha ceremonia para proteger invariantes que no existen.
Por qué esto sustituye al componente casi siempre
El motivo por el que la función de vista cubre tanto terreno no es que las interfaces sean sencillas, sino que la mayor parte de lo que parece estado local resulta ser, al examinarlo, estado derivado o estado del dominio mal colocado. Si un panel se ve desplegado cuando el elemento seleccionado coincide con el suyo, eso no es estado del panel: es una función de la selección, que pertenece al modelo. Si una fila se ve resaltada porque el ratón está encima, eso es estado, pero es estado de la pantalla, no de la fila, y vive perfectamente en el modelo como un identificador opcional. Cuando se hace ese ejercicio con honestidad, el número de fragmentos que necesitan memoria propia se reduce de forma drástica, y los que quedan suelen ser piezas complejas y bien delimitadas que merecen de verdad el tratamiento de la lección anterior.
-- Parece estado del panel, es una funcion de la seleccion
verPanel : Maybe Id -> Panel -> Html msg
verPanel seleccionado panel =
div [ classList [ ( "abierto", seleccionado == Just panel.id ) ] ]
[ verContenido panel ]
-- Es estado, pero de la pantalla, no de la fila
type alias Model =
{ filas : List Fila, bajoElPuntero : Maybe Id }
Queda un último argumento a favor de este enfoque que suele pasar inadvertido y que tiene que ver con las pruebas. Una función de vista sin memoria propia se comprueba como cualquier función: se la llama con unos datos y se examina el árbol que devuelve, sin montar entorno, sin simular un ciclo de vida, sin esperar a que se estabilice y sin manipular un documento. Esa facilidad no es solo comodidad, es lo que determina que las pruebas de la capa visual existan o no existan, porque una prueba que cuesta veinte minutos de infraestructura se pospone indefinidamente y una que cuesta tres líneas se escribe. La arquitectura no obliga a nadie a probar sus vistas, pero elimina la excusa más frecuente para no hacerlo.
flowchart TD D[Datos que hay que dibujar] --> V[Funcion de vista] C[Constructor de mensaje prestado] --> V V --> H[Arbol Html generico] H --> P[Pantalla que la usa] P --> M[Vocabulario propio de la pantalla] style V fill:#89b4fa,color:#11111b style H fill:#a6e3a1,color:#11111b style M fill:#cba6f7,color:#11111b
La razón profunda por la que este enfoque escala tiene poco que ver con Elm y mucho con una propiedad matemática que conviene enunciar con precisión: las funciones puras componen sin condiciones previas, y los objetos con estado no. Cuando compones dos funciones puras, el resultado es otra función pura y no hay nada más que verificar; el orden de aplicación está determinado, no hay interacción entre ellas por vías invisibles y el razonamiento sobre la composición se reduce al razonamiento sobre las partes. Cuando compones dos unidades con estado, en cambio, aparecen preguntas que no existían en las partes por separado: en qué orden se inicializan, qué ocurre si una se destruye mientras la otra la está leyendo, qué pasa si ambas reaccionan al mismo evento, cómo se sincronizan si comparten una fuente de verdad, qué sucede cuando una se reutiliza sin reinicializarse porque el algoritmo de reconciliación decidió que era la misma. Cada una de esas preguntas es una regla que alguien tuvo que diseñar, documentar y que tú tienes que recordar, y su número crece de forma no lineal con la cantidad de unidades que interactúan. Por eso una base de código construida sobre funciones de vista no se degrada al crecer del mismo modo que una construida sobre componentes: no es que tenga menos código, a menudo tiene una cantidad parecida, es que el coste de entender cualquier fragmento nuevo no aumenta con el tamaño del sistema, porque para leer una función basta con leer su firma y su cuerpo. Ese es el sentido preciso en que se dice que una arquitectura escala. No escala la que soporta más usuarios ni la que ejecuta más rápido, sino aquella en la que el coste de un cambio no depende de cuánto código haya alrededor.
- Toma una función de vista que reciba el modelo entero y reescribe su firma pidiendo solo los datos que dibuja; anota cuántos campos usaba de verdad.
- Convierte esa vista en genérica en el vocabulario de mensajes y comprueba que compila en dos pantallas con tipos de mensaje distintos.
- Escribe una vista interactiva que reciba el constructor de mensaje como parámetro y úsala desde dos pantallas sin recurrir a
Html.map. - Refactoriza una firma de cinco parámetros a un registro de configuración y compara la legibilidad de la llamada antes y después.
- Identifica dos opciones mutuamente excluyentes en esa configuración y protégelas con un tipo opaco y funciones modificadoras.
- Revisa tres fragmentos de tu aplicación que creas que necesitan estado local y demuestra, para al menos dos, que son estado derivado.