September 4, 2017

Re-frame with the HTML history api

  1. Ingredients
  2. The client
    1. Deps
    2. Routes
    3. Wiring
    4. Views
  3. The server
    1. Branch to deploy
    2. Build command
    3. Publish directory
    4. _redirects

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.

Tags: netlify re-frame routing clojurescript SPA