La vista es pura: una función de Model a Html
La firma de la vista en Elm cabe en una línea y esa línea decide casi todo lo demás: recibe el modelo, devuelve un árbol y no puede hacer nada más. Esta lección desarrolla las consecuencias de esa restricción. Primero examina qué significa exactamente que la vista sea total, determinista y sin estado propio, y por qué eso elimina de golpe la categoría entera de bugs que nacen del estado escondido dentro de la interfaz. Después muestra que no hace falta ninguna directiva de repetición ni de condición porque el lenguaje ya tiene funciones de orden superior y expresiones que devuelven valores, de modo que renderizar una lista es aplicar una transformación y decidir entre dos ramas es evaluar una expresión. Por último introduce la disciplina de la vista derivada: la regla de no guardar en el modelo aquello que se puede calcular desde él, y el criterio para decidir cuándo esa regla se rompe a conciencia. Al final la vista deja de ser una capa con vida propia y se convierte en lo que su tipo ya anunciaba: una proyección del estado, reconstruible en cualquier momento a partir de él.
En casi todas las tecnologías de interfaz que se usan hoy, la vista es un lugar donde pasan cosas. Un componente guarda estado privado que nadie más ve, se suscribe a fuentes externas durante su ciclo de vida, arranca peticiones cuando se monta, escribe en variables compartidas al desmontarse y decide por su cuenta cuándo volver a dibujarse. Eso convierte la interfaz en un segundo programa que corre en paralelo al primero, con su propia memoria y su propio calendario, y explica por qué tantos fallos de aplicación tienen la forma incómoda de en mi máquina no pasa: el estado que produjo el fallo no estaba en ningún sitio inspeccionable, vivía repartido entre docenas de nodos del árbol. La firma de la vista en Elm cancela esa posibilidad de un plumazo. La vista recibe el modelo completo, devuelve un árbol de nodos y no tiene ningún otro poder: no puede recordar nada entre dos llamadas, no puede lanzar un efecto, no puede consultar el reloj ni la red, no puede depender de en qué momento se la invoca. Es una función pura en el sentido más estricto y más productivo del término, y esta lección trata de todo lo que se sigue de esa austeridad.
- Enunciar con precisión qué garantiza la firma de la vista y qué clase de fallos vuelve imposibles.
- Renderizar colecciones con
List.mapy variantes, sin ninguna directiva de repetición. - Expresar condiciones con
ifycaseaprovechando que en Elm ambos son expresiones que devuelven un valor. - Aplicar la disciplina de la vista derivada y decidir con criterio cuándo conviene romperla.
Una función total, determinista y sin memoria
La firma canónica dice que la vista transforma un Model en un Html Msg, y conviene leerla como se leería cualquier otro contrato de función en el nivel de tipos. Es total, porque debe producir un árbol para todo modelo posible, incluido el que aún no ha cargado nada y el que acaba de fallar. Es determinista, porque dos llamadas con el mismo modelo producen el mismo árbol; no hay ninguna vía por la que un contador oculto, una marca de tiempo o un valor aleatorio puedan colarse dentro. Y no tiene memoria, porque no existe un lugar donde guardarla: entre dos invocaciones la función no conserva absolutamente nada.
-- El contrato completo de la capa de presentacion
view : Model -> Html Msg
-- Todo lo que la vista sabe entra por el parametro
view model =
div [ class "app" ]
[ cabecera model.usuario
, cuerpo model
, pie model.version
]
Esa combinación tiene un efecto que solo se aprecia cuando se ha depurado interfaces durante años: si la pantalla muestra algo raro, el estado que lo produjo está entero en el modelo, y solo ahí. No hay que buscarlo en el árbol, ni reconstruirlo a partir de una secuencia de interacciones, ni sospechar que un nodo intermedio recuerde algo de la vez anterior. Reproducir un fallo visual se reduce a reproducir un valor, y un valor se serializa, se guarda en un informe de error y se vuelve a inyectar más tarde. La vista pura convierte la depuración de la interfaz en la inspección de un dato.
Proyección, no copia
La vista no almacena una versión del estado: lo proyecta. Si el modelo cambia, la proyección cambia sin que nadie tenga que sincronizar nada.
Determinismo
Mismo modelo, mismo árbol. La ausencia de fuentes ocultas es lo que permite comparar dos renderizados y confiar en la comparación.
Sin ciclo de vida
No hay montaje ni desmontaje que ejecutar. Todo efecto vive en update como un Cmd, y la vista queda libre de calendario propio.
Depuración por valor
Un fallo visual se reproduce reproduciendo un modelo. El estado que lo causó siempre está en un único sitio inspeccionable.
Repetir y condicionar con el lenguaje que ya tienes
Cuando la vista es una expresión ordinaria, las dos construcciones que todo sistema de plantillas se ve obligado a inventar dejan de hacer falta. Renderizar una colección es transformar una lista de datos en una lista de nodos, es decir, exactamente lo que hace List.map; el resultado se pasa como segundo argumento del elemento contenedor porque ese argumento ya era una lista de hijos. No hay atributo especial de repetición, no hay variable implícita de iteración, no hay que recordar cómo se accede al índice: hay una función que aplica otra función a cada elemento, con las mismas reglas que en cualquier otro punto del programa.
-- Repetir es transformar una lista en otra
listaTareas : List Tarea -> Html Msg
listaTareas tareas =
ul [ class "lista" ] (List.map fila tareas)
fila : Tarea -> Html Msg
fila tarea =
li [ classList [ ( "hecha", tarea.hecha ) ] ]
[ text tarea.texto ]
-- Con indice, cuando la posicion importa
listaNumerada : List String -> Html Msg
listaNumerada items =
ol [] (List.indexedMap conNumero items)
conNumero : Int -> String -> Html Msg
conNumero indice texto =
li [] [ text (String.fromInt (indice + 1) ++ ". " ++ texto) ]
Condicionar funciona por el mismo motivo. En Elm tanto if como case son expresiones, no sentencias: siempre devuelven un valor, siempre tienen todas sus ramas, y ese valor puede ser un nodo igual que podría ser un número. Se colocan donde iría cualquier hijo. Cuando una rama no debe dibujar nada, se usa un nodo de texto vacío, que el motor de renderizado trata como un hueco sin coste; y cuando el número de casos crece, un case sobre un tipo unión obliga a cubrirlos todos y convierte cada estado del modelo en una pantalla explícita.
-- if es una expresion: sirve como hijo directo
resumen : List Tarea -> Html Msg
resumen tareas =
if List.isEmpty tareas then
p [ class "vacio" ] [ text "No hay nada pendiente" ]
else
p [] [ text (String.fromInt (List.length tareas)) ]
-- case obliga a dibujar todos los estados posibles
cuerpo : Model -> Html Msg
cuerpo model =
case model.peticion of
Cargando ->
spinner
Fallo error ->
aviso (descripcionDe error)
Listo datos ->
tabla datos
Hay un beneficio silencioso en que la vista despache un tipo unión con case. En las tecnologías donde el estado de carga se representa con dos o tres banderas independientes, olvidar una rama no produce ningún aviso y la pantalla simplemente se queda en blanco en una combinación poco frecuente. Aquí no se puede olvidar: si el modelo declara cuatro estados posibles, el compilador exige cuatro ramas y cada una tiene que devolver un árbol. La pantalla vacía inesperada, que es uno de los defectos más denunciados por los usuarios y más difíciles de reproducir, desaparece por construcción antes de que nadie la escriba.
La vista derivada y el modelo mínimo
La consecuencia de diseño más importante llega después. Como la vista puede calcular cualquier cosa a partir del modelo y se vuelve a evaluar entera cuando el modelo cambia, no hay ninguna razón para guardar en el modelo un dato que sea función de otros datos del modelo. El total de una lista, el subconjunto que pasa un filtro, la validez de un formulario, la etiqueta que corresponde a un estado: todo eso se deriva en el momento de dibujar. Guardar el derivado significa asumir la obligación de mantenerlo sincronizado en cada rama de update, y esa obligación se incumple tarde o temprano; el modelo mínimo es aquel del que no se puede eliminar ningún campo sin perder información genuina.
-- El modelo guarda hechos; la vista calcula consecuencias
type alias Model =
{ tareas : List Tarea, filtro : Filtro }
visibles : Filtro -> List Tarea -> List Tarea
visibles filtro tareas =
case filtro of
Todas ->
tareas
Pendientes ->
List.filter (not << .hecha) tareas
Hechas ->
List.filter .hecha tareas
view : Model -> Html Msg
view model =
listaTareas (visibles model.filtro model.tareas)
flowchart LR M[Model minimo] --> D[Funciones derivadas] D --> V[view] V --> A[Arbol Html Msg] A --> R[Runtime] R --> P[Pantalla] style M fill:#89b4fa,color:#11111b style V fill:#a6e3a1,color:#11111b style P fill:#cba6f7,color:#11111b
La regla tiene una excepción legítima y conviene nombrarla para que la disciplina no se vuelva dogma. Cuando el cálculo derivado es caro y se repite en cada dibujado, memorizar el resultado dentro del modelo es una decisión de rendimiento defendible, siempre que se tome a conciencia, se documente y se concentre la responsabilidad de recalcularlo en un único punto. Pero la excepción se paga con la posibilidad de incoherencia, así que el orden correcto es empezar por el modelo mínimo, medir, y solo entonces admitir el campo redundante si la medición lo justifica. En el siguiente escalón veremos además que Html.lazy permite evitar buena parte de ese coste sin ensuciar el modelo.
Detrás de la firma que transforma el modelo en un árbol hay un cambio de categoría que merece enunciarse sin metáforas: la interfaz deja de ser un sistema que evoluciona en el tiempo y pasa a ser una consulta sobre un valor. En el modelo dominante, un componente es un pequeño organismo con biografía propia. Nace, se le notifica que ha nacido, retiene recuerdos que nadie más puede leer, reacciona a estímulos que llegan por caminos distintos del flujo principal, y muere ejecutando ritos de limpieza que hay que acordarse de escribir. Razonar sobre una pantalla exige entonces razonar sobre la historia conjunta de decenas de esos organismos, y como esa historia no está escrita en ningún sitio, la única herramienta que queda es reproducir la secuencia exacta de interacciones que llevó al fallo. De ahí nace el género literario del informe de error imposible: pasos que no se pueden repetir porque el estado relevante nunca fue un dato. La vista pura elimina la biografía. No hay nada que nazca ni que muera, porque no hay nada que persista: cada dibujado es una evaluación completa desde el único estado que existe, y la pantalla es su resultado, no un sedimento acumulado. Eso tiene tres efectos encadenados. El primero es epistemológico: para explicar lo que se ve basta con leer un valor, nunca una historia. El segundo es metodológico: probar la interfaz se reduce a comparar el árbol que devuelve una función con el esperado, sin montar nada, sin simular ciclos de vida y sin esperar a que se estabilice. El tercero es arquitectónico y es el más profundo: si la vista no puede guardar estado, todo el estado tiene que estar en el modelo, y si todo el estado está en el modelo se puede serializar, viajar en el tiempo, adjuntarse a un informe y reinyectarse tal cual. Las herramientas de depuración con viaje en el tiempo que otras tecnologías persiguen con infraestructura considerable aquí no son una hazaña de ingeniería, son un corolario de haberle negado memoria a una función.
- Escribe la firma de la vista de memoria y enumera las tres propiedades que garantiza; para cada una, nombra un fallo real que vuelve imposible.
- Renderiza una lista con
List.mapy después conList.indexedMap, y explica por qué el resultado encaja sin más en el segundo argumento del contenedor. - Sustituye tres banderas booleanas de carga por un tipo unión de cuatro estados y comprueba qué te obliga a escribir el compilador en la vista.
- Localiza en un modelo tuyo un campo que sea función de otros campos, elimínalo, deriva su valor en la vista y cuenta cuántas ramas de
updatese simplifican. - Escribe una condición que en una rama no dibuje nada y razona por qué un nodo de texto vacío es preferible a devolver una lista vacía de hijos.
- Defiende, con una medición concreta y no con una intuición, el único caso de tu aplicación en el que guardarías un valor derivado dentro del modelo.