Electric is a library/framework/language that lets you write reactive single-page web apps in Clojure/ClojureScript without having to deal with network IO. You can write code as if the frontend and backend were both in the same "place," and Electric handles shuttling data across the network for you.
By default, Biff uses htmx to handle network IO. htmx is very lightweight and is a good fit for simple UIs (probably most UIs), but if you're developing something complex and/or collaborative (like, say, a board game), you might want to try out Electric. In general I think a good approach is to start with htmx and then consider introducing Electric if/when things start to get hairy.
I've created a git repository that contains the default Biff starter project, modified to use Electric instead of htmx. You can clone it and run bb dev
to try it out. If you're already a Biff user and have wanted to try out Electric, this should come in handy. If you haven't used Biff before, you can think of this as an alternative to the official Electric starter apps that has a bunch of additional stuff included (like authentication, routing, XTDB helpers + schema, ...).
A note from the readme:
View the latest commit to see how this differs from the regular Biff example app. To use Electric in your own Biff project, it's recommended to create a Biff app the normal way and then manually apply the changes in this project's latest commit. This ensures that your project will be created with the latest version of Biff—this repo won't necessarily be upgraded to future Biff releases.
So again, if you'd like to try it out, head over to the git repo. Otherwise, read on for some additional commentary.
The main things to understand about integrating Electric into your Biff project are:
The first one is simple: when your Electric code is being evaluated, the hyperfiddle.electric/*http-request*
dynamic var will be bound to the request map that initiated the websocket connection. Since Biff merges the system map with incoming requests, that dynamic var will include everything in the system map.
So if you e.g. need to submit a transaction, you can use e/*http-request*
where you would have normally used ctx
:
...
(dom/on "click" (e/fn [e]
(when (not-empty text)
(e/server
(biff/submit-tx e/*http-request*
[{:db/doc-type :msg
:msg/user (:xt/id user)
:msg/text text
:msg/sent-at :db/now}]))
(reset! !text ""))))
For querying and reacting to data in XTDB, take a look at the signals.clj file. (Disclaimer: I have no idea if "signals" is the right term for what's in that file. Moving on...) The key concepts here are that you need to load some data from XTDB when the relevant part of your app loads initially, and then you need to potentially update that data whenever a new transaction gets indexed.
That file contains an xt-signal
helper function to do just that. You define an initial value and a reducer function. The reducer takes that initial value you provided along with a new XT transaction and returns an updated value. Given those two things, xt-signal
can handle the remaining wiring for you. Here we define a user
signal/whatever for the document corresponding to the current signed-in user:
(defn user [{:keys [biff.xtdb/node session] :as ctx}]
(let [db (xt/db node)
initial-value (xt/entity db (:uid session))
reducer (fn [user tx]
(or (->> (::xt/tx-ops tx)
(keep (fn [[op maybe-doc]]
(when (and (= op ::xt/put)
(= (:xt/id maybe-doc)
(:xt/id initial-value)))
maybe-doc)))
first)
user))]
(xt-signal (merge ctx {::init initial-value ::reducer reducer}))))
Back in our UI code, we can use this function like so:
(e/def user)
(e/defn SignOut []
(dom/div
(dom/text "Signed in as " (e/server (:user/email user)) ". ")
...))
(e/defn App []
(e/server
(binding [user (new (signals/user e/*http-request*))]
(e/client
(dom/div
(SignOut.)
...)))))
One fairly unpolished piece of this repo is the way I handle the production build. The default way to deploy Biff is on a VPS, with a bb deploy
task that uploads your code with git push
and your config + generated files with rsync
. Ideally, you probably want to compile your JS bundle locally and then rsync it to the server, the same way Biff does it for your Tailwind build. However I was too lazy to redefine the deploy
and soft-deploy
tasks in this repo, so instead I'm just building the JS bundle on app startup. It adds 20-30 seconds to the startup time on my local machine, so on a small server it will take... longer. This all may be a moot point if/when I set up container-based deployment for Biff (with Fly), which I may actually get around to soon.
Published by Jacob O'Bryant on 15 Jun 2023