Skip to content

Coming from React Router

If you have used React Router's data APIs — createBrowserRouter, loaders, useNavigate, useLoaderData, useBlocker — most of re-frame2 routing will be familiar: routes are data, a route declares the data it needs, and the URL is an input to the app.

The main difference is that re-frame2 has no router object. Routes are registrations, a navigation is an event, and the active route is read with a subscription, so there is no <RouterProvider> and no router context. The model explains routing from scratch; this page starts from what you know.

The mapping

React Router re-frame2 Notes
createBrowserRouter([...]) / <Route> config reg-route Each route is one entry in a process-global table, registered like an event handler rather than placed in a component tree.
Route object (path, loader, errorElement…) The route's metadata map A plain Clojure map, which any code can read.
:slug path param, useParams() :slug in the path + @(subscribe [:rf.route/params]) Route params are coerced by the route's schema, and validated by it when re-frame.schemas is loaded.
useSearchParams() @(subscribe [:rf.route/query]) A separate map from path params. A key declared as :int arrives as a number.
useLocation() @(subscribe [:rf/route]) The whole route slice — route id, params, query, fragment, and readiness — as one map.
generatePath() / matchPath() rf.routing/route-url / rf.routing/match-url Pure URL construction and matching, runnable on the JVM. Schemas, defaults and canonical encoding determine the round trip — converting by hand.
loader function :resources for data the page needs; :on-match for work to start on entry Both are data, not functions. React Router's one loader does both jobs; here they are separate keys — see below.
useLoaderData() An ordinary subscription The data lands in the resource cache or app-db, and the view reads it like any other state.
useNavigate() → navigate("/x") (dispatch [:rf.route/navigate {:to :app/article :params {:slug "intro"}}]) Navigation is an event, so it is traced and can be intercepted.
redirect() from a loader or action, <Navigate replace> :fx [[:dispatch [:rf.route/navigate {:to :app/login :replace? true}]]] in an event handler A redirect is an ordinary navigation, returned as an effect.
action, <Form>, useSubmit() An ordinary event handler Submitting dispatches an event; its handler saves and, to move on, returns a navigate in its :fx. Nothing about it is routing-specific. Under server rendering a real POST form reaches the same event — the form action.
<Link to> [rf/route-link {:to :app/articles}] Renders a real <a href>, handles plain clicks, and leaves cmd/shift/middle-click to the browser. replace and preventScrollReset are :replace? true and :scroll :preserve on the link.
<NavLink>'s isActive Compare against @(subscribe [:rf.route/id]) in your own view route-link has no active state; a small wrapper sets :aria-current and a class — highlighting the active link.
<Link prefetch="intent"> (framework mode) [rf/route-link {:to :app/article :params {:slug "intro"} :prefetch :intent}] Hover, focus or touch loads the destination's resources without navigating — warming a destination. :intent is the only mode.
useNavigation().state ("loading") @(subscribe [:rf.route/transition]) :idle, :loading or :error, readable from any view. It reports the route's blocking resources only.
errorElement / useRouteError() A :blocking? true resource + @(subscribe [:rf.route/error]) When a blocking read fails, :rf.route/transition is :error and :rf.route/error holds a structured error record. Failures in :on-match work never reach the route.
useBlocker() / usePrompt() :can-leave guard + @(subscribe [:rf/pending-navigation]) A boolean guard sub, and a pending navigation your own view renders a prompt from — Guard against unsaved changes.
Auth in a loader (throw redirect(...)) :can-enter guard + a :rf.route/entry-denied handler Checked on every way into the route, including the first load and server rendering — Require sign-in on a route.
Splat route path="*" :rf.route/not-found An ordinary route you register and render; its params carry the URL and a :reason.
<Outlet/> + nested routes :parent + @(subscribe [:rf.route/chain]) You fold the chain into layout shells in the root view — see below. A parent's :resources are included in the child's.
state={{backgroundLocation}} modal routing An ordinary rendering decision See below.
<ScrollRestoration/> Built in; override with a route's or a navigation's :scroll :top when following a link, :restore on Back/Forward — fragments and scrolling.
createHashRouter / basename :url-strategy rf.routing/hash-url-strategy, wrapped in (rf.routing/with-base-path strategy "/base") for a sub-path Set on the url-bound frame — URL strategies.
createMemoryRouter A frame without :url-bound? true Routes in memory without touching the address bar, which is what tests use.
<RouterProvider router> Nothing The route lives in runtime-db, and any view subscribes to it.
Framework-mode server loaders The same :resources and :on-match One declaration runs on the client and the server.
lazy route modules No route key A route's view must be loaded before the root view renders it. To keep a rarely visited screen out of the first bundle, compile it into its own module and load it from an event — code splitting.

Where it differs

No hooks

In React Router, hooks such as useNavigate and useNavigation exist because router state is reachable only from components rendered inside the router. In re-frame2 the active route is in runtime-db, and you read it with subscribe from any view, from an event handler (through its coeffects), from a test, or from the REPL.

So a loading bar is a small view over :rf.route/transition, wherever it sits in the tree. An auth guard is a :can-enter subscription named on the route itself rather than a wrapper component around a subtree.

navigate("/articles") calls into React Router directly. In re-frame2 a navigation is (dispatch [:rf.route/navigate …]), like any other state change, and Back/Forward arrive as events too. Navigations therefore appear in Xray next to the click that caused them, and time-travel rewinds the URL along with the rest of the frame's state: the URL is derived from the state, not the other way round.

Loaders are data

React Router's loader is a function, so you find out what a route fetches by reading or running it. In re-frame2 both are data: a route's loader is :resources, a list of declarations, and its activation work is :on-match, a vector of event vectors. Either can be read without running anything: (rf/handler-meta {:source :store :kind :route :id :app/article}) returns the route's metadata.

:resources also handles the click-away race. Each resource loaded on entry belongs to that navigation's nav-token; if a newer navigation replaces it, a late reply is discarded instead of overwriting the page the reader is now on. React Router aborts superseded loaders, which saves bandwidth, but an abort can lose the race with a reply that has already arrived.

One loader becomes two keys

A React Router loader both fetches data the page cannot render without and starts work that merely begins on arrival, such as analytics. Both share the router's loading state and error handling, so a failed analytics call can put the page into its error state.

re-frame2 separates them. :resources declares the data the page needs, and only :resources drives :rf.route/transition and :rf.route/error. :on-match events are dispatched and not waited on; a handler that throws reports on the ordinary event error channel and does not affect the route.

With :parent, a child route includes its ancestors' :resources, and identical requests are fetched once. Nothing else is inherited: :on-match, :scroll, :tags and the guards stay per route.

Leaving asks the reader; entering asks the app

useBlocker returns a blocker object whose state you manage. In re-frame2, :can-leave is a boolean subscription, and a blocked navigation is parked in :rf/pending-navigation. Your own view renders the prompt from it and dispatches :rf.route/continue or :rf.route/cancel, so tests need no DOM and no native dialog.

:can-enter checks the app's current state. A refusal parks nothing: it commits nothing and dispatches :rf.route/entry-denied once. The return after sign-in is an ordinary new navigation, which the guard checks again. There is no flag that skips :can-enter.

Modals over a page

To show an item in a dialog over a list, React Router navigates to the item while keeping a backgroundLocation in history state, and renders the old match underneath. The URL and the rendered match then disagree, and only history state knows why.

In re-frame2 the URL names the article, and your root view decides to render it over the list:

(rf/reg-view root-view []
  (let [id @(subscribe [:rf.route/id])]
    [:div
     (page-for (if (= id :app/article) :app/articles id))   ;; keep the list mounted
     (when (= id :app/article)
       [article-dialog])]))

page-for is the tutorial's; article-dialog is your view of the article. Back, refresh and a pasted link all give the same result. If "opened from the list" needs to matter, store it in app-db.

Here it runs with two routes on an in-memory frame. Open an article: the list stays mounted under the dialog, and the route is :app/article.

(require '[re-frame.core :as rf]
         '[re-frame.routing])

(def sample-articles
  {"intro" {:title "Intro to re-frame2"}
   "ssr"   {:title "Server rendering"}})

(rf/reg-route :app/articles {} "/articles")
(rf/reg-route :app/article {:params [:map [:slug :string]]} "/articles/:slug")

(rf/reg-view articles-page []
  [:ul
   (for [[slug {:keys [title]}] sample-articles]
     ^{:key slug}
     [:li [rf/route-link {:to :app/article :params {:slug slug}} title]])])

(rf/reg-view article-dialog []
  (let [{:keys [slug]} @(subscribe [:rf.route/params])]
    [:div {:role "dialog" :style {:border "1px solid" :padding "0 1em 1em"}}
     [:h2 (get-in sample-articles [slug :title])]
     [rf/route-link {:to :app/articles} "Close"]]))

(rf/reg-view root-view []
  (let [id @(subscribe [:rf.route/id])]
    [:div
     [articles-page]                       ;; keep the list mounted
     (when (= id :app/article)
       [article-dialog])
     [:p [:code (pr-str id @(subscribe [:rf.route/params]))]]]))

[rf/frame-root {:id             :app
                :initial-events [[:rf.route/navigate {:to :app/articles}]]}
 [root-view]]

The same loaders run on the server

React Router's framework mode has server loaders with their own build and runtime. In re-frame2, server rendering feeds the request URL to the same routes on a per-request frame; the same :on-match events and :resources run, and the state is sent to the client, which hydrates without fetching again — see routing on the server.

Layouts instead of an outlet

<Outlet/> renders a parent layout's active child into a slot for you. re-frame2 has no slot: a route names its :parent, @(subscribe [:rf.route/chain]) returns the chain, and the root view folds the chain into layout shells with ordinary Clojure. That is more code than <Outlet/>, in exchange for no routing-specific rendering. The tutorial builds the fold; the model explains what the parent relation shares.

Smaller differences

  • Plain [:a {:href …}] links are not intercepted; they load the page. Use route-link, or install your own document-level click handler.
  • You register the 404 page. :rf.route/not-found is your route, and its :reason param tells a plain miss from a schema failure or a malformed URL.
  • Bad values fail differently by source (with re-frame.schemas loaded; without it, values are coerced but not checked). A bad URL from outside, such as a deep link, lands on not-found. A bad route-url call throws, and a bad navigation is rejected with an error.
  • The route table is data you can query, for breadcrumbs, sitemaps or analytics.