Reagent clientside routing with Bidi and Accountant
So, you are building a single page application with
Reagent and want a clean client
side routing in place? Here’s one way, inspired by
Yogthos recipe of a way to do it using
Secretary
taking advantage of Reagent’s atom
.
We will be using Bidi for routing and Accountant for hooking the browser history (which in turn relies on HTML5 Pushstate). I am assuming you have leiningen and figwheel working. I also assume you will use those project links to check how to add Bidi and Accountant to your project.
The outline of the approach is to let:
-
Bidi do the routing translation. It is designed to do this bidirectionally, hence the name. If you have a path, Bidi can match it to a route. If you have a route, Bidi can create the path that matches it.
-
Accountant manage the browser location and history, triggering the dispatching of routes by updating a
reagent/atom
. This relies on HTML5 pushstate. Check if you can use it here. -
Reagent re-render whatever parts of the UI that should change due to a route dispatch. We use a somewhat decorated
reagent/atom
calledsession
from the reagent-utils project to keep reagent informed.
I’ve created an example project showing the recipe at work. Many of you will want to skip reading the rest of this blog post and just study the example.
Setting up routes
Bidi uses data structures to define the routing table. I am too new to it to dare try to explain the grammar, check it up at the source. But at least I can describe the following structure, from the routing table used in the example project:
(def app-routes
["/" {"" :index
"section-a" {"" :section-a
["/item-" :item-id] :a-item}
"section-b" :section-b
"missing-route" :missing-route
true :four-o-four}])
As you can see the routing table is just data of regular clojure types.
You nest the path parts together, keeping things really DRY. Under /
we have four paths empty
→ :index
, section-a/
→ two subpaths,
section-b/
→ :section-b
, an (intentionally) missing route, and the
true
path → :four-o-four
, which catches everything not matching the
other paths.
The two subpaths of section-a/
are: empty
→ :section-a
and item/
→ :section-a-item
. The latter path takes a parameter :item-id
so
will match everything looking like /section-a/item/anything
and will
put anything
inside the matching routes :route-params
as
{:item-id anything}
. Bidi supports for checking the format of the
anything
, but I leave that up to you to play around with.
This we have set up a site structure like so:
-
Home -
/
-
Section A
/section-a/
-
Item foo
/section-a/item/foo
-
Item bar
/section-a/item/bar
-
…
-
Item baz
/section-a/item/baz
-
-
Section B `/section-b/
-
The routing table and most of the code in the example project you will
find in
src/routing-example/core.cljs
.
Defining pages
The "pages" are reagent components. Duh. They are also multimethods, and the routing will dispatch on a simple keyword:
(defmulti page-contents identity)
The home page links to Section A and B. Note the use of path-for
to
get the paths to use for the <a>
tags:
(defmethod page-contents :index []
[:span
[:h1 "Routing example: Index"]
[:ul
[:li [:a {:href (bidi/path-for app-routes :section-a) } "Section A"]]
[:li [:a {:href (bidi/path-for app-routes :section-b) } "Section B"]]
[:li [:a {:href (bidi/path-for app-routes :missing-route) } "Missing-route"]]
[:li [:a {:href "/borken/link" } "Borken link"]]]])
The Section A component lists a few links to "item" pages. This
demonstrates how Bidi forms links for parameterized routes. Check the
:section-a-item
entry in the routing table.
(defmethod page-contents :section-a []
[:span
[:h1 "Routing example: Section A"]
[:ul (map (fn [item-id]
[:li {:key (str "item-" item-id)}
[:a {:href (bidi/path-for app-routes :a-item :item-id item-id)} "Item: " item-id]])
(range 1 6))]])
The Item component picks up the :item-id
parameter from the
:route-params
entry in the session atom. (More about this below.
(defmethod page-contents :a-item []
(let [routing-data (session/get :route)
item (get-in routing-data [:route-params :item-id])]
[:span
[:h1 (str "Routing example: Section A, item " item)]
[:p [:a {:href (bidi/path-for app-routes :section-a)} "Back to Section A"]]]))
Section B is really boring.
(defmethod page-contents :section-b []
[:span
[:h1 "Routing example: Section B"]])
In the routing table we have a "catch all" route defined as
[true :four-o-four]
, remember? This will get dispatched for anything
missing from the routing table. Typically, if you use the
bidirectionality of Bidi this will only happen when the user is trying a
mistyped link.
(defmethod page-contents :four-o-four []
"Non-existing routes go here"
[:span
[:h1 "404"]
[:p "What you are looking for, "]
[:p "I do not have."]])
We have also defined the route :missing-route
to demonstrate that the
multimethod :default
dispatching value can help with letting you know
when you have missimplemented (or missed to implement entirely) a method
for a defined route.
(defmethod page-contents :default []
"Configured routes, missing an implementation, go here"
[:span
[:h1 "404: My bad"]
[:pre.verse
"This page should be here,
but I never created it."]])
Ideally your CI pipeline can use this to catch this error before it hits your users.
The page template component
All the pages in the silly app has the same header and footer, and then
embedd the active page component. Here the session atom gets derefed and
we pick out the route :current-page
and then pick out the
corresponding component from the page-components
map.
(defn page []
(fn []
(let [page (:current-page (session/get :route))]
[:div
[:p [:a {:href (bidi/path-for app-routes :index) } "Go home"]]
[:hr]
(page-contents page) ;;
[:hr]
[:p "(Using "
[:a {:href "https://reagent-project.github.io/"} "Reagent"] ", "
[:a {:href "https://github.com/juxt/bidi"} "Bidi"] " & "
[:a {:href "https://github.com/venantius/accountant"} "Accountant"]
")"]])))
- This will "dispatch" a new "page" when the session reagent/atom changes.
Time to dispatch
In order to enable the dispatching we need to get the page
component
into the browser’s DOM:
(defn on-js-reload []
(reagent/render-component [page]
(. js/document (getElementById "app"))))
(Also, Figwheel is configured to call this function whenever it has compiled new code.)
When the app initializes we configure the Accountant handlers. The
:nav-handler
is responsible for updating the session atom whenever the
browser wants to visit a new path within the app. Here’s is where we
meet the reciprocal friend of bidi/path-for
; bidi/match-route
. If
the matched route is parameterized the handler also inserts the
parameters into the session.
(defn ^:export init! []
(accountant/configure-navigation!
{:nav-handler (fn
[path] ;; 1
(let [match (bidi/match-route app-routes path) ;; 2
current-page (:handler match) ;; 3
route-params (:route-params match)] ;; 4
(session/put! :route {:current-page current-page ;; 5
:route-params route-params})))
:path-exists? (fn [path]
(boolean (bidi/match-route app-routes path)))})
(accountant/dispatch-current!) ;;
(on-js-reload))
- Accountant grabs the path from the browser navigation, and …
- … Bidi matches the route, using
bidi/match-route
. - Derefed in the
[page]
component, triggering a "dispatch".:route-params
to be de-refed by any component that needs them. - Here is when the Reagent atom (
session/state
in this case) is updated triggering a re-render in thepage
component. - This triggers the
:nav-handler
, updating the session so that the firston-js-reload
call has something to work on.
That’s it! Try the app and surf it. =)
However, during development you might find that it is a bother everytime
you have crashed it in a way that needs refreshing the browser window.
If you are not on the :index
page you’ll get Figwheel’s 404 page.
That’s why we need…
Loading the app from "outside" the root path
Before Accountant has taken responsibility for the browser navigation, we need to replace Figwheels 404 page with our app’s html template, regardless which app path is loaded.
claj showed me a really simple way yo solve
it. In the example project Figwheel is configured with a :ring-handler
set to routing-example.server/handler
. The project’s :dev
profile is
configured to look for code in the dev/
folder and there you will find
server.clj
with the following contents:
(ns routing-example.server)
(defn handler [request]
{:status 200
:body (slurp "resources/public/index.html")})
It doesn’t even need explaining. However, a few things can trip you up if you try this:
-
In the project file, the
:compiler
:asset-path
entry for the:dev
build use a relative path. Fix it so that it reads:asset-path "/js/compiled/out"
-
The template
index.html
also use relative paths for style sheets and script resources. Make those absolute and save yourself some of that precious hair on your head.
Room for improvement
Refreshing the browser resets the app. Since refreshing means
reloading index.html
we lose the benefit of Figwheel for these cases.
I don’t know if the browser history can be hijacked in such a way so
that the app isn’t jettisoned on refresh or when the user alters the url
in the location bar. But I know that I didn’t have this problem when
using Secretary and hooking the goog.history events old-style using
paths prefixed with a #
. So if someone could file a pull request for
Accountant supporting that we could configure things to use #-prefixed
paths for development and (if the business allows) HTML5 pushstate for
productuon.
Feedback please, and PRs!
I am a clojure and clojurescript noob. Please help me on my journey with PR’s to the example project, feedback and tips on how to roll this in a more idiomatic way.
Oh, and if you somehow missed where that example project is. =)