wandres.dev
ROUTING Y SPA · Browser.application

La ruta dentro del Model: el estado de cada página y su nacimiento

Guardar la ruta en el modelo es el primer paso obvio de una aplicación de una sola página, y también el que más aplicaciones dejan a medias. Esta lección desarrolla la distinción que decide la calidad de todo el diseño posterior: la ruta identifica una pantalla pero no es su estado, de modo que un modelo que solo guarda la ruta acaba acumulando en el mismo nivel los campos de todas las pantallas, con la consecuencia de que cualquier combinación de campos es representable aunque casi ninguna tenga sentido. La alternativa es un tipo Page cuyos constructores llevan dentro el estado propio de cada pantalla, con lo cual el modelo global se reduce a la clave de navegación y a la página actual. A partir de ahí se examina cómo se entra en una ruta: una función que va de Route a un par de página y comando, invocada desde init y desde el mensaje de cambio de URL, que construye el estado inicial y dispara la carga de datos; cómo se representan la carga y el fallo dentro de cada página en lugar de con banderas globales; y cómo se delegan mensajes y vistas hacia el módulo de cada pantalla con Cmd.map y Html.map sin fusionar vocabularios. La conclusión es que la URL no es una variable del programa sino la fuente de verdad desde la que el estado se reconstruye.

⏱ 18 min

Cuando una aplicación de una sola página empieza a crecer, el punto donde se decide si envejecerá bien o mal casi nunca es el enrutador: es la forma del modelo. La primera versión siempre es la misma y siempre parece razonable. Se guarda la ruta actual en un campo, y como la pantalla de artículos necesita una lista, se añade un campo para la lista; como la de detalle necesita un artículo, se añade otro; como el formulario necesita un borrador, otro más; y como todo eso se carga de forma asíncrona, se añaden banderas de carga y campos de error. Al cabo de unos meses el modelo tiene treinta campos de los cuales cinco tienen sentido a la vez, y nadie sabe ya cuáles porque la relación entre la ruta y los campos vivos no está escrita en ninguna parte: vive en la cabeza de quien lo escribió y en el orden en que se asignan los valores. El problema no es el desorden, es que el tipo miente. Declara un espacio de estados enormemente mayor que el de los estados legítimos, y todo lo que sobra es superficie donde se alojan los fallos. La corrección es la misma que ya conoces de los niveles de modelado, aplicada aquí: hacer que los estados inválidos no se puedan representar, con la ruta como índice y no como contenido.

🎯 Al terminar esta lección sabrás
  • Distinguir la ruta, que identifica una pantalla, del estado de esa pantalla, y explicar por qué el modelo plano vuelve representables estados imposibles.
  • Diseñar un tipo de página cuyos constructores contengan el estado propio de cada pantalla y reducir el modelo global a lo verdaderamente compartido.
  • Escribir la función de entrada en una ruta que construye el estado inicial y devuelve el comando de carga, y usarla desde el arranque y desde el cambio de URL.
  • Delegar mensajes y vistas hacia cada página con Cmd.map y Html.map sin fusionar vocabularios de mensajes.

La ruta identifica la pantalla; no es la pantalla

Conviene fijar la distinción con precisión porque de ella se sigue todo lo demás. Un Route es lo que la dirección aporta: qué pantalla y con qué identificadores. Es un dato pequeño, comparable, serializable y que siempre existe. El estado de una pantalla es otra cosa: incluye lo que se ha cargado, lo que está cargando, lo que ha fallado, lo que el usuario lleva escrito en un formulario y lo que tiene desplegado. Es un dato grande, distinto en cada pantalla, y que nace cuando se entra y muere cuando se sale. Confundirlos lleva al modelo plano; separarlos lleva a un modelo donde el estado vive dentro del constructor de la página a la que pertenece.

-- Modelo plano: casi ninguna combinacion es legitima
type alias ModelPlano =
    { ruta : Route
    , articulos : List Articulo
    , cargando : Bool
    , error : Maybe String
    , articuloActual : Maybe Articulo
    , borrador : String
    }


-- Modelo indexado: el estado vive dentro de su pantalla
type alias Model =
    { key : Nav.Key
    , sesion : Sesion
    , page : Page
    }


type Page
    = Inicio
    | Articulos (Estado (List Articulo))
    | Detalle Int (Estado Articulo)
    | Editor Borrador
    | NoEncontrado


type Estado a
    = Cargando
    | Cargado a
    | Fallo Error

