Skip to content

Instantly share code, notes, and snippets.

@roman01la
Last active October 22, 2022 12:07
Show Gist options
  • Save roman01la/b939e4f2341fc2f931e34a941aba4e15 to your computer and use it in GitHub Desktop.
Save roman01la/b939e4f2341fc2f931e34a941aba4e15 to your computer and use it in GitHub Desktop.
ClojureScript REPL Workflow

ClojureScript REPL Workflow

Short write up on using REPL when developing UIs in ClojureScript.

Hot-reload on save or Eval in REPL?

Everyone's standard approach to hot-reloading is to use a tool (Figwheel or shadow-cljs) that reloads changed namespaces automatically. This works really well: you change the code, the tool picks up changed files, compiles namespaces and dependants, notifies REPL client which then pulls in compiled changes, and re-runs a function that re-renders UI.

The other approach is to use ClojureScript's REPL directly and rely only on eval from the editor. This more or less matches Clojure style workflow. This approach might be useful when you don't want tools overhead or hot-reloading becomes slow for you or you just used to this style of interactions. Also changing code doesn't always mean that you want to reload all the changes. On the other hand it is very easy to change a couple of top-level forms and forget to eval one of them.

For this to work you would have to run ClojureScript REPL env from Clojure REPL. Below is a handy function to start it from the REPL.

(ns user
  (:require [cljs.repl :as repl]
            [cljs.repl.browser :as browser]))

(defn repl []
  (let [env (browser/repl-env
              :port 3000 ;; starts web server on localhost:3000
              :launch-browser false ;; do not launch browser automatically, I find this annoying
              :static-dir ["." "out" "resources/public"])] ;; serves assets from those dirs
    (repl/repl env)))

Executing (repl) will start ClojureScript REPL and print Waiting for browser to connect to http://localhost:3000 ..., when you open http://localhost:3000 in your browser, it will load REPL client and connect to REPL server. Initially nothing is executed, you have to load your entry point core namespace manually from the REPL in your editor.

Once that is done you can edit code and eval forms as usual. One thing that is annoying in UI development with this approach is re-rendering UI manually after every eval, so all those changes are reflected on the screen. Here's a useful snippet that you can put in your ClojureScript code and eval when REPL starts. This hooks into REPL client and runs provided render function after every successful evaluation.

(comment
  (let [eval-js (.. js/clojure -browser -repl -evaluate_javascript)]
    (set! (.. js/clojure -browser -repl -evaluate_javascript)
          (fn [& args]
            (let [ret (apply eval-js args)]
              (when (.includes ret ":status :success")
                (render)) ;; your render function
              ret)))))

Preserving state between reloads

This one is more related to tools like Figwheel or shadow-cljs that reload the whole namespace on every change, but still can be useful even when reloading manually.

Every UI code has stateful parts, like global state in re-frame or global event handlers or routing library that keeps its state internally or WebSocket subscription, etc. Sometimes you just want code to be executed only once, not after every reload. defonce should be used in such cases, it evaluates binding only once.

(defonce db (atom {}))

⚠️ But be aware that binding to the result of calling JavaScript function that returns undefined will break this, because defonce checks for existence of the var which involves undefined check.

(defonce global-handler
  (.addEventListener "keydown" handle-keydown)) ;; returns `undefined`

Put a random value, for example nil, as a returning value instead

(defonce global-handler
  (do (.addEventListener "keydown" handle-keydown)
      nil))

REPL & asynchrony

Asynchrony might be annoying when working in REPL, since you won't get the value back, but rather a promise or something that eventually resolves or prints. Consider this example fetching data and returning a string in a promise.

(-> (js/fetch url)
    (.then #(.text %)))

Because you don't get the value itself you'd have to perform the request every time you change the code and want to see the result. What I'm usually doing in such cases is using defonce to cache raw data and then write code that works with it.

(defn fetch-url [url]
  (-> (js/fetch url)
      (.then #(.text %))))
      
@state ;; this will have response string, when evaluated after the form below 
      
(comment
  (defonce state (atom nil)
  (-> (fetch-url url)
      (.then #(reset! state %))))

REPL and global state in re-frame

One benefit of using global state, such as in re-frame, is that it's easily accessible from REPL. Evaluating @re-frame.db/app-db will give you the whole state of your system. More to that, you can deref re-frame subscriptions in global context.

@(subscribe [:issues/by-id 1])

When a subscription is parameterized on component's arguments it becomes less trivial to run it from the REPL

(defn issue-view [id]
  @(subscribe [:issues/by-id id]))

But it's possible to capture those arguments into global vars, for example with a small macro like this one

(defmacro capture [syms & body]
  `(do
     ~@(for [sym syms]
         `(def ~sym ~sym))
     ~@body))

The macro creates defs for every provided symbol that matches argument name. When evaluating the component, after it has been re-rendered and the scope was captured you can eval id in a global context to inspect the value.

(defn issue-view [id]
  (capture [id]
    @(subscribe [:issues/by-id id])))

⚠️ Note that creating global vars pollutes namespaces and might result in unexpected results when there's a “useful” var with the same name is defined.

Conclusion

  • Additional tools are not strictly required to be productive
  • Global state is a win win for REPL workflow
  • Asynchrony is not ideal for REPL workflow
  • Macros are fun, but should be used carefully

Feel free to comment and explain your REPL workflow in ClojureScript. Tips for Node.js and other runtimes are welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment