Data-driven routing
In this tutorial we will develop a small routing system. You will learn how routing can fit into Replicant’s model where rendering is always top-down. We’ll also explore using the URL as an alternative to component local state.
With the exception of the final section, the ideas in this tutorial are not specific to Replicant, and can be used with a wide variety of rendering libraries.
§ Example setup
For this tutorial, we will build a small app to view Parens of the dead episodes. You can get the setup on Github if you want to follow along.
The parens.data
namespace has some data for us to work with:
(ns parens.data)
(def data
{:videos
[{:video/url "https://www.youtube.com/watch?v=6qnNtVdf08Q"
:video/thumbnail "/images/parens1.png"
:episode/id "s2e1"
:episode/number 1
:episode/title "It Lives Again"
:episode/description "Starting with an empty folder sure is a choice. Much like digging your way out of a grave, it can be intimidating, perhaps because it's not something you do every day. Watch us struggle with these issues and more in the very first episode of Parens of the Dead."}
{:video/url "https://www.youtube.com/watch?v=CyveUnHzc7g"
:video/thumbnail "/images/parens2.png"
:episode/id "s2e2"
:episode/number 2
:episode/title "Shambling Along"
:episode/description "Watch us totally ruin a pristine, beautiful lightly tinted purple page by adding decrepit buildings to it in this very second episode of Parens of the Dead. We'll get ClojureScript up and running with surprisingly little hassle. There might even be jokes."}
{:video/url "https://www.youtube.com/watch?v=_6tVIijfRzQ"
:video/thumbnail "/images/parens3.png"
:episode/id "s2e3"
:episode/number 3
:episode/title "Stumbling out of the Graveyard"
:episode/description "Someone eated our brains! In this very third episode, we're struggling to get more wiring wired properly while also typing with our fingers on the keyboard. It's a hard life, being a zombie developer. Web socket setup surely is no joke."}]})
The parens.ui
namespace renders a list of video titles:
(ns parens.ui)
(defn render-page [{:keys [videos]}]
[:div
[:h1 "Parens of the dead"]
[:ul
(for [{:keys [episode/title]} videos]
[:li title])]])
The parens.core
namespace is where we call replicant.dom/render
, and where
we’ll add additional central infrastructure:
(ns parens.core
(:require [parens.ui :as ui]
[replicant.dom :as r]))
(defn main [el state]
(r/render el (ui/render-page state)))
(defn bootup [el state]
;; Perform bootup steps that should only be done once here
(main el state))
Finally, there’s a dev namespace to kick things off:
(ns parens.dev
(:require [parens.core :as app]
[parens.data :as data]))
(defonce el (js/document.getElementById "app"))
(defonce started (app/bootup el data/data))
(defn ^:dev/after-load main []
;; Add additional dev-time tooling here
(app/main el data/data))
§ System design
By adding routing to the app we want to have different render functions at different URLs. To achieve this, we will extract information from the URL and use it to dispatch to the right render function.
When someone visits the path "/"
, we want to show them the frontpage. When
they visit "/episodes/s2e1"
, we want to show information about the episode
with id "s2e1"
. In other words, every path like "/episodes/??"
represents
the same page/render function, but with different parameters.
We will use the term “page” to mean the render function for a specific route.
The episode page has many concrete instances: /episodes/s2e1
,
/episodes/s2e2
, etc. Routing allows us to break down the URL to a data
structure describing a specific instance of a page. We will use the term
“location” for this data structure.
Given this route description:
{:location/page-id :pages/episode
:location/route ["episodes" :episode/id]}
The URL /episodes/s2e1
will be represented by this location:
{:location/page-id :pages/episode
:location/params {:episode/id "s2e1"}}
The location map can also hold information from the query string and hash if we need it:
{:location/page-id :pages/episode
:location/params {:episode/id "s2e1"}
:location/query-params {:view "related"}
:location/hash-params {:menu-expanded "1"}}
We’ll start with a bareboned version of the routing function:
(defn extract-location [path]
(or (when (= "/" path)
{:location/page-id :pages/frontpage})
(when-let [[_ id] (re-find #"/episodes/(\w+)" path)]
{:location/page-id :pages/episode
:location/params {:episode/id id}})))
§ Working with locations
We need to extract the location data when the app boots, and when the URL changes.
To do it when the app boots we’ll call extract-location
from main
and pass
the data to the render function:
(defn main [el state]
(->> (extract-location js/location.href)
(ui/render-page state)
(r/render el)))
Page dispatch
We can rename the render-page
function to render-frontpage
, and write a new
render-page
that chooses the right render function:
(ns parens.ui)
(defn render-frontpage [{:keys [videos]} _]
[:div
[:h1 "Parens of the dead"]
[:ul
(for [{:keys [episode/title]} videos]
[:li title])]])
(defn render-not-found [_ _]
[:h1 "Not found"])
(defn render-page [state location]
(let [f (case (:location/page-id location)
:pages/frontpage render-frontpage
render-not-found)]
(f state location)))
Because the location
map may contain useful information (routing and query
parameters, etc), we pass it as a separate argument to each page’s render
function.
§ Browser navigation
Routing at bootup is well and fine, but we also need to change the active location when the user navigates the app. We could add a navigation action and use it to change the location, but an even easier approach is to piggie-back on what the browser already does. This approach has the benefit of being less specific to our chosen tools, and is easier to move to the server if we wish to do so later.
We will add a click event listener to the body of the page. If the target of the
click is an anchor (or inside one), and its href
attribute matches any of our
routes, we will route it – otherwise, we’ll let the browser do its thing.
Let’s start by finding the target URL for a click event, if any:
(defn find-target-href [e]
(some-> e .-target ;; 1
(.closest "a") ;; 2
(.getAttribute "href"))) ;; 3
- The target of the event is whatever DOM element received the click
.closest
returns the element itself if it matches the CSS selector, or the closest parent that does. This allows us to also react to clicks on elements nested inside anchor elements, e.g.<a href="/videos/ac2"><img src="/ac2.png"></a>
.- Get the
href
attribute
We will call this function whenever a click
event triggers. If the href
resolves to a location, we will route the click and manually update the browser
URL, otherwise we do nothing.
First, we’ll refactor the main
function slightly, to avoid duplicating details
about the render:
(defn render-location [el state location]
(r/render el (ui/render-page state location)))
(defn main [el state]
(render-location el state (extract-location js/location.href)))
Next, we’ll add the event listener in bootup
(we don’t want this re-evaluated
when code is hot reloaded):
(defn bootup [el state]
(js/document.body.addEventListener
"click"
(fn [e]
(let [href (find-target-href e)]
(when-let [location (extract-location href)]
(.preventDefault e)
(.pushState js/history nil "" href) ;; Update browser URL
(render-location el state location)))))
(main el state))
To test our new capability we will add a render function for the episode page:
(defn render-episode [state location]
[:main
(if-let [episode (get-episode state location)]
(list [:h1 (:episode/title episode)]
[:p (:episode/description episode)])
[:h1 "Unknown episode"])
[:p [:a {:href "/"} "Back to episode listing"]]])
Then include it in the main dispatch:
(defn render-page [state location]
(let [f (case (:location/page-id location)
:pages/frontpage render-frontpage
:pages/episode render-episode
render-not-found)]
(f state location)))
And finally add some links from the frontpage:
(defn render-frontpage [{:keys [videos]} _]
[:div
[:h1 "Parens of the dead"]
[:ul
(for [{:keys [episode/title episode/id]} videos]
[:li [:a {:href (str "/episodes/" id)} title]])]])
Going back
We now have routing for links, but if we try to go back nothing much happens. The URL changes, but the page doesn’t re-render. We can fix this with a popstate handler.
(defn bootup [el state]
,,,
(js/window.addEventListener
"popstate"
(fn [_]
(->> (extract-location js/location.href)
(render-location el state))))
,,,)
With this handler in place, going back works as well.
§ Routing mechanics
We now have basic routing in place, but with hard-coded URLs and some duplication between the routing logic and generating links. We can fix this by using a routing library that has two way routing. We will use silk to demonstrate, but any library that has bi-directional routing and uses data rather than macros for routes will do.
To avoid being overly reliant on the specific routing library, we will create
our own router
namespace. It will also use lambdaisland/uri
to parse the URL.
(ns parens.router
(:require [domkm.silk :as silk]
[lambdaisland.uri :as uri]))
(def routes
(silk/routes
[[:pages/episode [["episodes" :episode/id]]]
[:pages/frontpage []]]))
(defn url->location [routes url]
(let [uri (cond-> url (string? url) uri/uri)]
(when-let [arrived (silk/arrive routes (:path uri))]
(let [query-params (uri/query-map uri)
hash-params (some-> uri :fragment uri/query-string->map)]
(cond-> {:location/page-id (:domkm.silk/name arrived)
:location/params (dissoc arrived
:domkm.silk/name
:domkm.silk/pattern
:domkm.silk/routes
:domkm.silk/url)}
(seq query-params) (assoc :location/query-params query-params)
(seq hash-params) (assoc :location/hash-params hash-params))))))
(defn location->url [routes {:location/keys [page-id params query-params hash-params]}]
(cond-> (silk/depart routes page-id params)
(seq query-params)
(str "?" (uri/map->query-string query-params))
(seq hash-params)
(str "#" (uri/map->query-string hash-params))))
We can now remove the extract-location
we wrote earlier and call
url->location
instead, e.g.:
(ns parens.core
(:require [parens.router :as router]
[parens.ui :as ui]
[replicant.dom :as r]))
,,,
(defn main [el state]
(->> js/location.href
(router/url->location router/routes)
(render-location el state)))
The route data is a global def now but we don’t want to cement that, so both
routing functions expect to be passed the routes. This means that the render
functions need access to the routes as well. Update main
like so:
(ns parens.core
(:require [parens.router :as router]
[parens.ui :as ui]
[replicant.dom :as r]))
,,,
(defn main [el state]
(->> js/location.href
(router/url->location router/routes)
(render-location el state router/routes)))
Pass along the routes when rendering:
(defn render-location [el state routes location]
(r/render el (ui/render-page state routes location)))
Then update the render functions accordingly:
(ns parens.ui
(:require [parens.router :as router]))
(defn render-frontpage [{:keys [videos]} routes _]
[:div
[:h1 "Parens of the dead"]
[:ul
(for [{:keys [episode/title episode/id]} videos]
[:li
[:a
{:href (router/location->url routes
{:location/page-id :pages/episode
:location/params {:episode/id id}})}
title]])]])
(defn get-episode [{:keys [videos]} {:keys [location/params]}]
,,,)
(defn render-episode [state routes location]
[:main
,,,
[:p
[:a {:href (router/location->url routes {:location/page-id :pages/frontpage})}
"Back to episode listing"]]])
(defn render-not-found [_ _ _]
,,,)
(defn render-page [state routes location]
(let [f ,,,]
(f state routes location)))
And with that, we have bi-directional routing powered by a routing library. And the routing library is merely an implementation detail of the app’s own router namespace. Win-win.
§ Using the URL for state transfer
URLs are great because they can address specific states in your user interface. By making some minor adjustments to the routing system, the URL can be used in place of component local state with the added benefit of making any state in the UI bookmarkable and shareable like only URLs can be.
If we are to put UI state in the URL, we need to do so without adding to the browser’s history. Otherwise, we would effectively break the back button by causing it to back through any minor state change. We can use the URL hash fragment for this.
When routing clicks, we will check if the old and new locations are the same if
we ignore the hash params. If they are, we will use replaceState
instead of
pushState
to avoid adding a history entry.
We’ll start with a function in the router namespace that can decide if two locations are essentially the same:
(defn essentially-same? [l1 l2]
(and (= (:location/page-id l1) (:location/page-id l2))
(= (not-empty (:location/params l1))
(not-empty (:location/params l2)))
(= (not-empty (:location/query-params l1))
(not-empty (:location/query-params l2)))))
Next, we’ll extract the body click handler to a separate function. In the
bootup
function:
(js/document.body.addEventListener "click"
#(route-click % el state router/routes))
And here’s the updated code in a separate function:
(defn get-current-location []
(->> js/location.href
(router/url->location router/routes)))
(defn route-click [e el state routes]
(let [href (find-target-href e)]
(when-let [location (router/url->location routes href)]
(.preventDefault e)
(if (router/essentially-same? location (get-current-location))
(.replaceState js/history nil "" href)
(.pushState js/history nil "" href))
(render-location el state router/routes location))))
With this little tweak, pages can now use addressable state over the URL:
(defn render-episode [state routes location]
(let [episode (get-episode state location)]
[:main
[:h1 (or (:episode/title episode)
"Unknown episode")]
(if (-> location :location/hash-params :description)
(list
[:p (:episode/description episode)]
[:a {:href (router/location->url routes
(update location :location/hash-params dissoc :description))}
"Hide description"])
(when (:episode/description episode)
[:a {:href (router/location->url routes
(assoc-in location [:location/hash-params :description] "1"))}
"Show description"]))
[:p
[:a {:href (router/location->url routes {:location/page-id :pages/frontpage})}
"Back to episode listing"]]]))
Using the URL for state transfer only works for small pieces of data, but for things like toggling menus, sorting tables, etc, it works perfectly and even improves UX by making those states addressable.
An interesting aspect about render-episode
is that while Replicant doesn’t
allow component local state, this page/“component” has all the knowledge about
the state it depends on. All it relies on is a little help from the central
infrastructure, and it can “do” whatever it needs.
§ The router alias
Making links that use the router is a little bit cumbersome:
[:a {:href (router/location->url routes
{:location/page-id :pages/frontpage})}
"Back to the frontpage"]
As a final touch, we will introduce a routing alias that can side-chaine the route data to reduce this down to:
[:ui/a {:ui/location {:location/page-id :pages/frontpage}}
"Back to the frontpage"]
We’ll define the alias in the core
namespace. This way we can do away with the
router dependency in the ui
namespace later:
(ns parens.core
(:require [parens.router :as router]
[parens.ui :as ui]
[replicant.alias :as alias]
[replicant.dom :as r]))
(defn routing-anchor [attrs children]
(let [routes (-> attrs :replicant/alias-data :routes)]
(into [:a (cond-> attrs
(:ui/location attrs)
(assoc :href (router/location->url routes
(:ui/location attrs))))]
children)))
(alias/register! :ui/a routing-anchor)
,,,
Next we’ll update the call to Replicant’s render function. We’ll make two changes: pass the routes as alias data, and stop passing the routes to the UI elements - they no longer need to receive them explicitly.
(defn render-location [el state routes location]
(r/render
el
(ui/render-page state location)
{:alias-data {:routes routes}}))
With explicit routing out of the way, the UI namespace no longer depends
directly on the router, and render-episode
is deliciously declarative:
(defn render-episode [state location]
(let [episode (get-episode state location)]
[:main
[:h1 (or (:episode/title episode)
"Unknown episode")]
(if (-> location :location/hash-params :description)
(list
[:p (:episode/description episode)]
[:ui/a {:ui/location (update location :location/hash-params dissoc :description)}
"Hide description"])
(when (:episode/description episode)
[:ui/a {:ui/location (assoc-in location [:location/hash-params :description] "1")}
"Show description"]))
[:p
[:ui/a {:ui/location {:location/page-id :pages/frontpage}}
"Back to episode listing"]]]))
Remember that aliases behave just like any other hiccup element, so you can add
classes with :ui/a.btn
, give it attributes, and so on.
The full code-listing is available on github.
Further reading
If you have added state management to your app, you might be wondering how to combine routing and state management. The details of doing this is covered in the state management tutorials: with an atom or with Datascript.