Fulcro via re-frame
I couldn't find some cliff notes
So I wrote some
So I wanted to do a post on starting with the basics, trying to introduce key ideas as they become necessary, at some level, it would be just doing things that are straightforward even if it's not the best way to do it, but at least it would be a starting point...
Then this happened1...
To be honest, I'm mightly tempted to take a look at how that all works, but for now, I'll keep at this.
But in that same vein, let's use re-frame
as a metaphor for what we want to do and how do we do it?
Before we do that, let's get one thing out of the way, Fulco is trying to do a lot more than re-frame does, providing a bridge between frontend and backend as well as helping you to manage both, but by re-frame being relatively focused it gains a lot in simplicity in terms of ease for someone to get started in it.
However, before we get into that, if this isn't quite what you're looking for and want some high-level guidance anyway, let's cover two very different perspectives around thinking about, prototyping and designing a site and what might be helpful.
Frontend First
You want to just get something up on a screen that you can look at and tinker with. I completely understand this, a lot of people do this while prototyping some vague formless notion they have in their heads about what they want to make =)...
Some quick tips if you just want to dive in:
I'd say the first few videos of the online tutorials Tony Kay did will be useful.
Focus on just using stuff like defsc
, :ident
and :initial-state
:
(defsc ItemList [this props]
{:query [{:item-list/all-items []}]
:initial-state {:item-list/all-items []}
:ident (fn [] [:component/id ::item-list])}
(dom/p
(pr-str "...")))
(def ui-item-list (comp/factory ItemList))
Just build a bunch of small self-contained components and get used to how to style them, it might be useful to know how to wrap simple js components as well.
Here's one that I did in a few minutes by taking apart some examples + npm install:
(ns app.ui.charts
(:require [goog.object :as gobj]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
[com.fulcrologic.fulcro.dom :as dom]
["vega" :as vegas]
["vega-lite" :as vl]
["vega-embed" :as ve]))
(defn parse-vl-spec [elem spec]
(when spec
(let [opts #js {"mode" "vega-lite"
"renderer" "canvas"
"actions" false
"tooltip" {"theme" "dark"}}
js-spec (clj->js (assoc spec :$schema "https://vega.github.io/schema/vega-lite/v4.10.2.json"))]
(ve elem js-spec opts))))
(defsc Chart [this _props]
{:componentDidMount (fn [this]
(when-let [dom-node (gobj/get this "div")]
(let [spec (comp/props this)]
(parse-vl-spec dom-node spec))))
:shouldComponentUpdate (fn [this next-props _next-state]
(when-let [dom-node (gobj/get this "div")]
(let [new-spec next-props]
next-props
(parse-vl-spec dom-node new-spec)))
false)}
(dom/div {:ref (fn [r] (gobj/set this "div" r))}))
(def chart (comp/factory Chart))
Backend First
Similarly, you might just be more comfortable with working with some data structures and services and worrying about what it's going to look like a lot later.
For diving in with this viewpoint I'd say, take a look at pathom, just get comfortable with it =)... It's the backbone of how data access works on the backend and will act as your bridge to other data sources.
The talk by it's creator is also rather enlightening.
Getting started
Now that we've covered that let's discuss things from a re-frame
viewpoint.
At a high level re-frame
tries to have a really simple model, you have an app-db
which encompasses all of your application state, you can define subscriptions
which allow you to subscribe to parts of it and then you can define event handlers
to which you can dispatch events which change your application state.
Yes, there's stuff like effects and interceptors, etc but you can get pretty far with just understanding subscriptions
and events
. Well that and hiccup
, which is just a weird kind of HTML, so that's not too bad.
Ok, how does Fulco do it?
Well, let's start with the similarities, instead of subscriptions
Fulcro uses the idea of queries
to allow you to pull data out of your app-db and its equivalent of event handlers
are defmutations
.
Now let's introduce a new concept, components
, in Fulcro, queries live on components, which are basically react class-based components.
Setup
Before we take a look at how all that works, let's get some basic setup done =)...
Here's a link to a github repo to get you started =)...
You can just get started by going there, cloning the repo and then once you've got a terminal in the project folder, call the commands below to get started.
npm install
npx shadow-cljs watch main
This is also a good opportunity to install Fulcro Inspect if you haven't already.
Now that you can follow along by navigating to http://localhost:8000/
.
Let's take a look at how that all works.
Components
So let's take a look at a component and how it works, this is probably the simplest component you can write.
(defsc Thing [this props]
{} ;; <-- This is technically optional, but I find it problematic not to have it...
(dom/div
(dom/p "TEST")
(dom/p props)))
defsc
which I'm using is required by [com.fulcrologic.fulcro.components :as comp :refer [defsc]]
.
Let's take that simple example and expand it a little, so we need the client.cljs
file:
(ns fulcro-starter.client
(:require
[com.fulcrologic.fulcro.application :as app]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
[com.fulcrologic.fulcro.dom :as dom]))
(defonce app (app/fulcro-app))
(defsc Thing [this props]
{}
(dom/div
(dom/p "TEST")
(dom/p props)))
(defn ^:export init
"Shadow-cljs sets this up to be our entry-point function. See shadow-cljs.edn `:init-fn` in the modules of the main build."
[]
(app/mount! app Thing "app")
(js/console.log "Loaded"))
(defn ^:export refresh
"During development, shadow-cljs will call this on every hot reload of source. See shadow-cljs.edn"
[]
;; re-mounting will cause forced UI refresh, update internals, etc.
(app/mount! app Thing "app")
(js/console.log "Hot reload"))
Let's make this a little more visible:
(defsc Thing [this props]
{}
(dom/div
(dom/h3 "This is a Thing!")
(pr-str props)))
Now if you have Fulcro Inspect
running, you can take a peek inside the app-db of our Fulcro application by selecting the tab in the inspector.
You can see that Fulcro Inspect
has added a uuid to the app-db, it does that to track which of the possibly many fulcro apps that could be on a page it's targeting.
Now, let's start looking at how to do some basic subscription-like stuff with queries!
Queries
Now, how do we load data into our app-db? Well, there are two ways to do it:
- Fulco bridges the frontend and the backend so you can just define your data on the backend and pull it in2
- You can use the key
:initial-state
on acomponent
to specify what initial state if any it has.
Seeing as we don't want to jump into the server-side (frontend) of things just yet, let's focus on 2.
for now.
(defsc Thing [this props]
{:initial-state {:a-thing "Some stuff!"}}
(dom/div
(dom/h3 "This is a Thing!")
(pr-str props)))
So we've added :a-thing
to our component! Note that by convention components are written in TitleCase.
Now this is important, we've changed something inside an :initial-state
, Fulcro expects that whatever's within the initial state to be fixed on first load, so you will need to reload the page to see anything change!
Now you should see something like this:
Great, so adding data to :initial-state
changed what's visible inside the app-db.
Now you're probably wondering what we need queries for? Well let's take a look at having two components:
(defsc AnotherThing [this props]
{}
(dom/div
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
(def ui-another-thing (comp/factory AnotherThing))
(defsc Thing [this props]
{:initial-state {:a-thing "Some stuff!"}}
(dom/div
(dom/h3 "This is a Thing!")
(ui-another-thing props)
(pr-str props)))
Now you've probably noticed that I've defined ui-another-thing
? Well because AnotherThing
is a React class, we have to feed it into a factory. The function we get out is what we use to display the component, as I've updated the Thing
component to show.
Note: By convention, we prefix the resulting function with ui-
and kebab-case it, so for example YetAnotherThing
gives us ui-yet-another-thing
.
But what's this? I'm passing in all the props
from one component to another! How do we control what goes in? Or better yet, how can the AnotherThing
component tell anyone who wishes to call it what it wants?
Enter queries
:
(defsc AnotherThing [this props]
{:query [:a-thing]}
(dom/div
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
(def ui-another-thing (comp/factory AnotherThing))
(defsc Thing [this props]
{:initial-state {:a-thing "Some stuff!"}}
(dom/div
(dom/h3 "This is a Thing!")
(ui-another-thing props)
(pr-str props)))
Now, what's the point of this? Well, let's connect up a repl to the running shadow-cljs instance and take a look.
Shadow-cljs should have given you an nrepl port to connect to when it started up, eg: shadow-cljs - nREPL server started on port 62040
. Now connect to that port and call:
(shadow/repl :main)
Now load into the ns fulcro-starter.client
and evaluate this:
(comp/get-query AnotherThing)
#_#_=> [:a-thing]
Ah! So the component has a way of returning what it's query is when asked!
So how do we get Thing
to know what AnotherThing
wants? Just nest the queries!
But how do we nest them?
Like so?
:query (comp/get-query AnotherThing)
Err, no.
So we need to mention a few other things:
- queries should be a vector
- if a component is directly using some data, then you can use the key, but if the component is going to hand it to a child, then that data needs to be nested one level.
So let's take a look at that in stages:
- Update the
:initial-state
to add a level of nesting
(defsc Thing [this props]
{:initial-state {:an-added-level {:a-thing "Some stuff!"}}}
(dom/div
(dom/h3 "This is a Thing!")
(pr-str props)))
Now don't forget, we changed :initial-state
, so let's refresh the page!
Alright! So our state is nested now
- Add the query for
Thing
(defsc Thing [this props]
{:query [:an-added-level]
:initial-state {:an-added-level {:a-thing "Some stuff!"}}}
(dom/div
(dom/h3 "This is a Thing!")
(pr-str props)))
So now we can see that Thing
is only getting the data it queried for ie: :an-added-level
.
- Put in the
AnotherThing
component into the query
(defsc AnotherThing [this props]
{:query [:a-thing]}
(dom/div {:style {:border "1px solid black"}} ;; <-- I've added a style here to make the picture clearer
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
(def ui-another-thing (comp/factory AnotherThing))
(defsc Thing [this props]
{:query [{:an-added-level (comp/get-query AnotherThing)}]
:initial-state {:an-added-level {:a-thing "Some stuff!"}}}
(dom/div
(dom/h3 "This is a Thing!")
(ui-another-thing (:an-added-level props))
(pr-str props)))
I've added a border to make it clear where the border between AnotherThing
and Thing
is.
You can see that each component lower down in the tree only gets the data it needs.
But wait! You might say, that's only because we're passing it in! Why all the ceremony?
Well, let's try asking Thing
what it's query is!
(comp/get-query Thing)
#_#_=> [{:an-added-level [:a-thing]}]
Well what do you know, Thing
asks for AnotherThing
's query as well.
So this is the reason, because we won't always be feeding in :initial-state
to our components with all the data they need, instead we can use this query mechanism to ensure that any arbitrary tree of components can ask for exactly the data they need from Fulcro.
One other useful thing to note:
(comp/get-query AnotherThing)
#_#_=> [:a-thing]
(meta (comp/get-query AnotherThing))
#_#_=> {:component fulcro-starter.client/AnotherThing, :queryid "fulcro-starter.client/AnotherThing"}
(comp/get-query Thing)
#_#_=> [{:an-added-level [:a-thing]}]
(meta (comp/get-query Thing))
#_#_=> {:component fulcro-starter.client/Thing, :queryid "fulcro-starter.client/Thing"}
You can see here that the returned queries also have some metadata associated with them, which says which component they came from and what query generated them. Fulcro understands how to unpack this and uses this to efficiently send the data back to the component who asked for it, this is why you should always nest queries as I've shown you.
If you see something break, there might be a component that's not following this rule.
Now, everything works right? Let's just refresh the page.
Oh no! What happened? Our app-db has nil
for :an-added-level
!
We've not set our :initial-state
correctly!
Let's first introduce you to another function get-initial-state
:
(comp/get-initial-state Thing)
#_#_=> {:an-added-level {:a-thing "Some stuff!"}}
So you see just like with get-query
Fulcro has a way of getting the component's initial state, however by nesting the query as we've done:
{:query [{:an-added-level (comp/get-query AnotherThing)}]}
We're signalling to fulcro that the particular bit of state within :an-added-level
belongs to AnotherThing
, so really it should be the one we need to ask what should go there.
We can do that like this:
(defsc AnotherThing [this props]
{:query [:a-thing]
:initial-state {:a-thing "Some stuff!"}}
(dom/div {:style {:border "1px solid black"}}
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
(def ui-another-thing (comp/factory AnotherThing))
(defsc Thing [this props]
{:query [{:an-added-level (comp/get-query AnotherThing)}]
:initial-state (fn [_] {:an-added-level (comp/get-initial-state AnotherThing)})}
(dom/div
(dom/h3 "This is a Thing!")
(ui-another-thing (:an-added-level props))
(pr-str props)))
Now you should note that Thing
's :initial-state
is a function of one param, that's because within :initial-state
we're calling a function, if what :initial-state
receives is not data, then you have to use that form.
Now you've probably spotted the loophole there, right? Thing
's :initial-state
is a function, you could do anything in there!
(defsc AnotherThing [this props]
{:query [:a-thing]}
(dom/div {:style {:border "1px solid black"}}
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
(def ui-another-thing (comp/factory AnotherThing))
(defsc Thing [this props]
{:query [{:an-added-level (comp/get-query AnotherThing)}]
:initial-state (fn [_] {:an-added-level {:a-thing "Some stuff!"}})}
(dom/div
(dom/h3 "This is a Thing!")
(ui-another-thing (:an-added-level props))
(pr-str props)))
So yes, the above is perfectly valid! I personally do use this approach when I'm prototyping a small component and I haven't hooked up a server yet.
In either case, you get this:
Right, there's a bunch more stuff we could cover, but I think it's worthwhile not packing too much stuff into one blog post.
So we've seen a high-level view of how to use queries to get subscribe like behaviour from Fulco, now let's just go over the other side of things.
Mutations
Let's keep to our two components, but adjust them slightly:
(defsc AnotherThing [this props]
{:query [:a-thing]}
(dom/div {:style {:border "1px solid black"}}
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
(def ui-another-thing (comp/factory AnotherThing))
(defsc Thing [this props]
{:query [{:a-list-of-things (comp/get-query AnotherThing)}]
:initial-state (fn [_] {:a-list-of-things [{:a-thing "1 stuff"}
{:a-thing "two stuff"}
{:a-thing "\uD83D\uDCA5\uD83D\uDCA5\uD83D\uDCA5 stuff"}]})}
(dom/div
(dom/h3 "This is a Thing!")
(map ui-another-thing (:a-list-of-things props))
(pr-str props)))
There we go, now we've got a list like thing, so we should be able to mutate this to add and remove stuff!
Now let's add in a mutation.
(ns fulcro-starter.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.mutations :as m :refer [defmutation]]))
(defonce app (app/fulcro-app))
(defmutation add-thing
"Mutation: Add a thing to :a-list-of-things"
[params]
(action [{:keys [state]}]
(println (pr-str @state))))
Now ultimately we want to add a thing to our list, but let's take this whole process one step at a time =)...
(comp/transact! app [(add-thing {})])
#_#_=> #uuid"4c24d1ea-1e1e-40db-8601-c4e5f824f7ce"
{:fulcro.inspect.core/app-id "fulcro-starter.client/Thing", :a-list-of-things [{:a-thing "1 stuff"} {:a-thing "two stuff"} {:a-thing "💥💥💥 stuff"}], :fulcro.inspect.core/app-uuid #uuid "1f775d10-dfbe-4960-b90e-4a2d5927c3e0", :com.fulcrologic.fulcro.application/active-remotes #{}}
Ok so it spat out a uuid, and printed out the current state of the app-db!
Looking at what came out, we want to update :a-list-of-things
conj
'ing to it a new {:a-thing "four stuff"}, or something like that.
So let's try it!
(defmutation add-thing
"Mutation: Add a thing to :a-list-of-things"
[params]
(action [{:keys [state]}]
(println (pr-str @state))
(swap! state update :a-list-of-things conj {:a-thing "four stuff"})))
I update the defmutation
and call this in the repl again:
(comp/transact! app [(add-thing {})])
=> #uuid"76bb96cf-5840-461a-a203-bb9e77154690"
{:fulcro.inspect.core/app-id "fulcro-starter.client/Thing", :a-list-of-things [{:a-thing "1 stuff"} {:a-thing "two stuff"} {:a-thing "💥💥💥 stuff"}], :fulcro.inspect.core/app-uuid #uuid "1f775d10-dfbe-4960-b90e-4a2d5927c3e0", :com.fulcrologic.fulcro.application/active-remotes #{}}
And 🎉, the app-db state updated!
Cool =)...
Now what happens if we do it again?
So we get another one, ok, so we can keep just adding them, works like we'd expect.
So as much as this post is getting rather long and it would be wise to wrap up, I just want to show you one thing.
Basically, I want an answer to the question why Fulcro? After all, everything I've shown you can be done within re-frame
.
So other than the joy of learning new things, why bother?
Well, let's touch on something a little different.
(defsc AnotherThing [this props]
{:query [:a-thing]
:ident :a-thing} ;; <-- Just add that =)...
(dom/div {:style {:border "1px solid black"}}
(dom/h3 "This is a AnotherThing!")
(pr-str props)))
Now reload the page.
Now the page looks the same, but check out our app-db.
We've got a new bottom level entry :a-thing
💥!
So because we gave AnotherThing
an :ident
, it knows that it can normalise whatever that component is given in the app-db under that :ident
.
So now :a-list-of-things
has been changed to only contain :ident
's, so instead of this:
:a-list-of-things [{:a-thing "1 stuff"} {:a-thing "two stuff"} {:a-thing "💥💥💥 stuff"}]
we see this:
:a-list-of-things [[:a-thing "1 stuff"] [:a-thing "two stuff"] [:a-thing "💥💥💥 stuff"]]
Hmm, it's not too obvious in this form, so let's add something else to our :a-thing
:
So you should not be able to see that :a-list-of-things
only contains the :ident
of the :a-thing
, the rest of it is stored under it's own top-level key.
That's one of the cool things that Fulcro brings to the table.
By helping us to deal with normalised data when we want it, and giving us access to working with it, we reduce a lot of the issues around updating an item in the app-db and having to track all the other places copies of that item could exist and updating them as well.
So how do we mutate stuff in this form?
We use merge-component
:
(ns fulcro-starter.client
(:require
...
[com.fulcrologic.fulcro.algorithms.merge :as merge]))
...
(defmutation add-thing
"Mutation: Add a thing to :a-list-of-things"
[params]
(action [{:keys [state]}]
(println (pr-str @state))
(let [class (comp/registry-key->class :fulcro-starter.client/AnotherThing)]
(swap! state merge/merge-component class {:a-thing "four stuff"}))))
You're probably wondering where :fulcro-starter.client/AnotherThing
came from, well remember when we did this:
(meta (comp/get-query AnotherThing))
#_#_=> {:component fulcro-starter.client/AnotherThing, :queryid "fulcro-starter.client/AnotherThing"}
That's where, Fulcro maintains a registry of the components that you've defined. So one of the good ways to do this is to lookup that registry and pass the value from there. As a rule of thumb, you don't want classes sitting in your app-db.
Now enough digressing, let's call our mutate =)...
Ok, so something odd happened, if you look at our UI nothing has changed and in our app-db, there's a new entry for "four stuff" under :a-thing
, what's going on?
We'll if you look at :a-list-of-things
you'll notice that it hasn't been updated with the ident for the bit of data we added, specifically [:a-thing "four stuff"]
.
We need to tell our mutate to update that location.
(defmutation add-thing
"Mutation: Add a thing to :a-list-of-things"
[params]
(action [{:keys [state]}]
(println (pr-str @state))
(let [class (comp/registry-key->class :fulcro-starter.client/AnotherThing)]
(swap! state merge/merge-component class {:a-thing "four stuff"}
:append [:a-list-of-things]))))
And done! =)...
If you're wondering how I knew about :append
, it's one of the targeting options listed in the manual, others that I've used are :prepend
and :replace
.
Well, that was a bit of a whirlwind tour of Fulcro, there's a lot that I didn't cover, but I wanted to keep this relatively focused.
Hopefully, that helped and let me know if any part of this ended up being confusing =)...