Routing and navigation¶
The core routing artefact defines route registration, navigation events, and route subscriptions. This page covers the Hicasso view side: route links, prefetch, scroll and focus policy, and unsaved-change guards.
Register routes and require the view integration¶
Register routes once during boot:
(ns app.routes
(:require [re-frame.core :as rf]
[re-frame.routing]))
(rf/reg-route :app/home {} "/")
(rf/reg-route :app/articles {} "/articles")
(rf/reg-route :app/article
{:params [:map [:id :string]]}
"/articles/:id")
(rf/reg-route :app/profile
{:params [:map [:username :string]]}
"/profile/:username")
(rf/reg-route :app/inbox {} "/inbox")
Require the Hicasso routing module where links are rendered:
(ns app.views.articles
(:require [re-frame.hicasso :as h]
[re-frame.hicasso.routing :refer [route-link]]))
Render an application route link¶
Call route-link as a plain helper. Name a registered route and its params
rather than constructing a URL:
(h/defview article-card [{:keys [id]}]
(let [{:keys [title author]}
(h/sub [:article/summary id])]
[:article.card
[:h2
(route-link {:to :app/article
:params {:id id}}
title)]
[:span.byline
"by "
(route-link {:to :app/profile
:params {:username author}
:class "author"}
author)]]))
The result is a real anchor. The router builds :href, so hover preview,
copy-link, middle-click, and browser link menus continue to work. The helper
inlines into its caller; it does not create another Hicasso view or
subscription.
The generated Hiccup contains a reserved navigation head:
[:a {:href "/profile/jane"
:class "author"
:on-click [::h/navigate
{:frame :rf/default
:payload [:rf.route/url-requested
{:url "/profile/jane"
:to :app/profile
:params {:username "jane"}}]
:native? false
:veto nil}]}
"jane"]
This form remains comparable with = and visible to structural tests. Do not
write ::h/navigate yourself; route-link owns its shape.
Click conduct is browser-compatible:
- a plain left-click prevents the browser default and dispatches the routing event to the frame captured during rendering
- modifier and auxiliary clicks remain browser operations, such as opening a new tab
- anchors with
:targetor:downloadnavigate natively
The generated map accepts only :frame, :payload, :native?, and :veto.
Unexpected keys raise :rf.error/hicasso-malformed-navigate during rendering.
If the core routing artefact was not loaded, rendering raises
:rf.error/routing-artefact-missing and names the requested route instead of
producing a dead anchor. Ordinary classes, data attributes, and ARIA props pass
through.
Mark the active link¶
route-link does not decide which link is active. Read the current route once
where the navigation renders and pass the result into an inline helper:
(h/defview site-nav []
(let [current (h/sub [:rf.route/id])
nav (fn [to label]
(route-link
{:to to
:class (when (= to current) "is-active")
:aria-current (when (= to current) "page")}
label))]
[:nav
(nav :app/home "Home")
(nav :app/articles "Articles")]))
Use :aria-current "page" as the semantic state and a class for styling.
Veto one link¶
A specific link may replace its navigation with another action, such as asking
whether to discard a local scratch pane. Pass one of the supported veto forms
as :on-click: nil, [::h/prevent INTENT], an h/event, or a plain
function.
(route-link
{:to :app/inbox
:on-click (when draft-open?
[::h/prevent [:composer/confirm-discard]])}
"Inbox")
The prevent wrapper cancels navigation and dispatches the inner event. A bare event vector is rejected because one click must not produce both an unrelated application event and the routing event.
Use the route-level dirty-leave guard for unsaved work that must protect every exit. A link veto covers only that link.
Prefetch on user intent¶
A route link can warm destination data on hover, focus, or touch:
:intent is the only accepted value. Omit :prefetch for a passive link. Any
other value fails at render rather than silently choosing a different mode.
The link dispatches [:rf.route/prefetch {:to … :params …}]; application code
may also dispatch that event directly.
Prefetch does not navigate. It does not change the URL, run guards, apply scroll/focus policy, or block activation. A later click uses ordinary resource deduplication to reuse work already in flight. An unused prefetch remains eligible for resource garbage collection.
Prefetch is not authorization. It may warm a destination that :can-enter
later refuses; the real navigation still evaluates its guards.
Scroll policy¶
Scroll behaviour belongs to route or navigation data:
| Policy | Behaviour | Normal use |
|---|---|---|
:top |
scroll to the top on entry | forward navigation |
:restore |
restore the saved position | Back/Forward |
:preserve |
leave the viewport unchanged | in-place query or filter changes |
For example, pagination that should keep the current viewport can dispatch:
Restoration requires the destination page to have its real height. If Back/Forward activates a long page while its list is still absent, restore may run against a short document and land at the top. Declare blocking route resources or retain previous data until the new page is ready.
Move focus after a page change¶
Changing the route does not automatically move keyboard or screen-reader focus. Key the main region by page identity, make it programmatically focusable, and focus it after commit:
(defn- focus-page [node]
(when node
(.focus node #js {:preventScroll true})))
(h/defview app-root []
(let [route (h/sub [:rf.route/id])]
[:div.app
[site-nav]
[:main {:key route
:tab-index -1
:ref focus-page}
(case route
:app/home [home-page]
:app/article [article-page]
[not-found-page])]]))
The key remounts <main> when page identity changes, causing the ref to run.
:tab-index -1 allows programmatic focus without adding the region to normal
tab order. preventScroll lets the router's scroll policy remain authoritative.
Query-only or fragment-only changes keep the same route id and therefore do not move focus. If article 7 and article 9 count as separate pages, include the route params in the key.
Modal and popover focus is owned by the overlays module, not this recipe.
Guard unsaved changes¶
A dirty-leave guard is ordinary state. Register a subscription that returns a strict boolean and attach it to the route:
(rf/reg-sub :editor/can-leave?
(fn [db _]
(= (get-in db [:editor :draft])
(get-in db [:editor :saved]))))
(rf/reg-route :app/article-editor
{:params [:map [:id :string]]
:can-leave [:editor/can-leave?]}
"/articles/:id/edit")
When the guard returns false, the route and URL remain unchanged. The
attempt is stored in [:rf/pending-navigation], which a view can render:
(h/defview leave-guard-dialog []
(when-let [pending (h/sub [:rf/pending-navigation])]
[:div.modal {:role "alertdialog"
:aria-modal true}
[:p "You have unsaved changes. Leave anyway?"]
[:button
{:on-click [:rf.route/cancel (:id pending)]}
"Stay"]
[:button
{:on-click [:rf.route/continue (:id pending)]}
"Discard and leave"]]))
Mount the view once near the root. :rf.route/continue replays the original
destination, replace flag, and scroll policy. :rf.route/cancel drops the
attempt. Both include the pending id, so a stale click after resolution is a
no-op.
A real application should render this state through the modal overlay so focus is trapped and restored.
After a successful save, navigate with a one-shot leave bypass:
(rf/reg-event :editor/save-and-close
(fn [{:keys [db]} _]
{:db (assoc-in db
[:editor :saved]
(get-in db [:editor :draft]))
:fx [[:dispatch
[:rf.route/navigate
{:to :app/article
:params {:id (get-in db [:editor :id])}
:bypass-leave? true}]]]}))
:bypass-leave? skips this route's :can-leave once. The destination's
:can-enter still runs.
Application routing cannot block browser exits
A route guard cannot stop closing the tab, reloading, or following an
external link. Install a beforeunload listener that reads the same
:editor/can-leave? fact. Keep one dirty calculation and expose it to the
two exit mechanisms; do not maintain separate flags.
Deep links, Back, and Forward¶
Initial URLs and browser history inputs use the same match, validation, guard, and activation pipeline as route links:
- Query defaults apply on a deep link before views read
[:rf.route/query]. - Entry and leave guards run for links, dispatched navigation, address-bar input, Back/Forward, initial load, and SSR.
- Back/Forward use
:restoreby default. The focus recipe may also run; itspreventScrolloption prevents focus from overriding restoration. - Navigation does not automatically cancel unrelated async work. A pending mutation remains readable and its cache effects may land after the user leaves. Route guards protect local state; mutation supersession protects reply races.
- The server runs the same routing pipeline for the request URL. Hydration adopts that result rather than navigating again.
For readers coming from React Router
route-link is a plain function returning an anchor, not a component with
private router context. A blocked transition is app state, not a blocker
hook. Prefetch is an event, and router facts are subscriptions.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Rendering a route link raises :rf.error/routing-artefact-missing |
The core routing artefact was not required before rendering | Require re-frame.routing during boot |
| An in-app link performs a full page load | A hand-written anchor bypassed route interception | Use route-link or the documented document-level routing listener |
Rendering raises :rf.error/hicasso-malformed-navigate |
Application code created or altered the reserved navigation head | Do not author ::h/navigate; let route-link create it |
route-link rejects a bare :on-click vector |
The click would produce two semantic events | Use [::h/prevent [:app/event]], h/event, or a plain function according to the intended veto |
:prefetch is rejected |
The value is not :intent |
Remove the key or use :prefetch :intent |
| Every attempt to leave is rejected and the guard is named | :rf.error/can-leave-non-boolean |
Return strict true or false from the guard subscription |
| Back/Forward restores to the top | Scroll restoration ran before content restored page height | Block activation on required resources or keep previous content visible |
| Focus stays on the old navigation link | Main region was not keyed/focusable or its ref did not run | Key by page identity, add :tab-index -1, and focus from the callback ref |
| A tab close ignores the dirty guard | Browser exits are outside application routing | Add beforeunload using the same can-leave state |
When not to use the routing integration¶
| Situation | Prefer |
|---|---|
| A single-screen application with no shareable URL state | No routing artefact |
| Wizard steps or temporary tabs that should not change the URL | app-db state or a state machine |
| External destinations | A plain anchor |
| Guarding one control rather than every page exit | A link veto or ordinary application event logic |