Routing and navigation¶
The core routing artefact defines route registration, navigation events, and route subscriptions. This page covers the Fresco view side: route links, prefetch, scroll and focus policy, and unsaved-change guards.
Register routes¶
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")
h/route-link ships on the door, so a view that renders links requires nothing
beyond it:
Boot a routed application¶
Routing costs a dependency and a frame option. The dependency is a coordinate;
the frame option rides h/frame-root with every other rf/make-frame option,
and there is no routing key on the root door at all.
;; deps.edn — beside the Fresco coordinate
{:deps {day8/re-frame2-fresco {:local/root "../re-frame2/implementation/fresco"}
day8/re-frame2-routing {:local/root "../re-frame2/implementation/routing"}}}
(ns app.core
(:require [re-frame.core :as rf]
[re-frame.routing] ;; loads the routing artefact
[re-frame.fresco.substrate :as substrate]
[re-frame.fresco :as h]
[app.routes] ;; the reg-route table above
[app.views :as views]))
(defonce app-root (h/client-root))
(defn ^:export init []
(rf/init! substrate/adapter)
(h/render! app-root
[h/frame-root
{:id :app/main
:url-bound? true ;; this frame owns the browser URL
:initial-events [[:app/initialise]]}
[views/app-root]]
(js/document.getElementById "app"))
nil)
Three things in that shape are load-bearing:
re-frame.routingis a separate coordinate, and Fresco does not bring it in. Require it before anything renders a route link, orh/route-linkraises:rf.error/routing-artefact-missing.- A frame owns the browser URL only by carrying
:url-bound? true. There is no default and nothing infers it; the declaration is the wiring, and creating the frame installs the URL listener and syncs the current URL into the route slice in one step. Without itroute-linkstill renders and navigation still updates that frame's own route state — the address bar simply never moves, and a refresh loses the page. :url-bound?rides the frame boundary, not the root door.h/render!'s opts carry:hydrate?and:identifier-prefixand REFUSE every other key —:url-bound?is a frame option, so it goes onh/frame-rootwith the rest of therf/make-framemap, beside the seed. One place names the frame and one place configures it: see A frame that needs more than a seed.
Exactly one frame may carry :url-bound? true. A second raises
:rf.error/duplicate-url-binding and the first claimant keeps the URL. Frames
without it — story variants, devcards, per-test fixtures — route independently
and never touch the address bar.
Render an application route link¶
Call h/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
(h/route-link {:to :app/article
:params {:id id}}
title)]
[:span.byline
"by "
(h/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 Fresco view or
subscription.
The generated Hiccup carries the click decision as data at :on-click — a
vector headed by a keyword the implementation owns, wrapping a map:
[:a {:href "/profile/jane"
:class "author"
:on-click [navigate-head ; route-link's own head
{: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, which
read it through re-frame.fresco.impl.intent/navigate-head?. It is not an
authoring spelling: route-link owns its shape, and there is nothing to write.
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 carries :frame, :payload, :native?, and :veto;
route-link creates it, and application code does not author it.
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]
(h/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.
(h/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¶
Warming a destination on hover, focus, or touch is an event,
[:rf.route/prefetch address], dispatched from the intent that signals the
interest. Write :prefetch :intent and the link fills the three intent
positions with that event, built from the address the link already carries:
renders
[:a {:href "/articles/intro"
:on-click [...] ;; the navigation, as always
:on-mouse-enter [:rf.route/prefetch {:to :app/article :params {:id "intro"}}]
:on-focus [:rf.route/prefetch {:to :app/article :params {:id "intro"}}]
:on-touch-start [:rf.route/prefetch {:to :app/article :params {:id "intro"}}]}
"Read more"]
:intent is the only accepted value, and omitting :prefetch is the only
way to opt out — a key present with any other value (true, nil, or a mode
borrowed from another router such as :render) raises
:rf.error/route-link-bad-prefetch at the render site rather than quietly
rendering a passive link. Omit it and none of the three positions is touched.
The sugar abbreviates a form you can always write yourself, and that longer form is the answer whenever a position must carry something else:
(h/route-link {:to :app/article
:params {:id "intro"}
:on-mouse-enter [:rf.route/prefetch {:to :app/article
:params {:id "intro"}}]}
"Read more")
The address takes :to, :params, :query and :fragment and nothing else
(:fragment is dropped from the prefetch — a fragment is never a resource
input). Application code may dispatch the event directly.
Do not write both. :prefetch :intent claims :on-mouse-enter,
:on-focus and :on-touch-start, and a value of your own at any of them
raises :rf.error/fresco-route-link-claimed-intent-position at render.
Fresco carries one intent per position, so there is nothing to compose with,
and half-applying the warm-up would leave a link that prefetches at two
positions out of three — indistinguishable from a working one until you
measure. Pick a side per link: the sugar, or the explicit vectors.
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}
[h/error-boundary
{:fallback [:p.oops "This page could not be shown."]}
(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.
The boundary sits inside <main> on purpose. Every Fresco refusal is a throw,
and React unmounts a root whose tree throws with nothing above it to catch, so a
single refused head anywhere in a page takes the whole application to a blank
screen with the error only in the console. Catching at the page keeps the
navigation usable, which is the region rule
Errors states and the
reason not to wrap the root instead — that would turn every failure into a
whole-page fallback and remove site-nav along with the broken content. It needs
no :reset-key: the :key above already remounts <main> on a route change, so
navigating away is the retry.
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.
A route deeper than one segment moves what relative URLs resolve against
A page's relative URLs resolve against the document URL, and under the
default history strategy the document URL is the route. So a host page
carrying href="css/style.css" is correct while every route is /, and
silently wrong the moment somebody deep-links or refreshes on
/articles/intro, where it resolves to /articles/css/style.css and 404s.
Nothing in the application fails: the script tag is usually absolute already,
so the app boots, routes and behaves — with no stylesheet and no favicon.
Give every asset in the host page an absolute path, as chapter 00's
index.html does for
/js/main.js, or add one <base href="/"> to <head>. Under a sub-path
deployment that becomes <base href="/my-app/">, with
rf.routing/with-base-path wrapped around the frame's :url-strategy so
the two agree.
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 |
| Links change the page but the address bar never moves, and a refresh loses the route | No frame carries :url-bound? true, so nothing owns the browser URL |
Declare it on the frame — Boot a routed application |
| The page loads and behaves but is unstyled after a deep link or a refresh | Relative asset paths in the host page resolve against the current route | Make host-page asset paths absolute, or add <base href="/"> |
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 |
A link carrying :prefetch :intent raises :rf.error/fresco-route-link-claimed-intent-position |
The link also supplies :on-mouse-enter, :on-focus or :on-touch-start, and :prefetch claims all three |
Drop :prefetch and dispatch [:rf.route/prefetch address] by hand from the positions you are not otherwise using |
| 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 |