Compara los dos espacios de estados. El modelo plano admite que haya un artículo actual y a la vez un borrador y a la vez un error, sin que ninguna pantalla lo pida; admite también estar cargando y tener error a la vez, y tener la lista llena mientras la bandera de carga sigue activa. Ninguno de esos estados significa nada, pero todos son construibles y por tanto todos hay que considerarlos al leer la vista. El modelo indexado no admite ninguno: si la página es de detalle, no existe ningún borrador porque no hay dónde ponerlo, y la carga y el fallo son constructores del mismo tipo y por tanto mutuamente excluyentes por construcción.

🗝️

Lo compartido, arriba

La clave de navegación y la sesión sobreviven a todas las pantallas, así que viven en el modelo global. Todo lo demás es de alguien.

📦

Lo propio, dentro

El estado de una pantalla va en su constructor. No se puede leer desde otra pantalla y no sobrevive a la salida, que es exactamente lo que se quiere.

🚦

Carga como constructor

Cargando, Cargado y Fallo son un union type, no tres campos. Estar cargando y tener error a la vez deja de ser representable.

🧹

Salir es olvidar

Al cambiar de página se construye un estado nuevo. El estado viejo desaparece con el constructor, sin limpieza manual ni campos que resetear.

Entrar en una ruta es construir su estado

Con el modelo así, aparece de forma natural la función central de toda aplicación de una sola página: la que convierte un Route en la página correspondiente junto con el comando que carga lo que esa página necesita. Es una función pequeña, un case sobre la ruta, y tiene la virtud de reunir en un solo sitio la respuesta a la pregunta qué ocurre al entrar aquí. No hay que buscarla repartida entre manejadores de eventos ni ciclos de vida de componentes: está escrita entera, y el compilador obliga a completarla cada vez que se añade una ruta nueva.

