12 May 2020

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.

initial app-db

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:

  1. Fulco bridges the frontend and the backend so you can just define your data on the backend and pull it in2
  2. You can use the key :initial-state on a component 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:

the app-db after adding :a-thing

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:

  1. queries should be a vector
  2. 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:

  1. 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!

app-db after adding a level of nesting

Alright! So our state is nested now

  1. 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)))

app-db after a query to Thing

So now we can see that Thing is only getting the data it queried for ie: :an-added-level.

  1. 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)))

app-db after adding AnotherThing to the Thing query

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!

reload breaks bad initial-state

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:

reload with good initial-state

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)))

starting with mutations

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 #{}}

after updating with add-thing

And 🎉, the app-db state updated!

Cool =)...

Now what happens if we do it again?

after updating with add-thing twice

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.

after adding an ident

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:

Temporarily added more data to show normalisation

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 =)...

after updating with add-thing+ident

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]))))

after updating with add-thing+ident+append

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 =)...

Tags: re-frame beginner fulcro