Fulcro dynamic frontend routing, URI changes and the things we take for granted

January 12, 2022

Introduction

You start by pulling in your favorite templating system to spin up a new React application, throw in React Router, and wire up a few routes. A couple of clicks later, everything just works. But pause for a moment—something subtle but important has happened. The URL changed, your application acknowledged it, and yet no request went back to the server. The new content appeared client-side, almost instantly, with zero roundtrip involved.

Getting that same seamless experience in Fulcro, however, isn’t as straightforward. It requires weaving together the framework's built-in methods along with tools from other libraries to achieve something similar.

The smallest example of Fulcro dynamic routing in action

This example depicts just two components, one router and a couple of buttons to transition from one to the other. Components declare a route-segmentto identify the route. Transitions are applied calling the function dr/change-route with the component and the new route.

(ns app.client
  (:require
   [com.fulcrologic.fulcro.application :as app]
   [com.fulcrologic.fulcro.components :as comp :refer [defsc]]
   [com.fulcrologic.fulcro.dom :as dom]
   [com.fulcrologic.fulcro.routing.dynamic-routing :as dr :refer [defrouter]]))

(defsc Settings [_this _props]
  {:ident         (fn [] [:component/id ::settings])
   :query         [:settings]
   :initial-state {:section/id :settings :ui/loading? false}
   :route-segment ["settings"]}
  (dom/div "Settings"))

(defsc Main [_this _props]
  {:ident         (fn [] [:component/id ::main])
   :query         [:main]
   :initial-state {:section/id :main :ui/loading? false}
   :route-segment ["main"]}
  (dom/div "Main"))

(defrouter MainRouter [this {:keys [_current-state _pending-path-segment]}]
  {:router-targets [Main Settings]})

(def main-router (comp/factory MainRouter))