entrar : Route -> Model -> ( Model, Cmd Msg )
entrar ruta model =
    case ruta of
        Route.Inicio ->
            ( { model | page = Inicio }, Cmd.none )

        Route.Articulos ->
            ( { model | page = Articulos Cargando }
            , Api.pedirArticulos RecibidosArticulos
            )

        Route.Articulo id ->
            ( { model | page = Detalle id Cargando }
            , Api.pedirArticulo id RecibidoArticulo
            )

        Route.NoEncontrado ->
            ( { model | page = NoEncontrado }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        CambioDeUrl url ->
            entrar (Route.parse url) model

        _ ->
            ( model, Cmd.none )
ℹ️
El mismo camino para arrancar y para navegar

Fíjate en que init puede llamar a esta misma función con la URL inicial, y que el mensaje de cambio de dirección hace lo propio. Que ambos caminos converjan no es una economía de líneas sino una garantía: significa que entrar en una pantalla escribiendo su dirección desde cero y llegar a ella navegando desde otra producen exactamente el mismo estado, porque literalmente ejecutan el mismo código. La clase de fallos donde una pantalla funciona al navegar pero se rompe al recargar —endémica en aplicaciones que inicializan por efectos secundarios repartidos— deja de ser posible, no por disciplina sino porque no hay dos caminos que puedan divergir.

Un matiz que ahorra disgustos: el estado inicial de una pantalla que carga datos debe ser Cargando, nunca una lista vacía. La diferencia parece cosmética y no lo es, porque la lista vacía significa que se consultó y no había nada, mientras que la carga significa que aún no se sabe, y la interfaz que corresponde a cada una es distinta. Confundirlas produce el parpadeo del mensaje de no hay resultados justo antes de que aparezcan los resultados, un defecto que delata que el modelo no distinguía dos situaciones que sí eran distintas.

flowchart TD
A[Cambio de Url] --> B[Route parse]
I[Init con la url inicial] --> B
B --> E[Funcion entrar]
E --> P[Page con estado Cargando]
E --> C[Cmd de carga]
C --> R[Mensaje con la respuesta]
R --> Q[Page con estado Cargado o Fallo]
P --> V[Vista de la pagina]
Q --> V
style E fill:#89b4fa,color:#11111b
style P fill:#f9e2af,color:#11111b
style Q fill:#a6e3a1,color:#11111b

Delegar sin fusionar

Cuando una pantalla crece hasta merecer su propio módulo, la delegación sigue el mismo patrón que ya conoces de la composición de la arquitectura: el módulo declara su Model, su Msg, su update y su view, y el programa principal envuelve. El constructor de la página guarda el modelo del submódulo, un constructor del mensaje principal envuelve los mensajes del submódulo, y en el punto de conexión se traducen las salidas con Cmd.map y las vistas con Html.map. Lo importante es que los vocabularios no se fusionan: el módulo de la pantalla nunca conoce el tipo de mensajes del programa, y por eso puede leerse, probarse y modificarse sin salir de su fichero.

update msg model =
    case ( msg, model.page ) of
        ( MsgEditor sub, Editor estadoEditor ) ->
            let
                ( nuevoEstado, cmdEditor ) =
                    Editor.update sub estadoEditor
            in
            ( { model | page = Editor nuevoEstado }
            , Cmd.map MsgEditor cmdEditor
            )

        ( MsgEditor _, _ ) ->
            ( model, Cmd.none )

        _ ->
            ( model, Cmd.none )
⚠️
El mensaje que llega tarde

El case sobre el par de mensaje y página no es una formalidad. Una petición lanzada al entrar en una pantalla puede responder cuando el usuario ya se ha ido a otra, y ese mensaje llegará igualmente porque el runtime no cancela nada por haber cambiado de vista. Si la rama solo mirase el mensaje e ignorase la página actual, ese resultado tardío reconstruiría una pantalla que el usuario abandonó, y vería aparecer de golpe un contenido que ya no había pedido. Al exigir que coincidan mensaje y página, el estado obsoleto se descarta sin ceremonia. Es una de esas situaciones donde la exhaustividad del case no solo obliga a considerar el caso raro, sino que lo pone delante de los ojos antes de que ocurra en producción.

La URL es la fuente de verdad y el estado de pantalla es caché derivada de ella

El giro conceptual que este diseño consuma, y que conviene enunciar porque casi nunca se dice en voz alta, es que la dirección deja de ser una consecuencia de la navegación para convertirse en su causa. En el modelo mental heredado de las interfaces de escritorio, el estado es lo primario: el usuario actúa, el estado cambia y, si acaso, se refleja en algún sitio para que se pueda compartir. Ese orden produce aplicaciones donde la barra de direcciones es un adorno desincronizado, donde recargar pierde la mitad del contexto y donde el botón de retroceso deshace algo que no se corresponde con lo último que el usuario hizo. Invertirlo cambia la disciplina entera: la URL es la única entrada que determina qué pantalla existe, y todo el estado de esa pantalla es material derivado que puede tirarse y reconstruirse sin pérdida de información. Una aplicación construida así satisface por diseño una propiedad muy fuerte y muy fácil de comprobar: para cualquier estado que un usuario pueda alcanzar, existe una dirección que lo reproduce desde cero en otra máquina, en otra sesión y sin haber hecho el camino. De ahí se sigue casi todo lo que distingue una aplicación web que se siente bien de una que no. El enlace profundo funciona porque no hay nada que restaurar aparte de lo que la dirección ya dice. La recarga no duele porque el arranque y la navegación ejecutan el mismo código de entrada. El botón de retroceso es exacto porque la pila del historial es una pila de rutas y las rutas determinan pantallas. Compartir es trivial porque la dirección contiene la identidad completa del estado. Y hay una consecuencia arquitectónica de fondo: cuando el estado de pantalla es caché derivada, deja de haber tentación de guardarlo, sincronizarlo o migrarlo entre vistas, y desaparecen las fugas donde una pantalla lee lo que otra dejó a medias. El precio, y hay que decirlo, es una decisión de diseño más en cada pantalla —qué parte de su estado merece estar en la dirección y qué parte es efímera—, pero es una decisión que conviene tomar con la cabeza fría al principio y no descubrirla como un fallo seis meses después.

⚔️ Indexa el estado por la pantalla que lo posee
  1. Toma un modelo plano de una aplicación real y cuenta cuántas combinaciones de sus campos son representables frente a cuántas tienen sentido.
  2. Rediséñalo con un tipo de página cuyos constructores contengan el estado propio, y señala qué estados inválidos acaban de dejar de existir.
  3. Escribe la función de entrada en una ruta y llámala desde el arranque y desde el cambio de dirección; comprueba que recargar una pantalla profunda produce el mismo estado que llegar navegando.
  4. Sustituye una bandera de carga y un campo de error por un union type de tres constructores, y observa qué ramas de la vista desaparecen.
  5. Provoca deliberadamente una respuesta tardía cambiando de pantalla antes de que llegue, y comprueba qué ocurre con y sin el case sobre el par de mensaje y página.
  6. Decide, para una pantalla con filtros y desplazamiento, qué parte de su estado pertenece a la dirección y qué parte es efímera; defiende el corte que has elegido.