El Model como record: modelar el estado y decidir qué entra en él
En la Arquitectura Elm el estado entero de la aplicación es un único valor, y ese valor es casi siempre un record. Esta lección examina qué implica esa concentración y qué decisiones de diseño obliga a tomar. Se estudia el criterio para admitir un campo en el modelo, la distinción entre estado esencial y estado derivable, el coste de guardar lo que puede calcularse a partir de otra cosa y el riesgo de desincronización que introduce cada dato duplicado. Después se aborda el problema central de todo modelo hecho solo de campos: un record es un producto, y un producto admite todas las combinaciones de sus campos, incluidas las que el dominio prohíbe, de modo que un modelo plano representa estados imposibles con la misma naturalidad que los legítimos. La respuesta consiste en estrechar el modelo sustituyendo grupos de campos correlacionados por un custom type que enumere solo las situaciones válidas, y la lección desarrolla ese refactor con el caso canónico de la carga remota, junto con el criterio para decidir cuándo un campo pertenece al modelo y cuándo pertenece al servidor, a la URL o al DOM.
La Arquitectura Elm impone una condición que en cualquier otro ecosistema sonaría a restricción insoportable: todo el estado de la aplicación vive en un único valor. No hay estado local escondido en un componente, no hay variables de módulo que recuerden cosas entre llamadas, no hay caché invisible ni referencia mutable a la que alguien pueda escribir desde otro archivo. Si el programa necesita recordar algo, ese algo está en el modelo, y el modelo es un valor que puedes imprimir entero, comparar entero y guardar entero. Ese valor es, en la práctica totalidad de los casos, un record: la estructura que este nivel viene estudiando, ahora en su papel más importante. Y aquí es donde el record deja de ser una cuestión de sintaxis para convertirse en una cuestión de diseño, porque decidir qué campos tiene el modelo es decidir qué puede saber la aplicación, qué puede mostrar y, sobre todo, qué situaciones absurdas será capaz de representar sin que nadie se lo impida. Un modelo mal escogido no produce errores de compilación: produce una vida entera de comprobaciones defensivas escritas para desmentir estados que nunca deberían haber existido.
- Justificar por qué el estado de una aplicación Elm se concentra en un único valor y qué se gana con esa concentración.
- Distinguir el estado esencial del estado derivable y aplicar el criterio para no guardar lo que puede calcularse.
- Reconocer que un record es un producto y que por tanto admite combinaciones de campos que el dominio prohíbe.
- Estrechar un modelo sustituyendo campos correlacionados por un custom type que enumere solo las situaciones válidas.
Un solo valor para todo lo que la aplicación sabe
El modelo se declara como cualquier otro alias de record y suele acompañarse de una función que produce su valor inicial. Lo relevante no es la sintaxis, que ya conocemos, sino el compromiso que implica: cualquier cosa que la interfaz necesite mostrar tiene que poder deducirse de este valor, y cualquier cosa que el usuario cambie tiene que acabar reflejada aquí.
type alias Model =
{ textoBusqueda : String
, usuarios : List Usuario
, seleccionado : Maybe Int
, cargando : Bool
, error : Maybe String
}
init : () -> ( Model, Cmd Msg )
init _ =
( { textoBusqueda = ""
, usuarios = []
, seleccionado = Nothing
, cargando = False
, error = Nothing
}
, Cmd.none
)
Este modelo funciona, y es el que casi todo el mundo escribe la primera vez. También es, como veremos en la tercera sección, un modelo defectuoso por razones que no tienen nada que ver con la sintaxis. Antes de llegar ahí conviene fijar la ventaja de la concentración: como el estado es un solo valor, la pregunta cuál es el estado de la aplicación en este instante tiene una respuesta literal y completa. No hay que reunir fragmentos dispersos por veinte componentes ni preguntarse si alguno guarda una copia desactualizada, y por eso serializar el estado, restaurarlo o reproducir un fallo exacto son operaciones triviales.
Antes de añadir un campo conviene preguntarse dónde vive de verdad ese dato. La posición del cursor dentro de un campo de texto pertenece al DOM y el navegador ya la gestiona; la ruta actual pertenece a la URL y llega por una suscripción; el resultado íntegro de una consulta pertenece al servidor y el modelo solo necesita la parte que va a mostrar. Meterlo todo en el modelo por costumbre produce estados enormes y sincronizaciones frágiles. El criterio útil es que el modelo guarde lo que la aplicación decide, no lo que la aplicación observa.
Esencial contra derivable: no guardes lo que puedes calcular
La segunda decisión importante es distinguir entre los datos que constituyen el estado y los que son función de él. Si un valor puede obtenerse a partir de otros campos, guardarlo es crear una segunda fuente de verdad que hay que mantener sincronizada en cada actualización, y toda desincronización posible acabará ocurriendo.
-- Mal: total y visibles son derivables de usuarios y textoBusqueda
type alias ModelRedundante =
{ usuarios : List Usuario
, textoBusqueda : String
, total : Int
, visibles : List Usuario
}
-- Bien: solo lo esencial en el modelo, lo demas se calcula en view
type alias ModelMinimo =
{ usuarios : List Usuario
, textoBusqueda : String
}
visibles : ModelMinimo -> List Usuario
visibles m =
List.filter (\u -> String.contains m.textoBusqueda u.nombre) m.usuarios
total : ModelMinimo -> Int
total m =
List.length m.usuarios
La versión redundante obliga a recalcular dos campos en cada rama de update que toque la lista o el texto, y basta con olvidar uno para que la interfaz mienta. La versión mínima no puede desincronizarse porque no hay nada que sincronizar: la función se evalúa cuando se necesita y siempre refleja el estado actual. La excepción legítima aparece cuando el cálculo es genuinamente costoso y se repite en cada fotograma, pero esa excepción hay que ganársela con una medición, no suponerla.
flowchart TD M[Model esencial] --> D1[Funcion visibles] M --> D2[Funcion total] D1 --> V[View] D2 --> V R[Campo derivado guardado] --> S[Riesgo de desincronizacion] style M fill:#a6e3a1,color:#11111b style S fill:#f38ba8,color:#11111b style V fill:#89b4fa,color:#11111b
Una sola fuente
Todo lo que la aplicación recuerda está en un valor. No hay copias dispersas ni fragmentos que puedan discrepar.
Derivar, no duplicar
Si un dato se puede calcular desde otro, se calcula. Guardarlo crea una segunda verdad que habrá que mantener a mano.
El producto es ancho
Un record admite todas las combinaciones de sus campos. Muchas de ellas no significan nada en el dominio.
Estrechar con tipos
Sustituir campos correlacionados por un custom type elimina de raíz los estados que nunca deberían existir.
Cuando el producto miente: estrechar el modelo
Volvamos al primer modelo de la lección. Tiene un booleano de carga, una lista de usuarios y un error opcional, tres campos que en apariencia describen tres aspectos independientes. No lo son en absoluto, y el número de combinaciones lo delata: si multiplicamos las posibilidades de cada campo obtenemos un espacio enorme de estados, de los cuales apenas cuatro tienen sentido. Cargando y con error a la vez no significa nada. Con lista llena y cargando activo tampoco. El record, por ser un producto, permite todas.
-- Los tres campos correlacionados desaparecen en un solo tipo
type EstadoUsuarios
= NoSolicitado
| Cargando
| Fallo String
| Cargados (List Usuario)
type alias Model =
{ textoBusqueda : String
, usuarios : EstadoUsuarios
, seleccionado : Maybe Int
}
-- La vista ya no puede olvidarse de un caso: el compilador lo exige
verLista : Model -> Html Msg
verLista m =
case m.usuarios of
NoSolicitado ->
text "Escribe algo para buscar"
Cargando ->
text "Cargando"
Fallo motivo ->
text ("Ha fallado: " ++ motivo)
Cargados lista ->
ul [] (List.map verUsuario lista)
El cambio es pequeño en líneas y enorme en garantías. Han desaparecido tres campos y con ellos todas sus combinaciones ilegales; ha desaparecido la necesidad de comprobar el booleano antes de mirar el error; y ha aparecido una obligación nueva que resulta ser un regalo, porque el case of exhaustivo fuerza a la vista a decidir qué mostrar en cada situación real. El criterio general que se extrae de aquí es el que gobierna todo el diseño de modelos en Elm: cuando dos o más campos solo tienen sentido en ciertas combinaciones, esos campos son en realidad un solo dato con varias formas, y ese dato pide un custom type.
Un ejercicio de diez segundos evita semanas de defensa: multiplica las posibilidades de cada campo del modelo y compara ese número con el de situaciones que el dominio admite. Si la distancia es grande, el modelo está permitiendo absurdos y la vista tendrá que desmentirlos con ramas defensivas que el compilador no puede verificar. Cada refactor que estrecha esa distancia elimina código de comprobación y, con él, la posibilidad de escribirlo mal.
La tentación al diseñar un modelo es hacerlo a imagen de la interfaz, con un campo por cada cosa visible y un booleano por cada cosa que aparece y desaparece. Ese método es rápido, produce siempre modelos que funcionan al principio, y es la causa más frecuente de que una aplicación se vuelva insostenible a los seis meses. La razón es que la pantalla es una proyección del estado, no su estructura, y confundir la proyección con el original invierte la dirección de la dependencia: en lugar de que la vista se derive del modelo, el modelo empieza a deformarse cada vez que un diseñador mueve un panel. La disciplina correcta consiste en modelar lo que es verdad sobre el dominio con independencia de cómo se dibuje, y luego dejar que la vista se las arregle. Esa disciplina tiene tres consecuencias prácticas que conviene enunciar por separado. La primera es que el modelo debe contener el estado esencial y nada más, porque cada campo derivado que se guarda es un contrato implícito que obliga a recalcularlo en todas las ramas de la actualización, y ningún compilador vigila esos contratos. La segunda es que la anchura del producto es un peligro real y medible: un record de cinco campos con tres booleanos representa docenas de configuraciones, y si el dominio solo admite cuatro, entonces el modelo está diciendo que existen decenas de mundos que no existen, y todo el código que lo consuma tendrá que dedicarse a demostrar que efectivamente no existen, una y otra vez, sin ayuda. La tercera, y la más difícil de aceptar para quien viene de otro paradigma, es que la respuesta a un modelo demasiado ancho nunca es más validación sino menos posibilidades: no se añaden comprobaciones para detectar el estado imposible, se reescribe el tipo para que el estado imposible no se pueda ni siquiera escribir. Ahí está la diferencia entre programar defensivamente y programar con tipos. El programador defensivo acepta que el estado puede ser incoherente y se protege; el programador que modela hace que la incoherencia no compile, y entonces no tiene de qué protegerse. El modelo de una aplicación Elm bien diseñada se lee, por eso, como una definición del dominio y no como un inventario de widgets, y la prueba de que se ha hecho bien es que la vista resulta aburrida: cada caso del modelo tiene su dibujo, no hay ninguna comprobación que no venga impuesta por un tipo, y añadir una situación nueva al dominio rompe la compilación exactamente en los sitios donde había que decidir algo.
- Escribe el modelo de una pantalla de búsqueda con campos sueltos y cuenta cuántas combinaciones de estado permite.
- Enumera las situaciones que el dominio admite de verdad y calcula la distancia respecto al número anterior.
- Sustituye los campos correlacionados por un custom type y comprueba qué ramas de la vista deja de ser necesario escribir.
- Localiza en tu modelo un campo derivable, elimínalo y conviértelo en una función que reciba el modelo.
- Provoca a propósito una desincronización con un campo derivado guardado y explica por qué el compilador no puede detectarla.
- Identifica un dato de tu modelo que en realidad pertenezca a la URL, al DOM o al servidor, y razona qué se simplifica al sacarlo.