(defsc Root [this {:root/keys [router]}]
  {:query         [{:root/router (comp/get-query MainRouter)}]
   :initial-state {:root/router {}}}
  (dom/div
    (dom/button {:onClick #(dr/change-route this ["main"])} "Go to main")
    (dom/button {:onClick #(dr/change-route this ["settings"])} "Go to settings")
    (main-router router)))

(defonce app (app/fulcro-app))

This example shows a simple application, with one root component, one router and two target components. Targets can have different parameters that encode routing concerns. In this case the only one is the route-segment mentioned. When you click in one of the navigation buttons, the application just renders the new component, nothing else happens.

From URLs to components with Pushy

Up until now, we’ve been able to render different components on the screen based on the state of the router, but that’s only part of the story in a modern single-page application (SPA). What we really want is dynamic content that updates smoothly as users navigate, without the need to reload the entire page. At the same time, we want to preserve the familiar behavior of the back and forward buttons. Enter the HTML5 History API. Introduced in 2000 as part of the HTML5 specification, it gave us a stack-like data structure and methods to manipulate browser history directly.

To integrate this into a Fulcro app, we can use Pushy to handle URL navigation. Pushy needs two key functions to get going: one to check if a given URL matches a route in the system, and another to actually respond to the route change. With these in place, Fulcro can take full advantage of dynamic routing and the History API.

(pushy/pushy dispatch-fn match-fn)

Let’s start with the match-fn function. We could implement it in the most straightforward way: by connecting a route (as a string) with the route-segment of a component. Since a segment is made up of strings and keywords, this makes it possible to break down the URI. We simply split the string by the '/' character, and then try to match those segments with the corresponding components. While basic, this method gives us a foundation for routing logic that directly ties URLs to specific parts of the application.

(-> (str/split route #"/") (rest) (vec))

The dispatch-fnfunction should be just calling to the function that lives in the com.fulcrologic.fulcro.routing.dynamic-routing namespace change-route used already in the first example. If we combine everything we can completely set Pushy up to be configured and ready to use when the application starts.

(def history (atom nil))

(defn- init-routing [app]
  (reset!
   history (pushy/pushy
            (fn dispatch-fn [match]
              (dr/change-route app match))
            (fn match-fn [route]
              (-> (str/split route #"/") (rest) (vec)))))
  (pushy/start! @history))

(defonce app (app/fulcro-app {:started-callback init-routing}))

To change from one route to another, we just need to call pushy/set-token! with the new route:

(pushy/set-token! @history uri)

that uses internally the goog.History library, set-token method. The current history element can be set with this method, or replaced with replace-token.

The case to be made for a proper routing library

We’ve just walked through how a basic setup can easily map URIs to components, and for a small application, that simplicity works just fine. But as your app scales, you’ll start craving a more structured approach to keep the growing complexity under control. This is where routing libraries come into play, each with its own set of trade-offs. For this example, we’ll focus on Reitit, widely supported, compatible with ClojureScript, and packed with a rich set of features straight out of the box.

The simplest way to bring Reitit into the mix is by configuring a router and modifying the match-fn we previously implemented. Rather than relying on a custom solution, we now let Reitit handle the heavy lifting.

(def router
  (reitit/router
   [["/settings" ]
    ["/"]]))

(fn match-fn [route]
  (reitit/match-by-path router route))

When routes do match, Reitit returns a data structure with information about the route:

#reitit.core.Match{
  :template "/settings", 
  :data {}, 
  :result nil, 
  :path-params {}, 
  :path "/settings"
}

Just with these two changes, Reitit would help us to know if a URI is in the system, it's valid within the context of the table that we have specified. But we are still left to do the same manual work we did before to map from URLs to segments. However, with Reitit we can append data to routes:

(def router
  (reitit/router
   [["/settings" {:route-segment ["settings"], :name ::settings}]
    ["/" {:route-segment ["main"] :name ::main}]]))

Programatically generating routes

Maintaining this data structure manually would quickly become a headache. We’d constantly need to keep the route segments in sync with the data held in the router. But instead of juggling that complexity ourselves, we can generate this structure programmatically from the components. This process involves, among other things, converting route segments into URIs. To make things smoother, we can extend our components by adding parameters that store route-related information directly.

(defsc Settings [_this _props]
  {...
   :route-segment ["settings"]
   :route-name    ::settings
   ...})

And we can transform this information into a Reitit router:

(defn- build-router-from-components [components]
  (mapv
   (fn [component]
     (let [{:keys [route-segment route-name]} (comp/component-options component)
           url (str "/" (str/join "/" route-segment))]
       [url {:route-segment route-segment :name route-name}]))
   components))

(->>  (comp/component-options MainRouter)
      (:router-targets)
      (build-router-from-components)
      (reitit/router))

Now with the router in place, we need to dispatch accordingly, from the route data:

(defn dispatch-fn [match]
  (dr/change-route app (-> match :data :route-segment)))

And as we did before, we can wire everything up when the application starts:

(def router (atom nil))

(defn- build-router-from-components [components] ...)

(defn- init-routing [app]
  (let [reitit (->> (comp/component-options MainRouter)
                    (:router-targets)
                    (build-router-from-components)
                    (reitit/router))

        pushy  (pushy/pushy
                (fn dispatch-fn [match]
                  (dr/change-route app (-> match :data :route-segment)))
                (fn match-fn [route]
                  (reitit/match-by-path @router route)))]

    (reset! router reitit)
    (reset! history pushy)
    (pushy/start! @history)))

(defonce app (app/fulcro-app {:started-callback init-routing}))

Reitit matches the query and then we instruct Fulcro to show the component we want. We could also specify timeouts, query parameters and other configurations.

(defsc Settings [_this _props]
  {...
   :will-enter    (fn [_app route-params]
                    ; Route params will contain the query params read
                    (dr/route-immediate [:section/by-id ::settings]))})

(fn dispatch-fn [match]
  (dr/change-route 
   app 
   (-> match :data :route-segment) 
   (-> match :query-params)))

Path parameters

Imagine we have a component that declares a route with multiple path parameters. In this case, the route contains two parameters: :type and :id. This setup allows the component to dynamically respond based on the values passed in the URL.

(defsc CustomerInformation [_this _props]
  {:ident          (fn [] [:section/by-id ::customer-info])
   ...
   :route-segment  ["customer" :type :id]
   ...)})

When dispatching the route we need to build the segment from the information provided by the router in the path-params collection:

(fn dispatch-fn [match]
  (dr/change-route
    app
    (reduce
    (fn [segments k]
      (conj segments (get (-> match :path-params) (keyword (name k)) k)))
    []
    (-> match :data :route-segment))
    (-> match :query-params)))

Reitit brings a lot more to the table than we can cover here. Beyond simple routing, it offers powerful features like validation, conflict resolution, coercion, and more. These tools give developers the flexibility to manage complex routing scenarios while ensuring the data flowing through routes is handled safely and consistently. While we’re just scratching the surface, these capabilities make Reitit a robust choice for scaling applications.