Navegación programática: pushUrl, replaceUrl y la disciplina del historial
No toda navegación nace de un clic. Después de guardar un formulario hay que llevar al usuario a la ficha creada, tras iniciar sesión hay que devolverlo a donde iba, y cuando escribe en un buscador conviene que la dirección refleje lo que está viendo. Elm resuelve esos casos con dos comandos que solo difieren en una cosa —si añaden una entrada al historial o sustituyen la actual— y esa diferencia mínima determina por completo qué hará el botón de retroceso. Esta lección desarrolla la navegación programática como efecto: por qué requiere la clave que solo un Browser.application entrega y qué garantiza esa exigencia, por qué el comando no cambia el modelo sino que provoca el mensaje de cambio de URL que sí lo cambiará, cuándo corresponde empujar y cuándo reemplazar con criterios aplicables, por qué las redirecciones y los pasos intermedios de un formulario piden replaceUrl si no se quiere construir una trampa de retroceso, qué papel juegan Nav.back, Nav.forward, Nav.load y Nav.reload, y cómo se prueba un diseño de navegación pulsando atrás repetidamente. El principio que se destila es que el historial es una estructura de datos compartida con el usuario y que cada entrada debe corresponder a un estado que él reconozca como un paso propio.
El botón de retroceso es probablemente el control más usado de toda la informática de consumo y, con casi total seguridad, el menos considerado en las decisiones de diseño de las aplicaciones que lo pueblan. Nadie escribe una especificación para él, nadie lo prueba de forma sistemática y sin embargo cualquier usuario detecta en tres segundos que una aplicación lo ha estropeado: pulsa atrás esperando volver a la lista y vuelve al formulario que acaba de enviar, o pulsa atrás quince veces porque cada tecla que escribió en un buscador dejó una entrada, o pulsa atrás y reaparece la pantalla de inicio de sesión que ya superó. Ninguno de esos defectos es un fallo de programación en el sentido habitual: son consecuencias directas de haber elegido mal entre dos comandos que se parecen tanto que invitan a no pensar. pushUrl añade una entrada al historial y replaceUrl sustituye la actual, y esa es toda la diferencia. Esta lección trata de por qué esa diferencia mínima es una de las decisiones de diseño con más impacto perceptible por unidad de código de toda la aplicación, y de cómo elegir sin dudar en cada caso concreto.
- Navegar desde
updateemitiendo comandos y explicar por qué la navegación es un efecto y no una modificación del modelo. - Justificar por qué esos comandos exigen la clave que solo
Browser.applicationentrega y qué garantiza esa exigencia. - Elegir entre empujar y reemplazar con un criterio explícito, y aplicarlo a redirecciones, formularios y buscadores.
- Auditar un diseño de navegación pulsando el botón de retroceso y detectar trampas de historial antes de que lleguen al usuario.
Navegar es emitir un comando, no asignar un campo
La navegación programática entra en la arquitectura por donde entra todo lo demás: update devuelve un comando y el runtime lo ejecuta. Los dos comandos que interesan reciben la clave de navegación y una dirección en forma de texto, y su efecto es alterar el historial del navegador. Nada más. En particular, no tocan el modelo, y esa es la parte que conviene fijar bien: emitir Nav.pushUrl no cambia la ruta guardada. Lo que ocurre es que el historial cambia, el runtime detecta el cambio, construye el mensaje que declaraste en onUrlChange y lo entrega a update, que entonces sí actualiza el estado. El ciclo pasa por fuera y vuelve a entrar, exactamente igual que el de un clic en un enlace.
Nav.pushUrl : Nav.Key -> String -> Cmd msg
Nav.replaceUrl : Nav.Key -> String -> Cmd msg
update msg model =
case msg of
ArticuloGuardado (Ok articulo) ->
( model
, Nav.pushUrl model.key (Route.toString (Route.Articulo articulo.id))
)
BusquedaEscrita texto ->
( { model | page = Buscador texto }
, Nav.replaceUrl model.key (Route.toString (Route.Buscar texto))
)
De que el ciclo pase por fuera se sigue una consecuencia práctica que ahorra depuraciones desconcertantes: si emites el comando y además actualizas la ruta del modelo en la misma rama, estás escribiendo el estado dos veces, una a mano y otra cuando llegue el mensaje de cambio, y la primera es siempre la que puede equivocarse. Deja que el modelo lo actualice únicamente el manejador de cambio de dirección y trata los dos comandos como lo que son: una petición al navegador para que modifique su historial, cuyo eco volverá por el camino normal.
Que la firma exija una Nav.Key no es un capricho de la biblioteca. Esa clave es un valor opaco que solo se obtiene como argumento de init en un Browser.application, no se puede construir, no se puede falsificar y no se puede leer de ningún sitio global. La consecuencia es estructural: ninguna función de un programa que no se haya declarado responsable de la navegación puede alterar el historial, y en un programa que sí lo es, la clave tiene que haberse guardado explícitamente en el modelo y pasarse hasta el punto donde se usa. Es la misma idea de capacidad que gobierna los cuatro constructores, llevada al nivel de una función concreta.
Los dos comandos reciben texto, y ese texto es el único punto de la aplicación donde una dirección vuelve a ser una cadena. Escríbelo siempre a través de la función que va de Route a texto en tu módulo de rutas, jamás concatenando fragmentos en el sitio de la llamada. La razón es de simetría: si la entrada de direcciones está tipada por el parser y la salida no lo está, cualquier cambio en la forma de una ruta rompe la salida en silencio y el fallo aparece como una pantalla de página no encontrada sin ninguna pista sobre su origen. Con las dos funciones enfrentadas en el mismo módulo, un cambio en el parser deja de compilar el constructor de enlaces y el compilador te lleva de la mano por todos los sitios afectados.
El historial es una pila que el usuario también manipula
La elección entre los dos comandos se vuelve fácil en cuanto se enuncia el criterio correcto, que no habla de código sino de percepción: cada entrada del historial debe corresponder a un paso que el usuario reconocería como propio, es decir, a un sitio al que le parecería razonable volver pulsando atrás una sola vez. Si el estado nuevo es un destino al que el usuario ha llegado deliberadamente, se empuja. Si el estado nuevo sustituye a uno que ya no debería existir —porque era un tránsito, una redirección o una versión anterior del mismo sitio—, se reemplaza.
pushUrl
Un destino nuevo y deliberado: abrir una ficha, entrar en una sección, pasar a la página siguiente de una lista. Volver atrás debe deshacerlo.
replaceUrl
Un estado que sustituye al actual: filtros que se refinan, texto de búsqueda que se teclea, una redirección tras iniciar sesión. Volver atrás no debe reponerlo.
back y forward
Mueven el cursor por la pila sin añadir nada. Útiles para un botón de volver propio, pero recuerda que la pila puede no tener nada detrás.
load y reload
Abandonan la aplicación de verdad: recargan el documento o van a otro origen. El estado en memoria se pierde entero, y eso es lo que se quiere al cerrar sesión.
El resto del módulo de navegación completa el cuadro con cuatro funciones que conviene tener presentes aunque se usen poco. Nav.back y Nav.forward mueven el cursor por la pila sin añadir entradas, y sirven para ofrecer un control de volver propio dentro de la interfaz; hay que recordar que la pila puede no tener nada detrás cuando el usuario llegó por un enlace directo, así que un botón que solo retrocede deja al recién llegado sin salida y suele ser preferible navegar a un destino conocido. Nav.load y Nav.reload abandonan la aplicación de verdad: destruyen el documento, pierden el estado en memoria y arrancan de cero. Esa pérdida total es indeseable casi siempre y exactamente lo que se quiere al cerrar sesión, porque garantiza que no queda ni un rastro del usuario anterior en ninguna parte del programa.
Nav.back : Nav.Key -> Int -> Cmd msg
Nav.forward : Nav.Key -> Int -> Cmd msg
Nav.load : String -> Cmd msg
Nav.reload : Cmd msg
update msg model =
case msg of
CerrarSesion ->
-- No basta con vaciar el modelo: se recarga para no dejar rastro
( model, Nav.load (Route.toString Route.Inicio) )
VolverAtras ->
( model, Nav.back model.key 1 )
Los dos casos que más se estropean en la práctica son la redirección y el formulario. Tras iniciar sesión, empujar la pantalla de destino deja el formulario de acceso justo detrás, de modo que un retroceso devuelve al usuario autenticado a una pantalla que ya no tiene sentido para él; hay que reemplazar. Tras guardar un formulario, empujar la ficha creada está bien, pero solo si el formulario mismo no se rellenó en varios pasos intermedios que empujaron sus propias entradas: en ese caso, atrás recorre el formulario hacia atrás paso a paso y el usuario acaba mirando un borrador que ya envió. Y en un buscador que actualiza la dirección con cada pulsación, empujar convierte el botón de retroceso en una máquina de borrar letras de una en una, que es el ejemplo canónico de trampa de historial.
flowchart TD A[Accion del usuario] --> D[Es un destino nuevo] D --> P[Si pushUrl anade entrada] D --> R[No replaceUrl sustituye entrada] P --> H[Historial] R --> H B[Boton atras] --> H H --> O[onUrlChange] O --> M[Model actualizado] style P fill:#89b4fa,color:#11111b style R fill:#f9e2af,color:#11111b style M fill:#a6e3a1,color:#11111b
Pulsar atrás como método de auditoría
Hay una prueba muy barata y sorprendentemente reveladora que conviene convertir en costumbre antes de cada entrega: recorrer los flujos principales de la aplicación y después pulsar el botón de retroceso repetidamente hasta salir, anotando qué aparece en cada paso. La prueba no requiere herramientas y detecta de inmediato las cuatro patologías clásicas. La primera es la entrada fantasma, un estado en el historial que el usuario nunca percibió como un sitio y al que volver resulta desconcertante. La segunda es la trampa, un par de estados que se redirigen mutuamente y del que no se sale hacia atrás. La tercera es la pérdida, una pantalla que al volver a ella aparece vacía porque su estado no estaba en la dirección. La cuarta es la inundación, decenas de entradas generadas por interacciones continuas.
Hay una confusión frecuente que conviene desactivar. El historial no es una pila de deshacer y el botón de retroceso no revierte acciones: revierte navegación. Si al pulsar atrás tras enviar un formulario el usuario vuelve al formulario, no ha deshecho el envío, solo está mirando una pantalla anterior mientras el efecto sigue hecho en el servidor. Diseñar como si fuese deshacer produce interfaces engañosas donde el usuario cree haber cancelado algo que sigue ocurriendo. Si tu dominio necesita deshacer de verdad, eso es una operación explícita del modelo, con su mensaje, su comando y su confirmación, y no debe delegarse en un control del navegador que significa otra cosa.
Hay un patrón que conviene tener resuelto de antemano porque aparece en toda aplicación con sesión: la ruta protegida. Al entrar en una pantalla que exige autenticación sin estarlo, la aplicación debe redirigir al acceso recordando el destino, y volver a él una vez autenticado. Las dos navegaciones de ese flujo son de reemplazo, no de empuje, porque ni la pantalla protegida a la que no se pudo entrar ni el formulario de acceso ya superado son sitios a los que tenga sentido volver.
entrar : Route -> Model -> ( Model, Cmd Msg )
entrar ruta model =
case ( ruta, model.sesion ) of
( Route.Panel, Anonimo ) ->
( model
, Nav.replaceUrl model.key (Route.toString (Route.Acceso ruta))
)
( Route.Panel, Autenticado usuario ) ->
( { model | page = Panel usuario }, Cmd.none )
_ ->
( model, Cmd.none )
Queda un caso incómodo que merece mención honesta: el usuario que abandona una pantalla con cambios sin guardar. La intercepción del clic permite avisarlo, porque el mensaje llega antes de que nada ocurra y la actualización puede decidir no empujar la dirección y mostrar una confirmación. El botón de retroceso, en cambio, no da esa oportunidad, porque cuando el programa se entera el cambio ya está consumado. Se puede reaccionar volviendo a empujar la dirección anterior, pero es un remiendo perceptible y conviene reconocerlo como tal. La lección de fondo es que la protección real no viene de interceptar la salida sino de reducir la cantidad de estado valioso que solo existe en memoria.
La manera más útil de pensar en todo esto es reconocer que el historial del navegador es la única estructura de datos de tu aplicación que el usuario manipula directamente, sin pasar por tu interfaz, con dos operaciones que tú no controlas y en cualquier momento que le apetezca. Es un dato compartido con un actor concurrente cuyas intenciones desconoces, y toda la disciplina que aplicas a la concurrencia en otros contextos aplica aquí en su forma más simple. La primera consecuencia es que no puedes suponer un orden: la pantalla que estás mostrando puede desaparecer bajo tus pies porque alguien pulsó atrás mientras tu petición estaba en vuelo, y por eso el estado de cada pantalla debe validarse contra la ruta actual y no darse por buena la última que se pidió. La segunda es que cada entrada que empujas es una promesa: le estás diciendo al usuario que ese es un sitio, que existe, que puede volver y que al volver encontrará lo que dejó. Empujar una entrada por cada pulsación de teclado es hacer cientos de promesas que no significan nada, y el usuario lo percibe como una avería aunque no sepa nombrarla. La tercera, y la más profunda, es que la calidad de un diseño de navegación se mide exactamente por la correspondencia entre la pila de entradas y el relato que el usuario se cuenta de lo que ha hecho. Cuando ambas coinciden, atrás significa lo que él espera, la dirección de la barra describe lo que ve, compartirla transmite lo que quería transmitir y recargar no le castiga; y esa coincidencia no se consigue con una biblioteca sino tomando, en cada acción que cambia de pantalla, una decisión consciente de una sola palabra. Que Elm haya reducido esa decisión a elegir entre dos funciones casi idénticas, en lugar de esconderla tras un valor por defecto que casi siempre empuja, es probablemente la razón por la que las aplicaciones escritas con este enfoque suelen tener un botón de retroceso que funciona: no porque sus autores sean más cuidadosos, sino porque el diseño de la interfaz les puso la pregunta delante en el momento exacto de responderla.
- Recorre un flujo completo de una aplicación que uses a diario y pulsa atrás hasta salir; clasifica cada entrada como legítima, fantasma, trampa o inundación.
- Implementa un acceso que redirija tras autenticar y prueba las dos variantes; explica qué ve el usuario al pulsar atrás en cada caso.
- Escribe un buscador que refleje el término en la dirección y compara empujar contra reemplazar contando las pulsaciones necesarias para salir.
- Construye la dirección de un enlace concatenando texto a mano, cambia después la forma de esa ruta en el parser y comprueba que nada falla hasta que el usuario llega; repítelo usando la función inversa y describe la diferencia.
- Añade una pantalla con cambios sin guardar y trata de proteger la salida por clic y por retroceso; documenta con precisión qué puedes garantizar y qué no.
- Enuncia el criterio de empujar o reemplazar en una sola frase y aplícalo a diez acciones concretas de un proyecto real, justificando los casos dudosos.