In this post, I'll introduce how to develop ClojureScript Single Page Application by using the combination of integrant and re-frame.

I also introduced that in the previous post. But as the previous example was special case, Ethereum DApp, it was a little complex to understand. So I develop an example again in pure cljs.

https://github.com/223kazuki/re-integrant-app

I tentatively call this pattern "re-integrant".

Overview

The SPA developed in this application pattern consists of three layers.

Integrant layer that manages the whole lifecycle of the application. Re-frame layer that manages app-db that is updated by user interaction. Reagent layer represents view that subscribes and dispatches app-db via re-frame handlers.

And the application is divided into modules by integrant. Re-frame handlers are registered in each modules' namespaces when the modules initialize.

Project Structure

I adopted the similar structure to the server side integrant application like duct template. And I created dev directory to manage development settings.

. ├── project.clj ├── dev │ ├── resources │ │ └── dev.edn │ └── src │ └── user.cljs ├── resources │ ├── config.edn │ └── public │ ├── css │ │ └── site.css │ └── index.html └── src └── re_integrant_app ├── core.cljs ├── module │ ├── app.cljs │ ├── moment.cljs │ └── router.cljs ├── utils.cljc └── views.cljs

The versions of primary libraries are bellow.

[org.clojure/clojure "1.9.0"] [org.clojure/clojurescript "1.10.339"] [reagent "0.8.0"] [re-frame "0.10.5"] [integrant "0.7.0"]

The build settings of ClojureScript in each profiles are bellow. Figwheel executes cljs.user/reset on jsload during development.

:cljsbuild {:builds [{:id "dev" :source-paths ["src" "dev/src"] :figwheel {:on-jsload cljs.user/reset} :compiler {:main cljs.user :output-to "resources/public/js/compiled/app.js" :output-dir "resources/public/js/compiled/out" :asset-path "js/compiled/out" :source-map-timestamp true :preloads [devtools.preload day8.re-frame-10x.preload] :closure-defines {"re_frame.trace.trace_enabled_QMARK_" true "day8.re_frame.tracing.trace_enabled_QMARK_" true} :external-config {:devtools/config {:features-to-install :all}}}} {:id "min" :source-paths ["src"] :compiler {:main re-integrant-app.core :output-to "resources/public/js/compiled/app.js" :optimizations :advanced :closure-defines {goog.DEBUG false} :pretty-print false}} {:id "test" :source-paths ["src" "test"] :compiler {:main re-integrant-app.runner :output-to "resources/public/js/compiled/test.js" :output-dir "resources/public/js/compiled/test/out" :optimizations :none}}]}

I added :module/moment that provides Moment in each seconds.

