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/all {} "/")
(rf/reg-route :app/active {} "/active")
(rf/reg-route :app/done {} "/done")
(rf/reg-route :app/todo
{:params [:map [:id :string]]}
"/todos/:id")
The three filter routes put the todo list's :showing filter in the URL, so a
subscription can derive it from the current route id (cf.
examples/core/todomvc). :app/todo is a detail page for one todo.
h/route-link is part of re-frame.fresco, so a view namespace that renders
links requires nothing more:
Boot a routed application¶
Routing needs a dependency and a frame option. The frame option goes on
h/frame-root with every other rf/make-frame option:
;; 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
:url-bound? true ;; this frame owns the browser URL
:initial-events [[:todo/initialise]]}
[views/app-shell]]
(js/document.getElementById "app"))
nil)
Three things in that boot matter:
re-frame.routingis a separate dependency that Fresco does not bring in. Require it before anything renders a route link, orh/route-linkraises:rf.error/routing-artefact-missing.- A frame owns the browser URL only if it carries
:url-bound? true. Nothing infers it. Creating that frame installs the URL listener and syncs the current URL into the route state. Without itroute-linkstill renders and navigation still updates the frame's route state, but the address bar never moves and a refresh loses the page. :url-bound?goes onh/frame-root, not inh/render!'s options.h/render!accepts only:hydrate?and:identifier-prefix; frame options belong on the frame-root with the rest of therf/make-framemap, as in A frame that needs more than a seed.
Exactly one frame may carry :url-bound? true. A second is still created, but
the runtime reports :rf.error/duplicate-url-binding naming both frames (an
error record, not a throw); 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 todo-row [{:keys [id]}]
(let [{:keys [title done?]} (h/sub [:todo/by-id id])]
[:li {:class (when done? "done")}
(h/route-link {:to :app/todo
:params {:id (str id)}
:class "title"}
title)]))
:query and :fragment complete the address, so
(h/route-link {:to :app/all :query {:q "milk"}} "Milk") links to /?q=milk.
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.
route-link puts the click decision in the anchor's props as data, so two
renders of the same link compare equal with = and a structural test can
inspect where a click goes (What a route link
renders shows the form).
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
A link takes the navigation policy :rf.route/navigate takes. :replace? true
replaces the current history entry instead of adding one, :scroll sets this
navigation's scroll policy, and :bypass-leave? true skips the current route's
:can-leave once. These keys ride the click's navigation payload and never
reach the anchor.
If the 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 filter-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.filters
(nav :app/all "All")
(nav :app/active "Active")
(nav :app/done "Done")]))
Use :aria-current "page" as the semantic state and a class for styling.
The cell below wires the three filter routes to filter-nav and a list that
derives its filter from the route id. Its frame has no :url-bound?, so it
routes in memory and leaves this page's address bar alone. Click the links:
the route changes, the active link gets its aria-current, and the list
follows. Hover a link to see the real href the router built.
(require '[re-frame.core :as rf]
'[re-frame.routing]
'[re-frame.fresco :as h])
(rf/reg-route :app/all {} "/")
(rf/reg-route :app/active {} "/active")
(rf/reg-route :app/done {} "/done")
(rf/reg-event :todo/initialise
(fn [_ _]
{:db {:todos {1 {:id 1 :title "Buy milk" :done? false}
2 {:id 2 :title "Walk the dog" :done? true}}}}))
(rf/reg-sub :todo/all
(fn [db _]
(vec (sort-by :id (vals (:todos db))))))
(rf/reg-sub :todo/visible
{:inputs [[:todo/all] [:rf.route/id]]}
(fn [[todos route] _]
(case route
:app/active (filterv (complement :done?) todos)
:app/done (filterv :done? todos)
todos)))
(h/defview filter-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")
:style {:margin-right "1em"}}
label))]
[:nav.filters
(nav :app/all "All")
(nav :app/active "Active")
(nav :app/done "Done")]))
(h/defview todo-page [_]
[:div
[filter-nav]
[:ul
(for [{:keys [id title]} (h/sub [:todo/visible])]
[:li {:key id} title])]
[:p "Route: " (pr-str (h/sub [:rf.route/id]))]])
[h/frame-root {:id :app
:initial-events [[:todo/initialise]
[:rf.route/navigate {:to :app/all}]]}
[todo-page]]
Veto one link¶
A specific link may replace its navigation with another action, such as asking
whether to discard a half-typed new todo. 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/all
:on-click (when draft-open?
[::h/prevent [:todo.ui/confirm-discard]])}
"All")
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.
An h/event or plain function is the imperative veto: it receives the click
first and cancels the navigation only by calling .preventDefault. Its return
value is not dispatched, so use [::h/prevent …] to replace the navigation
with an 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 "/todos/1"
:on-click [...] ;; the navigation, as always
:on-mouse-enter [:rf.route/prefetch {:to :app/todo :params {:id "1"}}]
:on-focus [:rf.route/prefetch {:to :app/todo :params {:id "1"}}]
:on-touch-start [:rf.route/prefetch {:to :app/todo :params {:id "1"}}]}
"Details"]
:intent is the only accepted value; to opt out, omit the key. A link that
needs one of those three positions for something else drops :prefetch and
writes the prefetch vector itself (Write the prefetch
yourself).
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, a pagination button that should keep the current viewport can dispatch the navigation as an intent:
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-shell [_]
(let [route (h/sub [:rf.route/id])]
[:div.app
[filter-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/all :app/active :app/done) [todo-page]
:app/todo [todo-detail-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 error boundary sits inside <main> so that a page that throws shows the
fallback while filter-nav stays usable; wrapping the root instead would replace
the whole application, navigation included
(Errors explains
where boundaries belong). 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 todo 7 and todo 9 count as separate pages, include the route params in the key.
Modal and popover focus is handled by the overlays module (Overlays and focus).
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 :todo.editor/can-leave?
(fn [db _]
(= (get-in db [:todo.editor :draft])
(get-in db [:todo.editor :baseline]))))
(rf/reg-route :app/todo-edit
{:params [:map [:id :string]]
:can-leave [:todo.editor/can-leave?]}
"/todos/:id/edit")
When the guard returns false, the route and URL remain unchanged, and the
blocked attempt is readable through the :rf/pending-navigation subscription,
which a view can render:
;; Also require [re-frame.fresco.overlay :as overlay].
(h/defview leave-guard-dialog [_]
(when-let [pending (h/sub [:rf/pending-navigation])]
[overlay/modal {:open? true
:label "Discard changes?"
:on-dismiss [:rf.route/cancel (:id pending)]}
[: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.
The modal overlay traps focus while the confirmation is open and restores it when the attempt is resolved. Escape cancels the pending navigation.
After a successful save, navigate with a one-shot leave bypass:
(rf/reg-event :todo.editor/save-and-close
(fn [{:keys [db]} _]
{:db (assoc-in db
[:todo.editor :baseline]
(get-in db [:todo.editor :draft]))
:fx [[:dispatch
[:rf.route/navigate
{:to :app/todo
:params {:id (str (get-in db [:todo.editor :draft :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
:todo.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, which under the
default history strategy is the route. A host page carrying
href="css/style.css" works at /, but after a deep link or a refresh on
/todos/1 it requests /todos/css/style.css and gets a 404, so the app
boots and routes with no stylesheet or favicon.
Give every asset in the host page an absolute path, as chapter 00's
index.html does for
/js/main.js, or add <base href="/"> to <head>. Under a sub-path
deployment use <base href="/my-app/"> and wrap the frame's
:url-strategy in re-frame.routing/with-base-path 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 |
Rendering a route link raises :rf.error/no-such-route or :rf.error/route-url-validation |
:to is not a registered route, or, with the schemas artefact loaded, :params or :query fail the route's schema (a number where the route expects a string, say) |
Fix the address; the routing guide covers route schemas |
| An in-app link performs a full page load | A hand-written anchor bypassed route interception | Use route-link, or a document-level click listener that dispatches :rf.route/url-requested (Linking from views) |
| 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 raises :rf.error/fresco-route-link-bad-on-click for a bare :on-click vector |
The click would produce two application events | Use [::h/prevent [:app/event]], h/event, or a plain function according to the intended veto |
A link raises :rf.error/route-link-bad-prefetch at render |
:prefetch carries a value other than :intent (true, nil, or another router's mode such as :render) |
Write :prefetch :intent, or omit the key |
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 |
Advanced¶
What a route link renders¶
The generated Hiccup carries the click decision as data at :on-click, a
vector headed by an internal keyword and wrapping a map:
[:a {:href "/todos/1"
:class "title"
:on-click [navigate-head ; route-link's own head
{:frame :app
:payload [:rf.route/url-requested {:url "/todos/1"}]
:native? false
:veto nil}]}
"Buy milk"]
You never write this form yourself; route-link creates it.
Write the prefetch yourself¶
:prefetch :intent 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/todo
:params {:id "1"}
:on-mouse-enter [:rf.route/prefetch {:to :app/todo
:params {:id "1"}}]}
"Details")
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, because a
position carries one intent and a half-applied warm-up would prefetch from only
some positions. Choose the sugar or the explicit vectors per link.