Re-frame with the HTML history api
The default routing in the re-frame template is hash-based, which may be just what you need if you want to deploy it on a "dumb server".
If you can make the server forward all requests to where your app lives, however, (and don't need to support ancient browsers) then you can use the HTML history api. It makes your fancy SPA seem like an old-school server-responds-with-html website. Which is good, cause that's how people expect the web to work. (The back button, links, etc.)
The first part of this blog post does a good job of explaining the lay of the land when it comes to client side routing. (In the second part they implement a router, but we'll use one that someone else built.)
Ingredients
We'll interact with html history through pushy, define our routes using bidi and use netlify for "serverless" hosting.
Basically someone already did all the hard work, all that's left is to glue the pieces together.
The client
Setting up clojurescript by hand can be fiddly, so we'll use the re-frame template.
Start by summoning a re-frame project from the void.
lein new re-frame spa-routing +cider +routes
(You can skip the +cider
part if you use another editor/ide.)
+routes
means that it will already be set up with routing via secretary.
It's nice to use this a comparison, and we'll keep the subscription and event handler as-is.
(Existing code that's irrelevant is represented by ,,,
.)
Deps
We need to depend on pushy and bidi.
project.clj
(defproject spa-routing "0.1.0-SNAPSHOT"
:dependencies [,,,
[kibu/pushy "0.3.8"]
[bidi "2.1.2"]
,,,]
,,,)
Routes
Then the routes themselves. The code in src/cljs/spa-routing/routes.cljs
both defines the routes and connects them to the browser. It looks like this:
(ns spa-routing.routes
(:require-macros [secretary.core :refer [defroute]])
(:import goog.History)
(:require [secretary.core :as secretary]
[goog.events :as events]
[goog.history.EventType :as EventType]
[re-frame.core :as re-frame]))
(defn hook-browser-navigation! []
(doto (History.)
(events/listen
EventType/NAVIGATE
(fn [event]
(secretary/dispatch! (.-token event))))
(.setEnabled true)))
(defn app-routes []
(secretary/set-config! :prefix "#")
;; --------------------
;; define routes here
(defroute "/" []
(re-frame/dispatch [:set-active-panel :home-panel]))
(defroute "/about" []
(re-frame/dispatch [:set-active-panel :about-panel]))
;; --------------------
(hook-browser-navigation!))
This mixes "what are the routes" with "what to do when a route changes" and "how to hook it up to the browser".
We'll put the parts where it's glued to re-frame and the browser elsewhere and reserve this namespace purely for defining the routes.
Change src/cljs/spa-routing/routes.cljs
so it looks like this:
(ns spa-routing.routes
(:require
[bidi.bidi :as bidi]))
(def routes
["/" {"" :home-panel
"about" :about-panel}])
(def match (partial bidi/match-route routes))
(def path (partial bidi/path-for routes))
Wiring
Now that we have our routes, we need to hook them up to the browser and to re-frame.
I like to do this in src/cljs/spa-routing/core.cljs
.
(ns spa-routing.core
(:require ,,,
[pushy.core :as pushy]
[spa-routing.routes :as routes]
,,,))
(defn start-routing! []
(pushy/start! (pushy/pushy #(re-frame/dispatch [:set-active-panel %])
#(:handler (routes/match %)))))
(defn dev-setup [] ,,,)
(defn mount-root [] ,,,)
(defn ^:export init []
(start-routing!)
,,,)
As you can see, pushy takes two functions. What to do when there's a match and the matching function. We hook this up to the existing re-frame event that came with the +routes
option and the match
function that we defined in routes.cljs
.
This is much nicer than using the history api through interop.
Views
The original template uses strings for the routes in src/cljs/spa-routing/views.cljs
. This is quite fragile, so we'll take advantage of fact that bidi is bi-directional by using the path
function we made in routes.cljs
.
(ns spa-routing.views
(:require [re-frame.core :as re-frame]
[spa-routing.routes :as routes])) ;; <= require routes
(defn home-panel []
(let [name (re-frame/subscribe [:name])]
(fn []
[:div (str "Hello from " @name ". This is the Home Page.")
[:div [:a {:href (routes/path :about-panel)} ;; <= Use helper here
"go to About Page"]]])))
(defn about-panel []
(fn []
[:div "This is the About Page."
[:div [:a {:href (routes/path :home-panel)} ;; <= and here.
"go to Home Page"]]]))
,,,
The server
The server needs to forward all requests to the same index.html
where our app lives. If you have access to the server you can configure it to do this. Or you can use netlify for hosting and have them do it. Yay, serverless other people's servers.
You'll need an account with netlify (they've got a free tier and don't ask for credit card details up front).
You could just drag and drop the folder containing your app into their ui, but this is 2017, and your app is probably already on one of github, bitbucket or gitlab.
If you give netlify access to the repo, they'll build and deploy it automatically any time you push to the specified branch.
There are three config options you need to be aware of.
Branch to deploy
This is the branch you want to deploy. Netlify will re-build and -deploy your site every time you push to it.
Build command
This is the command that builds your site.
lein clean && lein cljsbuild once min`
Publish directory
The directory to serve from.
resources/public
_redirects
The last part of the puzzle is telling netlify to just forward anything to the app. That is accomplished by having a special _recirects
file in your publish directory.
There are all sorts of things you can do with it, but what we want is to simply pass everything on to our SPA. For that, the incantation is /* / 200
.
# from the root of the project
echo "/* / 200" >> resources/public/_redirects
And that's it. Now, whenever you push to the specified branch the site's built and deployed automatically.
The code in this post can be found in this commit and the site is live here.