{:re-integrant-app.module/moment {} :re-integrant-app.module/router ["/" {"" :home "about" :about}] :re-integrant-app.module/app {:mount-point-id "app" :routes #ig/ref :re-integrant-app.module/router :moment #ig/ref :re-integrant-app.module/moment}}

Module

It doesn't do anything different from what I introduced in the last post. But I wrote process explicitly for the sake of ease. I added multimethods, reg-sub and reg-event to that we can add the implementation of handlers. We can register all of handlers by using it when the module initializes. The implementation of ::now subscription means that it provides Moment object in each seconds only when it's subscribed. (Please refer to Subscribing to External Data)

;; Initial DB (def initial-db {::now nil}) ;; Subscriptions (defmulti reg-sub identity) (defmethod reg-sub ::now [k] (re-frame/reg-sub-raw k (fn [app-db _] (let [close (create-loop #(re-frame/dispatch [::fetch-now]) 1000)] (reagent.ratom/make-reaction #(get-in @app-db [::now]) :on-dispose close))))) ;; Events (defmulti reg-event identity) (defmethod reg-event ::init [k] (re-frame/reg-event-db k [re-frame/trim-v] (fn-traced [db _] (-> db (merge initial-db) (assoc ::now (js/moment)))))) (defmethod reg-event ::halt [k] (re-frame/reg-event-db k [re-frame/trim-v] (fn-traced [db _] (->> db (filter #(not= (namespace (key %)) (namespace ::x))) (into {}))))) (defmethod reg-event ::fetch-now [k] (re-frame/reg-event-db k [re-frame/trim-v] (fn-traced [db _] (js/console.log "tick!") (assoc db ::now (js/moment))))) ;; Init (defmethod ig/init-key :re-integrant-app.module/moment [k {:keys [:dev]}] (js/console.log (str "Initializing " k)) (when dev (js/console.log "It's dev mode.")) (let [subs (->> reg-sub methods (map key)) ;; Get the keywords of handlers. events (->> reg-event methods (map key))] ;; Same as above. (->> subs (map reg-sub) doall) ;; Execute multimethod and register handlers. (->> events (map reg-event) doall) ;; Same as above. (re-frame/dispatch-sync [::init]) {:subs subs :events events})) ;; Halt (defmethod ig/halt-key! :re-integrant-app.module/moment [k {:keys [:subs :events]}] ;; Get the keywords of handlers. (js/console.log (str "Halting " k)) (re-frame/dispatch-sync [::halt]) (->> subs (map re-frame/clear-sub) doall) ;; Clear handlers. (->> events (map re-frame/clear-event) doall)) ;; Same as above.

View

It's not different from what was in the previous post. Only when home-panel which is subscribing ::moment/now opens, :module/moment provides Moment objects.

(defn home-panel [] (let [now (re-frame/subscribe [::moment/now])] (fn [] [:div [sa/Segment [:h2 "Now"] (when-let [now @now] (str now))]]))) (defn about-panel [] (fn [] [:div "About"])) (defn none-panel [] [:div]) (defmulti panels identity) (defmethod panels :home-panel [] #'home-panel) (defmethod panels :about-panel [] #'about-panel) (defmethod panels :none [] #'none-panel) (def transition-group (reagent/adapt-react-class js/ReactTransitionGroup.TransitionGroup)) (def css-transition (reagent/adapt-react-class js/ReactTransitionGroup.CSSTransition)) (defn app-container [] (let [title (re-frame/subscribe [:re-integrant-app.module.app/title]) active-panel (re-frame/subscribe [::router/active-panel])] (fn [] [:div [sa/Menu {:fixed "top" :inverted true} [sa/Container [sa/MenuItem {:as "span" :header true} @title] [sa/MenuItem {:as "a" :href "/"} "Home"] [sa/MenuItem {:as "a" :href "/about"} "About"]]] [sa/Container {:className "mainContainer" :style {:margin-top "7em"}} (let [panel @active-panel] [transition-group [css-transition {:key panel :classNames "pageChange" :timeout 500 :className "transition"} [(panels panel)]]])]])))

It's also not changed so much. You need to require all modules because we can't use integrant's load-namespaces in ClojureScript. And I defined config as an atom because I want to change it during development.

(ns re-integrant-app.core (:require [integrant.core :as ig] [re-integrant-app.module.app] [re-integrant-app.module.router] [re-integrant-app.module.moment]) (:require-macros [re-integrant-app.utils :refer [read-config]])) (defonce system (atom nil)) (def config (atom (read-config "config.edn"))) (defn start [] (reset! system (ig/init @config))) (defn stop [] (when @system (ig/halt! @system) (reset! system nil))) (defn ^:export init [] (start))

It's the development setting. I set :dev true in :module/moment to check if it's reflected.

{:re-integrant-app.module/moment {:dev true}}

It's the main namespace during development. It loads dev.edn and merge it to core/config. Figwheel call reset on jsload.

(ns cljs.user (:require [re-integrant-app.core :refer [system config start stop]] [meta-merge.core :refer [meta-merge]]) (:require-macros [re-integrant-app.utils :refer [read-config]])) (enable-console-print!) (println "dev mode") (swap! config #(meta-merge % (read-config "dev.edn"))) (defn reset [] (stop) (start))

Development

You can start Figwheel server and open cljs repl by following command. When you save the code, Figwheel detect that, build it and reflect it to browser.

% lein dev Figwheel: Cutting some fruit, just a sec ... Figwheel: Validating the configuration found in project.clj Figwheel: Configuration Valid ;) Figwheel: Starting server at http://0.0.0.0:3449 Figwheel: Watching build - dev Figwheel: Cleaning build - dev Compiling build :dev to "resources/public/js/compiled/app.js" from ["src" "dev/src"]... Successfully compiled build :dev to "resources/public/js/compiled/app.js" in 29.414 seconds. Figwheel: Starting CSS Watcher for paths ["resources/public/css"] Launching ClojureScript REPL for build: dev Figwheel Controls: (stop-autobuild) ;; stops Figwheel autobuilder (start-autobuild id ...) ;; starts autobuilder focused on optional ids (switch-to-build id ...) ;; switches autobuilder to different build (reset-autobuild) ;; stops, cleans, and starts autobuilder (reload-config) ;; reloads build config and resets autobuild (build-once id ...) ;; builds source one time (clean-builds id ..) ;; deletes compiled cljs target files (print-config id ...) ;; prints out build configurations (fig-status) ;; displays current state of system (figwheel.client/set-autoload false) ;; will turn autoloading off (figwheel.client/set-repl-pprint false) ;; will turn pretty printing off Switch REPL build focus: :cljs/quit ;; allows you to switch REPL to another build Docs: (doc function-name-here) Exit: :cljs/quit Results: Stored in vars *1, *2, *3, *e holds last exception object Prompt will show when Figwheel connects to your application [Rebel readline] Type :repl/help for online help info ClojureScript 1.10.339 dev:cljs.user=>

And as I created user.cljs as main namespace, we can get config, rewrite it and reset system in repl.

dev:cljs.user=> @config {:re-integrant-app.module/moment {:dev true}, :re-integrant-app.module/router ["/" {"" :home, "about" :about}], :re-integrant-app.module/app {:mount-point-id "app", :routes {:key :re-integrant-app.module/router}, :moment {:key :re-integrant-app.module/moment}}} dev:cljs.user=> (swap! config update-in [:re-integrant-app.module/moment :dev] not) {:re-integrant-app.module/moment {:dev false}, :re-integrant-app.module/router ["/" {"" :home, "about" :about}], :re-integrant-app.module/app {:mount-point-id "app", :routes {:key :re-integrant-app.module/router}, :moment {:key :re-integrant-app.module/moment}}} dev:cljs.user=> (reset) {:re-integrant-app.module/moment {:subs (:re-integrant-app.module.moment/now), :events (:re-integrant-app.module.moment/init :re-integrant-app.module.moment/halt :re-integrant-app.module.moment/fetch-now)}, :re-integrant-app.module/router {:subs (:re-integrant-app.module.router/active-panel :re-integrant-app.module.router/route-params), :events (:re-integrant-app.module.router/init :re-integrant-app.module.router/halt :re-integrant-app.module.router/go-to-page :re-integrant-app.module.router/set-active-panel), :router {:history #object[pushy.core.t_pushy$core31222], :routes ["/" {"" :home, "about" :about}]}}, :re-integrant-app.module/app {:subs (:re-integrant-app.module.app/title), :events (:re-integrant-app.module.app/init :re-integrant-app.module.app/halt :re-integrant-app.module.app/set-title), :container #object[HTMLDivElement [object HTMLDivElement]]}}

Summary

In this post, I introduced how to develop ClojureScript Single Page Application by using the combination of integrant and re-frame. Although it is a little thick stack, you can adopt it to a complicated SPA that has a lot of depedencies, has complex lifecycle and need to change settings depending on profiles.

Refferences