1. Prologue
Warning
|
This book covers Fulcro 2.0. New applications should start with version 3.0, and use The Developer’s Guide for that version. |
1.1. About This Book
This is a stand-alone book for Fulcro developers that can be used by beginners and experienced developers and covers most of the library in detail. Fulcro has a pretty extensive set of resources on the web tailored to fit your learning style. There is this book, YouTube videos, an interactive tutorial, and full-blown sample applications.
A lot of time and energy went into creating these libraries and materials and providing them free of charge. If you find them useful please consider contributing to the project. Also contributing fixes to this guide is appreciated.
This book includes quite a bit of live code. Live code demos with their source look like this:
(ns book.example-1
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :refer [defmutation]]
#?(:cljs [fulcro.client.dom :as dom]
:clj
[fulcro.client.dom-server :as dom])))
(defmutation bump-number [ignored]
(action [{:keys [state]}]
(swap! state update :ui/number inc)))
(defsc Root [this {:keys [ui/number]}]
{:query [:ui/number]
:initial-state {:ui/number 0}}
(dom/div
(dom/h4 "This is an example.")
(dom/button {:onClick #(prim/transact! this `[(bump-number {})])}
"You've clicked this button " number " times.")))
All of the full stack examples use a mock server embedded in the browser to simulate the interaction, but the source that you’ll read for the application is identical to what you’d write for a real server.
Warning
|
If you’re viewing this directly from the GitHub Fulcro repository then you won’t see the live code! Use http://book.fulcrologic.com instead. |
The mock server has a built-in latency to simulate a moderately slow network so you can observe behaviors over time. You can control the length of this latency in milliseconds using the "Server Controls" in the upper-right corner of this document (if you’re reading the HTML version with live examples).
1.1.1. Common Prefixes and Namespaces
Many of the code examples assume you’ve required the proper namespaces in your code. This book adopts the following general set of requires and aliases:
(ns your-ns
(:require [fulcro.client.primitives :as prim :refer [defsc defui]]
[fulcro.client.dom :as dom]
[fulcro.util :as util]
[fulcro.client.util :as cutil]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.server :as server :refer [defquery-root defquery-entity]]
[fulcro.client :as fc]])
others will be identified as they are used.
The next chapter, "Getting Started", is an exception. The code you see in that chapter is meant to be added to a template that you create and run on your own machine.
2. Getting Started
This chapter takes you through a step-by-step guide of how to go from nothing to a full-stack basic application. Concepts are introduced as we go, and given a very cursory definition in the interest of concision. Once you’ve got the general idea you can use other materials to refine your understanding.
The Leiningen template is the very quickest way to get started. It gives you a number of useful things like workspaces (a place to build components and even apps in cards), production builds, CI integration and more while also giving you the minimal amount of actual code. This can save you hours of setup.
Important
|
This document assumes you’re working with Fulcro 2.6 and above. Any differences should be minor. |
2.1. Install Fulcro Inspect
You should use Chrome for development because we have a developer tool called Fulcro Inspect that is available
from the Chrome store for free. There are also devtools that are installed via preloads which will autoformat
EDN that is sent via console.log
. When properly configured, these tool will let you view the internal state of your
Fulcro application, the local and remote transaction history, save "snapshots" of state so you can quickly retry a
UI flow by hitting a "reset" button, "scroll" over state history and watch the UI change, run queries (with autocomplete
if you’re using Pathom) against your EQL server, and even (via binaryage devtools) source-level debug your Fulcro application in CLJS with
stack frame analysis and proper clojure names for things! Doing without either of these tools will be like working
in the dark.
Fulcro Inspect is a free extension for Chrome, and Binaryage devtools is a library that can be injected via a preload. The setup for these is done automatically when you use the Fulcro lein template, but you still have to install the chrome extension via Chrome itself.
2.2. Project setup
You can get a basic working app using:
$ lein new fulcro app
This gives you a shell of a project that still contains everything you’d want to set up (workspaces, testing, a development web server, shadow-cljs, etc.) without much actual code to understand or delete.
You should stop here for a moment and read the README in your generated project to see how that is laid out.
2.2.1. Starting the Compiler
The Fulcro template uses shadow-cljs
. We’ve been using this tool for over a year and it is a really great experience.
The additions over the stock Clojurescript compiler make for a much more polished experience.
It allows you to use the normal js ecosystem of components very easily, is really good about caching, doesn’t get easily
confused (I almost never have to do a clean of project builds, which I used to have to do with the standard tools
all the time). You should check out the User’s Guide when
you have a chance, but for now you can simply start the compiler server:
$ npm install # only need to do this once
$ npx shadow-cljs server
and navigate to the URL it prints out for the compiler server (usually http://localhost:9630). You can then use the "Builds" menu to turn on/off different client builds and see the progress live as it happens!
It also has hot-code reload built in, so there is no need for an additional tool like figwheel.
2.2.2. Starting the Server
Simply start a REPL (see the README in your generated project for instructions) and use:
(start)
to start the server (which defaults to port 3000). If you’ve turn on the main
build in shadow-cljs, then you
should see the base template page. Please refer to the README in your template app if this doesn’t work, as
this could get out of date with respect to the template.
Note
|
At the time of this writing you’ll have the fewest problems running JDK 8 (newer versions work, but have various extra options you have to pass). Also make sure you don’t have anything strange in your personal Leingingen profile, and that your Clojure tools.deps version is up-to-date. |
2.2.3. Choosing a React Version
Fulcro supports React 15 and 16 (and should even be fine with 17 when it is released). The template uses shadow-cljs
for compiles, so you simple include the version you’d like to use in the standard package.json
file. This is already
done for you in the template.
2.3. The Code Client Files
A complete Fulcro front-end client can be created in about two lines of code. Hot-load concerns require just a few more lines.
Your project should have a src/main/app/client.cljs
file that looks something like this:
(ns app.client
(:require [fulcro.client :as fc]
[app.ui.root :as root]
[fulcro.client.network :as net]))
(defonce app (atom nil))
(defn mount []
(reset! app (fc/mount @app root/Root "app")))
(defn start []
(mount))
(def secured-request-middleware
;; The CSRF token is embedded in the server_components/html.clj
(->
(net/wrap-csrf-token (or js/fulcro_network_csrf_token "TOKEN-NOT-IN-HTML!"))
(net/wrap-fulcro-request)))
(defn ^:export init []
(reset! app (fc/make-fulcro-client
;; This ensures your client can talk to a CSRF-protected server.
;; See middleware.clj to see how the token is embedded into the HTML
{:networking {:remote (net/fulcro-http-remote
{:url "/api"
:request-middleware secured-request-middleware})}}))
(start))
Note
|
The client construction function new-fulcro-client still exists and may be
what you see in applications and videos. The new function changes some of the defaults
and takes a map instead of named parameters. The old function still exists and works, but is
discouraged.
|
These functions are used from the HTML page to initialize the application. The reason things
are split into multiple functions is to allow the application to be remounted on hot-code reload.
The client isn’t active until you mount it, and to mount it
you need a UI. The file src/main/ui/root.cljc
contains some simple UI.
(ns app.ui.root
(:require
[fulcro.client.dom :as dom :refer [div]]
[fulcro.client.primitives :as prim :refer [defsc]]
[app.ui.components :as comp]))
(defsc Root [this {:keys [root/message]}]
{:query [:root/message]
:initial-state {:root/message "Hello!"}}
(div :.ui.segments
(div :.ui.top.attached.segment
(div :.content
"Welcome to Fulcro!"))
(div :.ui.attached.segment
(div :.content
(comp/ui-placeholder {:w 50 :h 50})
(div message)
(div "Some content here would be nice.")))))
If you look at the shadow-cljs.edn
file you’ll see it is configured to re-call start
after every hot load.
Mounting an already mounted app is the same as asking for a forced UI refresh. This ensures you’ll see UI changes
even if your props don’t change (all Fulcro components are pure and don’t render unless forced to, or see a prop change).
...
:builds {:main {...
:devtools {:after-load app.client/start}
...
This is all the real code you need to get started with a hot-code reload capable application! However, the
browser needs instructions to load this stuff up, and the target div
of the mount needs to exist.
2.3.1. The HTML
Your HTML file to load the app needs these things:
-
Include a DIV on the DOM with the ID you want to mount on.
-
Include the generated JS file.
-
Call your exported
init
function.
This corresponds to:
<!DOCTYPE html>
<html>
<body>
<div id="app"></div>
<script src="js/app.js" type="text/javascript"></script>
<script>app.client.init();</script>
</body>
</html>
but you won’t find this in your template resources. The template generates a secure app with CSRF (cross-site request forgery) protection.
This means a CSRF token needs to be embedded in the page,
and this is a complication you can ignore for the most part right now; however, it explains why we dynamically
generate the HTML file in the server using hiccup
(in middleware.clj
):
(defn index [csrf-token]
(html5
[:html {:lang "en"}
[:head {:lang "en"}
[:title "Application"]
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"}]
[:link {:href "https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css"
:rel "stylesheet"}]
[:link {:rel "shortcut icon" :href "data:image/x-icon;," :type "image/x-icon"}]
[:script (str "var fulcro_network_csrf_token = '" csrf-token "';")]]
[:body
[:div#app]
[:script {:src "js/main/app.js"}]
[:script "app.client.init();"]]]))
REMINDER: When developing it is a good idea to: Use Chrome (the devtools only work there) and install the Fulcro Inspect extension from the Chrome store. Keep the developer’s console open, and in the developer console settings: "Network, Disable cache (while DevTools is open)", and "Console, Enable custom formatters".
Cached files can, as everywhere else, cause you lots of headaches. Fortunately they only really affect you poorly on the initial load in Fulcro. Hot reloads typically work very well.
The Fulcro Inspect tab will show the state of your Fulcro application (along with lot of other useful details like your transaction log) if you’ve included the proper dependencies and preloads (which the template should have done for you).
2.4. Basic UI Components
Fulcro supplies defsc
to build React components. This macro emits React components that work as 100% raw React
components (i.e. once you compile them to Javascript they could be used from other native React code).
There are also factory functions for generating all standard HTML5 DOM elements in React in the fulcro.client.dom
namespace.
2.4.1. The defsc
Macro
The basic code to build a simple component has the following form:
(defsc ComponentName
[this props] ; parameters. Available in body, and in *some* of the options
; optional: { ...options... }
(dom/div {:className "a" :id "id" :style {:color "red"}}
(dom/p "Hello")))
This macro emits the equivalent of a React component with a render
method.
Class names and IDs can be written in a more compact format instead of the map (or in front of it if you have other props to pass):
(dom/div :.a#id {:style {:color "red"}}
(dom/p "Hello"))
of course you can also :refer
the common tags in your namespace declaration:
(ns ui
(:require [fulcro.client.dom :as dom :refer [div p]]))
and make this even tighter:
(div :.a#id {:style {:color "red"}}
(p "Hello"))
For our purposes we won’t be saying much about the React lifecycle methods, though they can be added. The basic intention of this macro’s syntax is to declare a component that can render UI and participate in our data-driven story.
2.4.2. The render
method.
The body of defsc
is the render for the component and can do whatever work you need, but it should return
a react element (see React Components, Elements, and Instances).
As of React 16 you can return a sequence of elements as well (though each must have a unique :key
).
There are factory methods for all of HTML5 in fulcro.client.dom
. These functions can take an optional keyword that
specifies classes and an ID, followed by an optional map or additional props. If the tag allows children then
they are simply placed after these two optional things:
(dom/div :.a#thing "Hi") ; keyword can contain any number of classes preceeded by dots, and an id with #
(dom/div :.a#thing {:data-prop 3} "Hi") ; props can still be supplied with the keyword
(dom/div :.a.b.c "Hi") ; Any number of static classes (a b, and c).
(dom/div {:className "a" :data-prop 3} "Hi") ; or it can all be done in props
(dom/div {:classes [(when hidden "hidden") (if tall :.tall :.short)]} ...) ; :classes is nice for expressions (2.5.12+)
These are macros that obtain very good runtime speed by converting the maps and such to low-level js at compile time.
Note
|
The class keyword requires each class to be preceeded by a . , and will give a compile error if you forget to do that.
|
Important
|
If you’re writing your UI in CLJC files then you need to make sure you use a conditional reader to pull in the proper server DOM functions for Clojure: |
(ns app.ui
(:require #?(:clj [fulcro.client.dom-server :as dom] :cljs [fulcro.client.dom :as dom]))
... the actual code is the same as before
The reason this is necessary is that CLJS requires macros to be in CLJ files, but in order to get higher-order functions to operate in CLJ the DOM elements must be functions. In CLJS, you can have both a macro and function with the same name, but this is not true in CLJ. Therefore, in order to get the optimal (inlined) client performance two namespaces are required.
2.4.3. Props
React components receive their data through props and state (which is local mutable state on the component).
In Fulcro we highly recommend using props for most things. This
ensures that various other features work well. The data passed to a component can be accessed (as a cljs map) by
calling prim/props
on this
, or by destructuring in the second argument of defsc
.
So, let’s define a Person
component to display details about
a person. We’ll assume that we’re going to pass in name and age as properties:
(defsc Person [this {:keys [person/name person/age]}]
(dom/div
(dom/p "Name: " name)
(dom/p "Age: " age)))
Now, in order to use this component we need an element factory. An element factory lets
us use the component within our React UI tree. Name confusion can become an
issue (Person the component vs. person the factory?) we recommend prefixing the factory with ui-
:
(def ui-person (prim/factory Person))
Now we can compose people into our root:
(defsc Root [this props]
(dom/div
(ui-person {:person/name "Joe" :person/age 22})))
2.4.4. Hot Code Reload
Part of our quick development story is getting hot code reload to update the UI whenever we change the source.
Try editing the UI of Person
and save. You should see the UI update even though the person’s data didn’t change.
2.4.5. Composing
You should already be getting the picture that your UI is going to be a tree composed from a root element. The method of data passing (via props) should also be giving you the picture that supplying data to your UI (through root) means you need to supply an equivalently structured tree of data. This is true of basic React. However, just to drive the point home let’s make a slightly more complex UI and see it in detail:
Replace your content with this:
(defsc Person [this {:keys [person/name person/age]}]
(dom/li
(dom/h5 (str name " (age: " age ")"))))
(def ui-person (prim/factory Person {:keyfn :person/name}))
(defsc PersonList [this {:keys [person-list/label person-list/people]}]
(dom/div
(dom/h4 label)
(dom/ul
(map ui-person people))))
(def ui-person-list (prim/factory PersonList))
(defsc Root [this {:keys [ui/react-key]}]
(let [ui-data {:friends {:person-list/label "Friends" :person-list/people
[{:person/name "Sally" :person/age 32}
{:person/name "Joe" :person/age 22}]}
:enemies {:person-list/label "Enemies" :person-list/people
[{:person/name "Fred" :person/age 11}
{:person/name "Bobby" :person/age 55}]}}]
(dom/div
(ui-person-list (:friends ui-data))
(ui-person-list (:enemies ui-data)))))
So that the UI graph looks like this:
and the data graph matches the same structure, with map keys acting as the graph "edges":
{ :friends { :person-list/people [PERSON ...]
; ==to-one list=> ==to-many people==>
:enemies { :person-list/people [PERSON ...] }
2.4.6. The Component Registry (version 2.8.8+)
We don’t need it at the moment, but you may find it useful to note that any component that is required in your application will appear in a global component registry. You can look up such components using a symbol or keyword that matches the fully namespace-qualified name of the component:
(prim/classname->class `app.ui/Root)
;; OR
(prim/classname->class :app.ui/Root)
This comes in handy for things like code splitting (where the dynamically loaded code will appear in the registry), tracking classes (by name) in app state (which cannot appear there as "code").
2.5. Feeding the Data Tree
Obviously it isn’t going to be desirable to hand-manage this very well for anything but the most trivial application (which is the crux of the problems with most UI libraries).
At best it does give us a persistent data structure that represents the current "view" of the application (which has many benefits), but at worst it requires us to "think globally" about our application. We want local reasoning. We also want to be able to easily re-compose our UI as needed, and a static data graph like this would have to be updated every time we made a change! Almost equally as bad: if two different parts of our UI want to show the same data then we’d have to find and update a bunch of copies spread all over the data tree.
So, how do we solve this?
2.5.1. Why not have components just "grab" their data (sideband)?
This is certainly a possibility; however, it leads to other complications. What is the data model? How do you interact with remotes to fill your data needs? Fulcro has a very nice cohesive story for these questions, while other systems end up with complications like event handler middleware, coeffect accretion, and signal graphs…not to mention that the sideband solution says nothing definitive about how you actually accomplish the server interactions with said data model.
Fulcro has a model for all of this, and it is surprising how simple it makes your application once you put your application together. Let’s look at the steps and parts:
2.5.2. Step 1 — The Initial State
All applications have some starting initial state. Since our UI is a tree, our starting state needs to somehow establish what goes to the initial nodes.
In Fulcro, there is a way to construct the initial tree of data in a way that allows for local reasoning and easy refactoring: co-locate the initial desired part of the tree with the component that uses it. This allows you to compose the state tree in exactly the same way as the UI tree.
The defsc
macro makes short work of this with the initial-state
option. Simply give it a
lambda that gets parameters (optionally from the parent) and returns a map representing the state of the component.
You can retrieve this data using (prim/get-initial-state Component)
.
It looks like this:
(ns app.ui.root
(:require
#?(:clj [fulcro.client.dom-server :as dom] :cljs [fulcro.client.dom :as dom])
[fulcro.client.primitives :as prim :refer [defsc]]))
(defsc Person [this {:keys [person/name person/age]}]
{ :initial-state (fn [{:keys [name age] :as params}] {:person/name name :person/age age}) }
(dom/li
(dom/h5 (str name "(age: " age ")"))))
(def ui-person (prim/factory Person {:keyfn :person/name}))
(defsc PersonList [this {:keys [person-list/label person-list/people]}]
{:initial-state
(fn [{:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(prim/get-initial-state Person {:name "Sally" :age 32})
(prim/get-initial-state Person {:name "Joe" :age 22})]
[(prim/get-initial-state Person {:name "Fred" :age 11})
(prim/get-initial-state Person {:name "Bobby" :age 55})])})}
(dom/div
(dom/h4 label)
(dom/ul
(map ui-person people))))
(def ui-person-list (prim/factory PersonList))
; Root's initial state becomes the entire app's initial state!
(defsc Root [this {:keys [friends enemies]}]
{:initial-state (fn [params] {:friends (prim/get-initial-state PersonList {:label "Friends"})
:enemies (prim/get-initial-state PersonList {:label "Enemies"})}) }
(dom/div
(ui-person-list friends)
(ui-person-list enemies)))
Note
|
You must reload your browser for this to show up. Fulcro pulls this data into the database when the application first mounts, not on hot code reload (because that would change your app state, and hot code reload is more useful without state changes). |
Now a lot of the specific data here is just for demonstration purposes. Data like this (people) would almost
certainly come from a server, but it serves to illustrate that we can localize the initial data needs of a
component to the component, and then compose that into the parent in an abstract way
(by calling get-initial-state
against that child).
There are several benefits of this so far:
-
It generates the exact tree of data needed to feed the initial UI.
-
That initial state becomes your initial application database.
-
It restores local reasoning (and easy refactoring). Moving a component just means local reasoning about the component being moved and the component it is being moved from/to: You remove the
get-initial-state
from one parent and add it to a different one.
You can see that there is no magic if you just pull the initial tree at the REPL:
dev:cljs.user=> (fulcro.client.primitives/get-initial-state app.ui.root/Root {})
{:friends
{:person-list/label "Friends",
:person-list/people
[{:person/name "Sally", :person/age 32}
{:person/name "Joe", :person/age 22}]},
:enemies
{:person-list/label "Enemies",
:person-list/people
[{:person/name "Fred", :person/age 11}
{:person/name "Bobby", :person/age 55}]}}
It’s nothing more than function composition. The initial state option on defsc
encodes your initial state
into a function that can be accessed via get-initial-state
on a class.
So behind the scenes Fulcro detects the initial state on the first mount and automatically uses it to initialize your application state.
By default, the entire initial state database is passed into your root node on render, so it is available for destructuring in Root’s props.
If you even want to see your current application state, you can do so through the atom that is holding your mounted application:
dev:cljs.user=> @(fulcro.client.primitives/app-state (get @app.client/app :reconciler))
Let’s see how we program our UI to access the data in the application state!
2.5.3. Step 2 — Establishing a Query
Fulcro unifies the data access story using a co-located query on each component. This sets up data access for both the client and server, and also continues our story of local reasoning and composition.
Queries go on a component in the same way as initial state: as static
implementations of a protocol.
The query notation is relatively light, and we’ll just concentrate on two bits of query syntax: props and joins.
Queries form a tree just like the UI and data. Obtaining a value at the current node in the tree traversal is done using the keyword for that value. Walking down the graph (a join) is represented as a map with a single entry whose key is the keyword for that nested bit of state.
So, a data tree like this:
{:friends
{:person-list/label "Friends",
:person-list/people
[{:person/name "Sally", :person/age 32}
{:person/name "Joe", :person/age 22}]},
:enemies
{:person-list/label "Enemies",
:person-list/people
[{:person/name "Fred", :person/age 11}
{:person/name "Bobby", :person/age 55}]}}
would have a query that looks like this:
[{:friends ; JOIN
[ :person-list/label
{:person-list/people ; JOIN
[:person/name :person/age]}]}]
This query reads "At the root you’ll find :friends
, which joins to a nested entity that has a label and people,
which in turn has nested properties name and age.
-
A vector always means "get this stuff at the current node"
-
:friends
is a key in a map, so at the root of the application state the query engine would expect to find that key, and would expect the value to be nested state (because maps mean joins on the tree) -
The value in the
:friends
join must be a vector, because we have to indicate what we want out of the nested data.
Joins are automatically to-one
if the data found in the state is a map, and to-many
if the data found is a
vector. In the example above the :friends
field from root pointed to a single PersonList
, whereas the PersonList
field :person-list/people
pointed to a vector of Person
. Beware that you don’t confuse yourself with
naming (e.g. friends is plural, but points to a single list).
The namespacing of keywords in your data (and therefore your query) is highly encouraged, as it makes it clear to the reader what kind of entity you’re working against (it also ensures that over-rendering doesn’t happen on refreshes later).
You can try this query stuff out in your REPL. Let’s say you just want the friends list label. The function
db→tree
can take an application database (which we can generate from initial state) and run a query
against it:
dev:cljs.user=> (fulcro.client.primitives/db->tree [{:friends [:person-list/label]}] (fulcro.client.primitives/get-initial-state app.ui.root/Root {}) {})
{:friends {:person-list/label "Friends"}}
HINT: The mirror of initial state with query is a great way to error-check your work (and defsc
does some of that
for you): For each scalar property in
initial state, there should be an identical simple property in your query. For each join of initial state to a child via
get-initial-state
there should be a query join via get-query
to that same child.
Adding Queries to Our Example
We want our queries to have the same nice local-reasoning as our initial data tree. The get-query
function
works just like the get-initial-state
function, and can pull the query from a component. In this case, you
should not ever call query
directly. The get-query
function augments the subqueries with metadata that is
important at a later stage.
So, the Person
component queries for just the properties it needs:
(defsc Person [this {:keys [person/name person/age]}]
{:query [:person/name :person/age]
:initial-state (fn [{:keys [name age] :as params}] {:person/name name :person/age age})}
(dom/li
(dom/h5 (str name "(age: " age ")"))))
Notice that the entire rest of the component did not change.
Next up the chain, we compose the Person
query into PersonList
(notice how the composition of state and query
are mirrored):
(defsc PersonList [this {:keys [person-list/label person-list/people]}]
{:query [:person-list/label {:person-list/people (prim/get-query Person)}]
:initial-state
(fn [{:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(prim/get-initial-state Person {:name "Sally" :age 32})
(prim/get-initial-state Person {:name "Joe" :age 22})]
[(prim/get-initial-state Person {:name "Fred" :age 11})
(prim/get-initial-state Person {:name "Bobby" :age 55})])})}
(dom/div
(dom/h4 label)
(dom/ul
(map ui-person people))))
again, nothing else changes.
2.5.4. Step 3 — Receive the Data Feed as Props in Root
Finally, we compose to Root
:
(defsc Root [this {:keys [friends enemies]}]
{:query [{:friends (prim/get-query PersonList)}
{:enemies (prim/get-query PersonList)}]
:initial-state (fn [params] {:friends (prim/get-initial-state PersonList {:label "Friends"})
:enemies (prim/get-initial-state PersonList {:label "Enemies"})})}
(dom/div
(ui-person-list friends)
(ui-person-list enemies)))
This all looks like a minor (and useless) change. The operation is the same; however, we’re getting close to the magic, so stick with us. The major difference in this code is that even though the database starts out with the initial state, there is nothing to say we have to query for everything that is in there, or that the state has to start out with everything we might query for in the future. We’re getting close to having a dynamic data-driven application.
Notice that everything we’ve done so far has global client database implications, but that each component codes only the portion it is concerned with. Local reasoning is maintained. All software evolution in this model preserves this critical aspect.
Also, you now have application state that can evolve (the query is running against the active application database stored in an atom)!
Important
|
You should always think of the query as "running from root". You’ll
notice that Root still expects to receive the entire data tree for the UI (even though it doesn’t have to
know much about what is in it, other than the names of direct children), and it still picks out those sub-trees
of data and passes them on. In this way an arbitrary component in the UI tree is not querying
for its data directly in a side-band sort of way, but is instead being composed in from parent to parent all the
way to the root. Later, we’ll learn how Fulcro can optimize this and pull the data from the database for
a specific component, but the reasoning will remain the same.
|
2.6. Passing Callbacks and Other Parent-computed Data
The queries on component describe what data the component wants from the database; however, you’re not allowed to put code in the database, and sometimes a parent might compute something it needs to pass to a child like a callback function.
It turns out that we can optimize away the refresh of components (if their data has not changed). This means that we can use a component’s query to directly re-supply data for refresh; however, since doing so skips the rendering of the parent, if we are not careful this can lead to "losing" these extra bits of computationally generated data passed from the parent, like callbacks.
Let’s say we want to render a delete button on our individual people in our UI. This button will mean "remove the person from this list"…but the person itself has no idea which list it is in. Thus, the parent will need to pass in a function that the child can call to affect the delete properly:
2.6.1. The Incorrect Way:
(defsc Person [this {:keys [person/name person/age onDelete]}] ; (3)
{:query (fn [] [:person/name :person/age])
:initial-state (fn [{:keys [name age] :as params}] {:person/name name :person/age age})}
(dom/li
(dom/h5 (str name " (age: " age ")") (dom/button {:onClick #(onDelete name)} "X")))) ; (4)
(def ui-person (prim/factory Person {:keyfn :person/name}))
(defsc PersonList [this {:keys [person-list/label person-list/people]}]
{:query [:person-list/label {:person-list/people (prim/get-query Person)}]
:initial-state
(fn [{:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(prim/get-initial-state Person {:name "Sally" :age 32})
(prim/get-initial-state Person {:name "Joe" :age 22})]
[(prim/get-initial-state Person {:name "Fred" :age 11})
(prim/get-initial-state Person {:name "Bobby" :age 55})])})}
(let [delete-person (fn [name] (println label "asked to delete" name))] ; (1)
(dom/div
(dom/h4 label)
(dom/ul
(map (fn [p] (ui-person (assoc p :onDelete delete-person))) people))))) ;; (2)
-
A function acting in as a stand-in for our real delete
-
Adding the callback into the props (WRONG)
-
Pulling the onDelete from the passed props (WRONG). The query has to be changed to a lambda to turn off error checking to even try this method.
-
Invoking the callback when delete is pressed.
This method of passing a callback will work initially, but not consistently. The problem is that we can optimize away a re-render of a parent when it can figure out how to pull just the data of the child on a refresh, and in that case the callback will get lost because only the database data will get supplied to the child! Your delete button will work on the initial render (from root), but may stop working at a later time after a UI refresh.
2.6.2. The Correct Way:
There is a special helper function that can record the computed data like callbacks onto the child that receives them
such that an optimized refresh will still know them. There is also an additional (optional) component parameter to defsc
that you can use to deconstruct them:
(defsc Person [this {:keys [person/name person/age]} {:keys [onDelete]}] (2)
{:query [:person/name :person/age]
:initial-state (fn [{:keys [name age] :as params}] {:person/name name :person/age age})}
(dom/li
(dom/h5 (str name " (age: " age ")") (dom/button {:onClick #(onDelete name)} "X")))) ; (4)
(def ui-person (prim/factory Person {:keyfn :person/name}))
(defsc PersonList [this {:keys [person-list/label person-list/people]}] ;
{:query [:person-list/label {:person-list/people (prim/get-query Person)}]
:initial-state
(fn [{:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(prim/get-initial-state Person {:name "Sally" :age 32})
(prim/get-initial-state Person {:name "Joe" :age 22})]
[(prim/get-initial-state Person {:name "Fred" :age 11})
(prim/get-initial-state Person {:name "Bobby" :age 55})])})}
(let [delete-person (fn [name] (println label "asked to delete" name))] ; (1)
(dom/div
(dom/h4 label)
(dom/ul
(map (fn [p] (ui-person (prim/computed p {:onDelete delete-person}))) people))))) ; (1)
-
The
prim/computed
function is used to add the computed data to the props being passed. -
The child adds an additional parameter, and pulls the computed data from there. You can also use
(prim/get-computed this)
to pull all of the computed props in the body.
Now you can be sure that your callbacks (or other parent-computed data) won’t be lost to render optimizations.
2.7. Updating the Data Tree
Now the real fun begins: Making things dynamic.
In general you don’t have to think about how the UI updates, because most changes are run within the context that needs refreshed. But for general knowledge UI Refresh is triggered in two ways:
-
Running a data modification transaction on a component (which will re-render the subtree of that component), and refresh only the DOM for those bits that had actual changes.
-
Telling Fulcro that some specific data changed (e.g.
:person/name
).
The former is most common, but the latter is often needed when a change executed in one part of the application modifies data that some UI component elsewhere in the tree needs to respond to.
So, if we run the code that affects changes from the component that will need to refresh (a very common case) we’re covered. If a child needs to make a change that will affect a parent (as in our earlier example), then the modification should run from the parent via a callback so that refresh will not require further interaction. Later we’ll show you how to deal with refreshes that could be in far-flung parts of the UI. First, let’s get some data changing.
2.7.1. Transactions
Every change to the application database must go through a transaction processing system. This has two goals:
-
Abstract the operation (like a function)
-
Treat the operation like data (which allows us to generalize it to remote interactions)
The operations are written as quoted data structures. Specifically as a vector of mutation invocations. The entire transaction is just data. It is not something run in the UI, but instead passed into the underlying system for processing.
You essentially just "make up" names for the operations you’d like to do to your database, just like function names. Namespacing is encouraged, and of course syntax quoting honors namespace aliases.
(prim/transact! this `[(ops/delete-person {:list-name "Friends" :person "Fred"})])
is asking the underlying system to run the mutation ops/delete-person
(where ops can be an alias established
in the ns
). Of course, you’ll typically use unquote to embed data from local variables:
(prim/transact! this `[(ops/delete-person {:list-name ~name :person ~person})])
2.7.2. Handling Mutations
When a transaction runs in Fulcro it passes things off to a multimethod. The multi-method is described in more
detail in the section on the mutation multimethod, but Fulcro provides a macro that makes
building (and using) mutations easier: defmutation
.
Mutations typically can go wherever you want. The macro augments a multimethod, so you need to make sure that
namespace is required by files that your program already uses. Something like src/main/app/api/mutations.cljs
is fine, but you might also find it useful to place your mutations in something topical so you can write
the server mutations in the CLJ version of the file, and the client ones in the cljs. Mutations are namespaced,
so this makes things easier.
A mutation looks a bit like a method. It can have a docstring, and the argument list will always receive a single argument (params) that will be a map (which then allows destructuring).
The body looks a bit like a letfn
, but the names we use for these methods are pre-established. The one
we’re interested in at the moment is action
, which is what to do locally. The action
method will be
passed the application database’s app-state atom, and it should change the data in that atom to reflect
the new "state of the world" indicated by the mutation.
For example, delete-person
must find the list of people on the list in question, and filter out the one
that we’re deleting:
(ns app.api.mutations
(:require [fulcro.client.mutations :as m :refer [defmutation]]))
(defmutation delete-person
"Mutation: Delete the person with name from the list with list-name"
[{:keys [list-name name]}] ; (1)
(action [{:keys [state]}] ; (2)
(let [path (if (= "Friends" list-name)
[:friends :person-list/people]
[:enemies :person-list/people])
old-list (get-in @state path)
new-list (vec (filter #(not= (:person/name %) name) old-list))]
(swap! state assoc-in path new-list))))
-
The argument list for the mutation itself
-
The thing to do, which receives the app-state atom as an argument.
Then all that remains is to change ui.root
in the following ways:
-
Add a require and alias for app.operations to the ns
-
Change the callback to run the transaction
(ns app.ui.root
(:require [fulcro.client :as fc]
[fulcro.client.dom :as dom]
; ADD THIS:
[app.api.mutations :as api] ; (1)
[fulcro.client.primitives :as prim :refer [defui defsc]]))
...
(defsc PersonList [this {:keys [person-list/label person-list/people]}]
...
(let [delete-person (fn [name] (prim/transact! this `[(api/delete-person {:list-name ~label :name ~name})]))] ; (2)
...
-
The require ensures that the mutations are loaded, and also gives us an alias to the namespace of the mutation’s symbol.
-
Running the transaction in the callback.
Note that our mutation’s symbol is actually app.api.mutations/delete-person
, but the syntax quoting will fix it.
Also realize that the mutation is not running in the UI, it is instead being handled "behind the scenes". This
allows a snapshot of the state history to be kept, and also a more seamless integration to full-stack operation
over a network to a server (in fact, the UI code here is already full-stack capable without any changes!).
This is where the power starts to show: all of the minutiae above is leading us to some grand unifications when it comes to writing full-stack applications.
2.7.3. Hold on – This Sucks!
But first, we should address a problem that many of you may have already noticed: The mutation code is tied to the shape of the UI tree!!!
This breaks our lovely model in several ways:
-
We can’t refactor our UI without also rewriting the mutations (since the data tree would change shape)
-
We can’t locally reason about any data. Our mutations have to understand things globally!
-
Our mutations could get rather large and ugly as our UI gets big
-
If a fact appears in more than one place in the UI and data tree, then we’ll have to update all of them in order for things to be correct. Data duplication is never your friend.
2.8. The Secret Sauce – Normalizing the Database
Fortunately, we have a very good solution to the mutation problem above, and it is one that has been around for decades: database normalization!
Here’s what we’re going to do:
Each UI component represents some conceptual entity with data (assuming it has state and a query). In a fully normalized database, each such concept would have its own table, and related things would refer to it through some kind of foreign key. In SQL land this looks like:
In a graph database (like Datomic) a reference can have a to-many arity, so the direction can be more natural:
Since we’re storing things in a map, we can represent "tables" as an entry in the map where the key is the table name, and the value is a map from ID to entity value. So, the last diagram could be represented as:
{ :PersonList { 1 { :label "Friends"
:people #{1, 2} }}
:Person { 1 {:id 1 :name "Joe" }
2 {:id 2 :name "Sally"}}}
This is close, but not quite good enough. The set in :person-list/people
is a problem. There is no schema, so there is no
way to know what kind of thing "1" and "2" are!
The solution is rather easy: code the foreign reference to include the name of the table (is a single such "pointer", and to-many relations store many such "pointers" in a vector (so you end up with a doubly-nested vector)):
{ :PersonList { 1 { :label "Friends"
:people [ [:Person 1] [:Person 2] ] }}
:Person { 1 {:id 1 :name "Joe" }
2 {:id 2 :name "Sally"}}}
A foreign key as a vector pair of [TABLE ID]
is known as an Ident
.
So, now that we have the concept and implementation, let’s talk about conventions:
-
Properties are usually namespaced (as shown in earlier examples)
-
Table names are usually namespaced with the entity type, and given a name that indicates how it is indexed. For example:
:person/by-id
,:person-list/by-name
, etc. If you use Clojure spec, you may choose to alter this a bit for convenience in namespace-aliasing keywords (e.g.::my-db-schema/person-by-id
).
2.8.1. Automatic Normalization
Fortunately, you don’t have to hand-normalize your data. The components have almost everything they need to
do it for you, other than the actual value of the Ident
. So, we’ll add one more option to your components
(and we’ll add IDs to the data at this point, for easier implementation):
The program will now look like this:
(ns app.ui.root
(:require
translations.es
[fulcro.client.dom :as dom]
[app.api.mutations :as api]
[fulcro.client.primitives :as prim :refer [defsc]]))
(defsc Person [this {:keys [db/id person/name person/age]} {:keys [onDelete]}]
{:query [:db/id :person/name :person/age] ; (2)
:ident [:person/by-id :db/id] ; (1)
:initial-state (fn [{:keys [id name age]}] {:db/id id :person/name name :person/age age})} ; (3)
(dom/li
(dom/h5 (str name " (age: " age ")") (dom/button {:onClick #(onDelete id)} "X")))) ; (4)
(def ui-person (prim/factory Person {:keyfn :person/name}))
(defsc PersonList [this {:keys [db/id person-list/label person-list/people]}]
{:query [:db/id :person-list/label {:person-list/people (prim/get-query Person)}]
:ident [:person-list/by-id :db/id] ; (5)
:initial-state
(fn [{:keys [id label]}]
{:db/id id
:person-list/label label
:person-list/people (if (= label "Friends")
[(prim/get-initial-state Person {:id 1 :name "Sally" :age 32})
(prim/get-initial-state Person {:id 2 :name "Joe" :age 22})]
[(prim/get-initial-state Person {:id 3 :name "Fred" :age 11})
(prim/get-initial-state Person {:id 4 :name "Bobby" :age 55})])})}
(let [delete-person (fn [person-id] (prim/transact! this `[(api/delete-person {:list-id ~id :person-id ~person-id})]))] ; (4)
(dom/div
(dom/h4 label)
(dom/ul
(map (fn [p] (ui-person (prim/computed p {:onDelete delete-person}))) people)))))
(def ui-person-list (prim/factory PersonList))
(defsc Root [this {:keys [ui/react-key friends enemies]}]
{:query [:ui/react-key {:friends (prim/get-query PersonList)}
{:enemies (prim/get-query PersonList)}]
:initial-state (fn [params] {:friends (prim/get-initial-state PersonList {:id :friends :label "Friends"})
:enemies (prim/get-initial-state PersonList {:id :enemies :label "Enemies"})})}
(dom/div
(ui-person-list friends)
(ui-person-list enemies)))
-
Adding an ident allows Fulcro to know how to build a FK reference to a person (given its props). The first element is the table name, the second is the name of the property that contains the ID of the entity.
-
We will be using IDs now, so we need to add
:db/id
to the query (and props destructuring). This is just a convention for the ID attribute -
The state of the entity will also need the ID
-
The callback can now delete people by their ID, which is more reliable.
-
The list will have an ID, and an Ident as well
If you reload the web page (needed to reinitialize the database state), then you can look at the newly normalized database at the REPL (NOTE: It is much easier to look at this using Fulcro Inspect in your developer tools tab):
dev:cljs.user=> @(fulcro.client.primitives/app-state (-> app.client/app deref :reconciler))
{:friends [:person-list/by-id :friends],
:enemies [:person-list/by-id :enemies],
:person/by-id
{1 {:db/id 1, :person/name "Sally", :person/age 32},
2 {:db/id 2, :person/name "Joe", :person/age 22},
3 {:db/id 3, :person/name "Fred", :person/age 11},
4 {:db/id 4, :person/name "Bobby", :person/age 55}},
:person-list/by-id
{:friends
{:db/id :friends,
:person-list/label "Friends",
:person-list/people [[:person/by-id 1] [:person/by-id 2]]},
:enemies
{:db/id :enemies,
:person-list/label "Enemies",
:person-list/people [[:person/by-id 3] [:person/by-id 4]]}}}
Note that db→tree
understands this normalized form, and can convert it (via a query)
to the proper data tree. db→tree
(for legacy reasons) requires a way to resolve references (idents) and the
database. In Fulcro these are the same. So, try this at the REPL:
dev:cljs.user=> (def current-db @(fulcro.client.primitives/app-state (-> app.client/app deref :reconciler)))
dev:cljs.user=> (def root-query (fulcro.client.primitives/get-query app.ui.root/Root))
#'cljs.user/current-db
dev:cljs.user=> (fulcro.client.primitives/db->tree root-query current-db current-db)
{:friends
{:db/id :friends,
:person-list/label "Friends",
:person-list/people
[{:db/id 1, :person/name "Sally", :person/age 32}
{:db/id 2, :person/name "Joe", :person/age 22}]},
:enemies
{:db/id :enemies,
:person-list/label "Enemies",
:person-list/people
[{:db/id 3, :person/name "Fred", :person/age 11}
{:db/id 4, :person/name "Bobby", :person/age 55}]}}
2.8.2. Mutations on a Normalized Database
We have now made it possible to fix the problems with our mutation. Now, instead of removing a person from a tree, we can remove a FK from a TABLE entry!
This is not only much easier to code, but it is completely independent of the shape of the UI tree:
(ns app.api.mutations
(:require [fulcro.client.mutations :as m :refer [defmutation]]))
(defmutation delete-person
"Mutation: Delete the person with name from the list with list-name"
[{:keys [list-id person-id]}]
(action [{:keys [state]}]
(let [ident-to-remove [:person/by-id person-id] ; (1)
strip-fk (fn [old-fks]
(vec (filter #(not= ident-to-remove %) old-fks)))] ; (2)
(swap! state update-in [:person-list/by-id list-id :person-list/people] strip-fk)))) ; (3)
-
References are always idents, meaning we know the value to remove from the FK list
-
By defining a function that can filter the ident from (1), we can use update-in on the person list table’s people.
-
This is a very typical operation in a mutation: swap on the application state, and update a particular thing in a table (in this case the people to-many ref in a specific person list).
If we were to now wrap the person list in any amount of additional UI (e.g. a nav bar, sub-pane, modal dialog, etc) this mutation will still work perfectly, since the list itself will only have one place it ever lives in the database.
2.8.3. How Automatic Normalization Works (optional)
It is good to know how an arbitrary tree of data (the one in InitialAppState) can be converted to the normalized form. Understanding how this is accomplished can help you avoid some mistakes later.
When you compose your query (via prim/get-query
), the get-query
function adds metadata to the query fragment that
names which component that query fragment came from.
For example, try this at the REPL:
dev:cljs.user=> (meta (fulcro.client.primitives/get-query app.ui.root/PersonList))
{:component app.ui.root/PersonList}
The get-query
function adds the component itself to the metadata for that query fragment. We already know that
we can call the static methods on a component (in this case we’re interested in ident
).
So, Fulcro includes a function called tree→db
that can simultaneously walk a data tree (in this case initial-state) and a
component-annotated query. When it reaches a data node whose query metadata names a component with an Ident
, it
places that data into the appropriate table (by calling your ident
function on it to obtain the table/id), and
replaces the data in the tree with its FK ident.
Once you realize that the query and the ident work together to do normalization, you can more easily
figure out what mistakes you might make that could cause auto-normalization to fail (e.g. stealing a query from
one component and placing it on another, writing the query of a sub-component by-hand instead of pulling it
with get-query
, etc.).
2.9. Review So Far
-
An Initial app state sets up a tree of data for startup to match the UI tree
-
Component query and ident are used to normalize this initial data into the database
-
The query is used to pull data from the normalized db into the props of the active Root UI
-
Transactions invoke abstract mutations
-
Mutations modify the (normalized) db
-
The transaction’s subtree of components re-renders
-
2.10. Using Better Tools
So far we’ve been hacking things in place and using the REPL to watch what we’re doing. There are better ways to work on Fulcro applications, and now that we’ve got one basically working, let’s take a look at them both.
2.10.1. Fulcro Inspect
We’ve mentioned this before, but now that you know about the centralized database, we want to mention it again: Use it! The DB tab of this tool shows you your application’s database and has a time slider to see the history of states. It also has tabs for showing you transactions that have run, and network interactions. See the tool’s documentation for more information. In fact, by the time you read this it will probably have even more features.
2.10.2. Workspaces
There is a build in the template project called workspaces
. This starts up a development environment where you can
code components, full-stack elements, or even entire applications in a flexible environment.
Remember, we can actually split off chunks of the application because they are all fed data via a normalized database
and coupled only to their parent.
See the README of your template for instructions on using it.
2.11. Going Remote!
OK, back to the main story!
Believe it or not, there’s not much to add/change on the client to get it talking to a server, and there is also a relatively painless way to get a server up and running.
Of course, your template already has a server.
2.11.1. Setting up a Server
The template generates a pretty complete server that uses ring-defaults and has CSRF protection configured.
The code in the template is pretty well documented, and it is mostly standard Ring stuff. The main part
of interest is the wrap-api
handler:
(def server-parser (server/fulcro-parser))
(defn wrap-api [handler uri]
(fn [request]
(if (= uri (:uri request))
(server/handle-api-request
;; Sub out a pathom parser here if you want to use pathom.
server-parser
;; this map is `env`. Put other defstate things in this map and they'll be
;; in the mutations/query env on server.
{:config config}
(:transit-params request))
(handler request))))
This generates a Fulcro query/mutation parser and sets it up to be called on some uri
(which the server sets to
/api
).
Fulcro includes an EDN configuration file reader that allows for easy configuration of your server. It bases
all configs on config/defaults.edn
, and deep merges a runtime-selected EDN file over that so that each developer,
test environment, or production system can make it’s own configuration changes.
Your template already has these in src/main/config
. See your template content for their content.
The config/defaults.edn
file is always looked for by the server (on CLASSPATH, so in your source or resources folder),
and should contain all of the default settings you think you want independent of where the server is started.
The server (for safety reasons in production) will not start if there isn’t also a user-specified file containing potential overrides.
Basically, it will deep-merge the two and have the latter override things in the former. This makes mistakes in
production harder to make. If you read the source of the config.clj
file you’ll see that it defaults to development mode.
If you look in server_main.clj
(the production main) you’ll see this is overridden.
You can also override this with the JVM option -Dconfig=path-to-file
. If the path is relative, it will look for it
in application resources. If it is absolute, it will look for it on the disk:
java -Dconfig=/usr/local/etc/app.edn -jar app.jar
This command-line option overrides any config file that was specified in the application itself (except for defaults.edn
,
which is always the base).
Starting the Server
If you now start a local Clojure REPL (with no special options), it should start in the user
namespace.
You can kick off your own application’s easy web server with:
user=> (start)
The console should tell you the URL, and if you browse there you should see your index.html
file.
Server Refresh
When you add/change code on the server you will want to see those changes in the live server without having to restart your REPL.
user=> (restart)
will do this.
If there are compiler errors, then the user
namespace might not reload properly. In that case, you should be able
to recover using:
user=> (tools-ns/refresh)
user=> (start)
Warning
|
Don’t call refresh while the server is running. It will refresh the code, but it will lose the reference to the running server, meaning you won’t be able to stop it and free up the network port. If you do this, you’ll have to restart your REPL. |
Warning
|
If you’ve built an AOT compiled uberjar, then you might end up with classes loaded into your VM, and hot
code reload on the server can then fail for very strange reasons. Clear your target folder if you’re seeing
mysterious behavior.
|
2.11.2. Loading Data
Now we will start to see more of the payoff of our UI co-located queries and auto-normalization. Our application so far is quite unrealistic: the people we’re showing should be coming from a server-side database, they should not be embedded in the code of the client. Let’s remedy that.
Fulcro provides a few mechanisms for loading data, but every possible load scenario can be done using
the fulcro.client.data-fetch/load
function.
It is very important to remember that our application database is completely normalized, so anything we’d want to put in that application state will be at most 3 levels deep (the table name, the ID of the thing in the table, and the field within that thing). We’ve also seen that Fulcro can also auto-normalize complete trees of data, and has graph queries that can be used to ask for those trees.
Thus, there really are not very many scenarios!
The three basic scenarios are:
-
Load something into the root of the application state
-
Load something into a particular field of an existing thing
-
Load some pile of data, and shape it into the database (e.g. load all of the people, and then separate them into a list of friends and enemies).
Let’s try out these different scenarios with our application.
First, let’s correct our application’s initial state so that no people are there:
(defsc PersonList [this {:keys [db/id person-list/label person-list/people]}]
{:query [:db/id :person-list/label {:person-list/people (prim/get-query Person)}]
:ident [:person-list/by-id :db/id]
:initial-state
(fn [{:keys [id label]}]
{:db/id id
:person-list/label label
:person-list/people []})} ; REMOVE THE INITIAL PEOPLE
...
If you now reload your page you should see two empty lists.
Normalization
When you load something you will use a query from something on your UI (it is rare to load something you don’t want to show). Since those components (should) have a query and ident, the result of a load can be sent from the server as a tree, and the client can auto-normalize that tree just like it did for our initial state!
Loading something into the DB root
This case is less common, but it is a simple starting point. It is typically used to obtain something that you’d want
to access globally (e.g. the user info about the current session). Let’s assume that our Person component represents
the same kind of data as the "logged in" user. Let’s write a load that can ask the server for the "current user" and
store that in the root of our database under the key :current-user
.
Loads, of course, can be triggered at any time (startup, event, timeout). Loading is just a function call.
For this example, let’s trigger the load just after the application has started.
Triggering the Load
To do this, we can add an option to our client. In app.client
change app
:
(ns app.client
(:require [fulcro.client :as fc]
[fulcro.client.data-fetch :as df] ; (1)
[app.ui.root :as root]))
...
(fc/make-fulcro-client
{:client-did-mount
(fn [app] ; (2)
(df/load app :current-user root/Person))})
...
-
Require the
data-fetch
namespace -
Issue the load in the application’s
client-did-mount
Note
|
If you are using workspaces you will need to place the option for the application under a special app key: |
(ns myapp.workspaces.fulcro-demo-cards
(:require [fulcro.client.primitives :as fp]
[fulcro.client.localized-dom :as dom]
[nubank.workspaces.core :as ws]
[nubank.workspaces.card-types.fulcro :as ct.fulcro]
[nubank.workspaces.lib.fulcro-portal :as f.portal]))
...
(ws/defcard fulcro-demo-card
(ct.fulcro/fulcro-card
{::f.portal/root FulcroDemo
::f.portal/wrap-root? true ; generates a root. Useful for embedding normalized components with a placeholder root.
::f.portal/app {:client-did-mount (fn [app] ...)}}))
Note
|
Prior to Fulcro 2.8.1 the client was created with new-fulcro-client which had a :started-callback option.
The old method is still available (and unchanged). The new make-fulcro-client method changes some defaults,
and renames started-callback to client-did-mount .
|
Of course hot code reload does not restart the app (it just hot patches the code), so to see this load trigger we must reload the browser page.
If you do that at the moment, you should see an error in the various consoles related to the failure of the load.
Important
|
Make sure any card is running from your server (port 3000), and not the workspaces dev server. |
Technically, load
is just writing a query for you (in this case [{:current-user (prim/get-query Person)}]
) and sending it to the
server. The server will receive exactly that query as a CLJ data structure.
Implementing the Server Handler
You now need to convert the raw CLJ query into a response. You can read more about the gory details of that in the developer’s guide; however, Fulcro’s has some helpers that make our job much easier.
You’ll need to put your read handling code somewhere. For example you could create
src/main/app/api/read.clj
.
Since we’re on the server and we’re going to be supplying and manipulating people, we’ll just make a single atom-based
in-memory database. This could easily be stored in a database of any kind.
To handle the incoming "current user" request, we can use a macro to write the handler for us.
Something like this:
(ns app.api.read
(:require
[fulcro.server :refer [defquery-root defquery-entity defmutation]]))
(def people-db (atom {1 {:db/id 1 :person/name "Bert" :person/age 55 :person/relation :friend}
2 {:db/id 2 :person/name "Sally" :person/age 22 :person/relation :friend}
3 {:db/id 3 :person/name "Allie" :person/age 76 :person/relation :enemy}
4 {:db/id 4 :person/name "Zoe" :person/age 32 :person/relation :friend}
99 {:db/id 99 :person/name "Me" :person/role "admin"}}))
(defquery-root :current-user
"Queries for the current user and returns it to the client"
(value [env params]
(get @people-db 99)))
This macro actually augments a multimethod, which means we need to make sure this namespace is loaded by our server, so edit one of your components (like the middleware.clj) and require it there.
Now you should be able to restart/refresh the server at the SERVER REPL:
user=> (restart)
If you’ve done everything correctly, then reloading your application should successfully load your current user. You can verify this by examining the network data, but it will be even more convincing if you look at your client database via the dev card visualization on Fulcro Inspect. It should look something like this:
{:current-user [:person/by-id 99]
:person/by-id {99 {:db/id 99 :person/name "Me" :person/role "admin"}}
...}
Notice that the top-level key is a normalized FK reference to the person, which has been placed into the correct database table.
Note
|
The defquery macros make it easy to get started, but we recommend migrating away from them to
Pathom as soon as you have the basic hang of Fulcro. The Pathom library provides a much more powerful GraphQL-like
experience for writing your Fulcro server. You can also use Pathom to create a GraphQL client remote that can interface
between a Fulcro front-end and regular GraphQL servers. The Fulcro template can easily be modified to use Pathom by
substituting a Pathom parser in middleware.clj (search for fulcro-parser).
|
Using Data from Root
Of course, the question is now "how do I use that in some arbitrary component?" We won’t completely
explore that right now, but the answer is easy: The query syntax has a notation for "query something at the root". It looks like this:
[ {[:current-user '_] (prim/get-query Person)} ]
. You should recognize this as a query join, but on something that
looks like an ident without an ID (implying there is only one, at root).
We’ll just use it on the Root UI node, where we don’t need to "jump to the top":
(defsc Root [this {:keys [ui/react-key friends enemies current-user]}] ; (2)
{:query [:ui/react-key
{:current-user (prim/get-query Person)} ; (1)
{:friends (prim/get-query PersonList)}
{:enemies (prim/get-query PersonList)}]
:initial-state (fn [params] {:friends (prim/get-initial-state PersonList {:id :friends :label "Friends"})
:enemies (prim/get-initial-state PersonList {:id :enemies :label "Enemies"})})}
(dom/div
(dom/h4 (str "Current User: " (:person/name current-user))) ; (3)
(ui-person-list friends)
(ui-person-list enemies)))
-
Add the current user to the query
-
Pull of from the props
-
Show something about it in the UI
Loading something that gets "added in" to an existing entity
The next common scenario is loading something into some other existing entity in your database. Remember that since the database is normalized this will cover all of the other loading cases (except for the one where you want to convert what the server tells you into a different shape (e.g. paginate, sort, etc.)).
Fulcro’s load method accomplishes this by loading the data into the root of the database, normalizing it, then (optionally) allowing you to re-target the top-level FK to different location(s) in the database.
Targeting the Load
The load looks very much like what we just did, but with one addition:
(df/load app :my-friends Person {:target [:person-list/by-id :friends :person-list/people]})
The :target
option indicates that once the data is loaded and normalized (which will leave the FK reference
at the root as we saw in the last section) this top-level reference (or vector of references) will be moved into the key-path provided.
Since our database is normalized, this means a 3-tuple (table, id, target field).
Warning
|
It is important to choose a keyword for this load that won’t stomp on real data in your database’s root.
We already have the top-level keys :friends and :enemies as part of our UI graph from root. So, we’re making up
:my-friends as the load key. One could also namespace the keyword with something like :server/friends .
|
Since friend and enemies are the same kind of query, let’s add both into the startup code (in the card/client):
...
:client-did-mount
(fn [app]
(df/load app :current-user root/Person)
(df/load app :my-enemies root/Person {:target [:person-list/by-id :enemies :person-list/people]})
(df/load app :my-friends root/Person {:target [:person-list/by-id :friends :person-list/people]}))
...
Handling the Load Request on the Server
The server query processing is what you would expect from the last example (in read.clj
):
(def people-db ...) ; as before
(defn get-people [kind keys]
(->> @people-db
vals
(filter #(= kind (:person/relation %)))
vec))
(defquery-root :my-friends
"Queries for friends and returns them to the client"
(value [{:keys [query]} params]
(get-people :friend query)))
(defquery-root :my-enemies
"Queries for enemies and returns them to the client"
(value [{:keys [query]} params]
(get-people :enemy query)))
A refresh of the server and reload of the page should now populate your lists from the server!
user=> (restart)
Morphing the Loaded Data
It is somewhat common for a server to return data that isn’t quite what we want in our UI. So far we’ve just been placing the data returned from the server directly in our UI. Fulcro’s load mechanism allows a post mutation of the loaded data once it arrives, allowing you to re-shape it into whatever form you might desire.
For example, you may want the people in your lists to be sorted by name. You’ve already seen how to write client
mutations that modify the database, and that is really all you need. The client mutation for sorting the people
in the friends list could be (in mutations.cljs
):
(defn sort-friends-by*
"Sort the idents in the friends person list by the indicated field. Returns the new app-state."
[state-map field]
(let [friend-idents (get-in state-map [:person-list/by-id :friends :person-list/people] [])
friends (map (fn [friend-ident] (get-in state-map friend-ident)) friend-idents)
sorted-friends (sort-by field friends)
new-idents (mapv (fn [friend] [:person/by-id (:db/id friend)]) sorted-friends)]
(assoc-in state-map [:person-list/by-id :friends :person-list/people] new-idents)))
(defmutation sort-friends [no-params]
(action [{:keys [state]}]
(swap! state sort-friends-by* :person/name)))
Note
|
When building larger applications we’ve often found it useful to put the client mutations in one
topically-named file (e.g. person.cljs ) and the server mutations right next to it in (e.g. person.clj ). This makes
code navigation very easy.
|
Of course this mutation could be triggered anywhere you could run a transact!
, but since we’re interested in morphing
just-loaded data, we’ll add it there. Our dev card would now look like this:
(ns app.intro
(:require [fulcro.client.cards :refer [defcard-fulcro]]
[app.ui.root :as root]
[fulcro.client.data-fetch :as df]
[app.api.mutations :as api]))
(defcard-fulcro sample-app
root/Root
{}
{:inspect-data true
:fulcro {:client-did-mount
(fn [app] (df/load app :current-user root/Person)
(df/load app :my-friends root/Person {:target [:person-list/by-id :friends :person-list/people]
:post-mutation `api/sort-friends})
(df/load app :my-enemies root/Person {:target [:person-list/by-id :enemies :person-list/people]}))}})
Notice the syntax quoting. The post mutation has to be the symbol of the mutation. Remember that
our require has app.api.mutations
aliased to api
, and syntax quoting will expand that for us.
If you reload your UI you should now see the people sorted by name. Hopefully you can see how easy it is to change this sort order to something like "by age". Try it!
Loading a specific entity and its subgraph (by ident)
Once things are loaded from the server they are immediately growing stale (unless you’re pushing updates with websockets). It is very common to want to re-load a particular thing in your database. Of course, you can trigger a load just like we’ve been doing, but in that case we would be reloading a whole bunch of things. What if we just wanted to refresh a particular person (e.g. in preparation for editing it).
The load
function can be used for that as well. Just replace the keyword with an ident, and you’re there!
Load can take the app
or any component’s this
as the first argument, so from within the UI we can trigger a load
using this
:
(df/load this [:person/by-id 3] Person)
Trigger the Load via a User Event
Let’s embed that into our UI at the root:
(defsc Root [this {:keys [ui/react-key friends enemies current-user]}]
{:query [:ui/react-key
{:current-user (prim/get-query Person)}
{:friends (prim/get-query PersonList)}
{:enemies (prim/get-query PersonList)}]
:initial-state (fn [params] {:friends (prim/get-initial-state PersonList {:id :friends :label "Friends"})
:enemies (prim/get-initial-state PersonList {:id :enemies :label "Enemies"})})}
(dom/div
(dom/h4 (str "Current User: " (:person/name current-user)))
; NEW BUTTON HERE:
(dom/button {:onClick (fn [] (df/load this [:person/by-id 3] Person))} "Refresh Person with ID 3")
(ui-person-list friends)
(ui-person-list enemies)))
Handling an Entity Query on the Server
Note
|
The defquery* macros are meant as a quick entry point. Once inside you’ll have access to the subquery in
the env as :query . It is highly recommended that you use pathom to further
process queries. Specifically, we recommend using the "Connect" functionality of Pathom.
|
The incoming query will have a slightly different form, so there is an alternate macro for making a handler for entity
loading. Let’s add this in our server’s read.clj
:
(defquery-entity :person/by-id
"Server query for allowing the client to pull an individual person from the database"
(value [env id params]
; the update is just so we can see it change in the UI
(update (get @people-db id) :person/name str " (refreshed)")))
The defquery-entity
takes the "table name" as the dispatch key. The value
method of the query handler will receive
the server environment, the ID of the entity to load, and any parameters passed with the query (see the :params
option
of load
).
In the implementation above we’re augmenting the person’s name with "(refreshed)" so that you can see it happen in the UI.
Remember to (restart)
your server to load this code.
Your UI should now have a button, and when you press it you should see one person update!
Refreshing "This"
There is a special case that is somewhat common: you want to trigger a refresh from an event on the item that needs
the refresh. The code for that is identical to what we’ve just presented (a load with an ident and component); however,
the data-fetch
namespace includes a convenience function for it.
So, say we wanted a refresh button on each person. We could leverage df/refresh
for that:
(defsc Person [this {:keys [db/id person/name person/age]} {:keys [onDelete]}]
{:query [:db/id :person/name :person/age]
:ident [:person/by-id :db/id]
:initial-state (fn [{:keys [id name age]}] {:db/id id :person/name name :person/age age})}
(dom/li
(dom/h5 (str name " (age: " age ")")
(dom/button {:onClick #(onDelete id)} "X")
(dom/button {:onClick #(df/refresh! this)} "Refresh")))) ; ADD THIS
This should already work with your server, so once the browser hot code reload has happened this button should just work!
Additional Permutations
Fulcro’s load system covers a number of additional bases that bring the story to completion. There are load markers (so you can show network activity), UI refresh add-ons (when you modify data that isn’t auto-detected, e.g. through a post mutation), server query parameters, and error handling. See the Developers Guide, doc strings, or source for more details.
2.11.3. Handling Mutations on The Server
Mutations are handled on the server using the server’s defmutation
macro (if you’re using Fulcro’s built-in request parser).
This has the identical syntax to the client version!
Important
|
You want to place your mutations in the same namespace on the client and server since the defmutation
macros namespace the symbol into the current namespace. So, this is really why we have a duplicated namespace in
Clojure called mutations.clj right next to our mutations.cljs . The defmutation macro will accept a fully-qualified
symbol as well, but that becomes painful for code navigation. If many of your mutations are full-stack, you might
even consider using cljc files and placing the server/client mutations in the same exact code block.
|
So, let’s add an implementation for our server-side delete-person
. Your mutations.clj
should end
up looking like this (don’t forget the require to get access to the people db):
(ns app.api.mutations
(:require
[taoensso.timbre :as timbre]
[app.api.read :refer [people-db]]
[fulcro.server :refer [defmutation]]))
;; Place your server mutations here
(defmutation delete-person
"Server Mutation: Handles deleting a person on the server"
[{:keys [person-id]}]
(action [{:keys [state]}]
(timbre/info "Server deleting person" person-id)
(swap! people-db dissoc person-id)))
Refresh the code on your server with (restart)
at the REPL. However, don’t expect it to work just yet. We have
to tell the client to send the remote request.
Triggering the Remote Mutation from the Client
Mutations are simply optimistic local updates by default. To make them full-stack, you need to add a method-looking
section to your defmutation
handler:
(defmutation delete-person
"Mutation: Delete the person with person-id from the list with list-id"
[{:keys [list-id person-id]}]
(action [{:keys [state]}]
(let [ident-to-remove [:person/by-id person-id]
strip-fk (fn [old-fks]
(vec (filter #(not= ident-to-remove %) old-fks)))]
(swap! state update-in [:person-list/by-id list-id :person-list/people] strip-fk)))
(remote [env] true)) ; This one line is it!!!
The syntax for the addition is:
(remote-name [env] boolean-or-ast)
where remote
is the name of a remote server (the default is remote
). You can have any number of network remotes.
The default one talks to the
page origin at /api
. What is this AST we speak of? It is the abstract syntax tree of the mutation itself (as data).
Using a boolean true means "send it just as the client specified". If you wish you can pull the AST from the env
,
augment it (or completely change it) and return that instead. See the Developers Guide for more details.
Now that you’ve got the UI in place, try deleting a person. It should disappear from the UI as it did before; however, now if you’re watching the network you’ll see a request to the server. If you server is working right, it will handle the delete.
Try reloading your page from the server. That person should still be missing, indicating that it really was removed from the server.
Now that you’ve gotten an overview and have written some code, you can read through the remaining chapters for more detail on each topic.
3. Core Concepts
This chapter covers some detail about the core language features and theory that are important in the Fulcro ecosystem. You need not read this chapter to use Fulcro, but it will aid in your understanding of it quite a bit, especially if you’re relatively new to Clojurescript.
3.1. Immutable Data Structures
Many of the most interesting and compelling features of Fulcro are directly or indirectly enabled (or made simpler) by the use of persistent data structures that are a first-class citizen of the language.
In imperative programming languages like Java and Javascript you have no idea what a function or method might do to your program state:
Person p = new Person();
doSomethingOnAnotherThread(p);
p.fumble();
// did p just change??? Did I just cause a race condition???
This leads to all sorts of subtle bugs and is arguably the source of many of
the hardest problems in keeping software sustainable today. What if Person
couldn’t
change and you instead had to copy instead if you wanted to modify?
Person p = new Person();
doSomethingOnAnotherThread(p);
Person q = p.fumble();
// p is definitely unchanged, but q could be different
Now you can reason about what will happen. The other thread will see p
exactly as
it was when you (locally) reasoned about it. Furthermore, q
cannot be affected
because if p
is truly "read-only" then I still know what it is when I use it to
derive q
(the other thread can’t modify it either).
In order to derive these benefits you need to either write objects that enforce this behavior (which is highly inconvenient and hard to make efficient in imperative langauges), or use a programming language that supplies the ability to do so as a first-class feature.
Another benefit is that persistent data structures can do structural sharing. Basically the new version of a map, vector, list, or set can use references to point to any parts of the old version that are still the same in the new version. This means, for example, that adding an element to the head of a list that had 1,000,000 entries (where only one is being changed) is still a constant time operation!
Here are some of the features in Fulcro that trivially result from using persistent data structures:
-
A Time-travel UI history viewer that consumes little space.
-
Extremely efficient detection of data changes that affect the UI (can be ref compare instead of data compare)
-
"Pure Rendering" is possible and convenient without having to resort to hidden variables in the UI.
3.2. Pure Rendering
Fulcro uses Facebook’s React to accomplish updates to the browser DOM. React, in concept, is really simple:
Render is a function you make that generates a data structure known as the VDOM (a lightweight virtual DOM)
-
On The first "frame", the real DOM is made to match this data structure.
-
On every subsequent frame, render is used to make a new VDOM. React compares the prior VDOM (which is cached) to the new one, and then applies the changes to the DOM.
The cool realization the creators of React had was that the DOM operations that are slow and heavy, but there are efficient ways to figure out what needs to be changed via the VDOM without you having to write a bunch of controller logic.
Now, because React lives in a mutable space (Javascript), it allows all sorts of things that can embed "rendering logic" within a component. This sounds like a good idea to our OOP brains, but consider this:
What if you could have a complete snapshot of the state of your application, pass that to a function, and have the screen just "look right". Like writing a 2D game: you just redraw the screen based on the new "state of the world". All of the sudden your mind shifts away from "bit twiddling" to thinking more about the representation of your model with minimal data!
That is what we mean by "pure rendering".
Here’s an example to whet your appetite: Nested check-boxes. In imperative programming each checkbox has its own state, and when we want a "check all" we end up writing nightmares of logic to make sure the thing works right because we’re having to store a mutable value into an object that then does the rendering. Then we play with it and find out we forgot to handle that event where some sub-box gets unchecked to fire an event to ensure to uncheck the "select all"…oh wait, but when I do that it accidentally fires the event from "check all" which unchecks everything and then goes into an infinite loop!
What a mess! Maybe you eventually figure out something that’s tractable, but that extra bit of state in the "check all" is definitely the source of bugs.
Here’s what you do in pure rendering with immutable data:
Each sub-item checkbox is a simple data structure with a :checked?
key that has a boolean
value. You use that to directly tell the checkbox what its state should be
(and React enforces that…making it impossible for the UI to draw it any
differently)
(def state {:items [{:id :a :checked? true} {:id :b :checked? false} ...]})
For a "state of the world", these are read-only. (you have to make a "new
state of the world" to change one). When you render, the state of the
check-all is just the conjunction of its children’s :checked?
:
(let [all-checked (every? :checked? (get state :items)]
(dom/input {:checked all-checked}))
The check-all button would have no application state at all, and React will force it to the correct state based on the calculated value. When the sub-items change, a new "state of the world" is generated with the altered item:
(def next-state (assoc-in state [:items 0 :checked?] false))
and the entire UI is re-rendered (React makes this fast using the VDOM diff), the "check all" checkbox will just be right!
If the "check all" button is pressed, then the logic is similarly very simple: change the state for the subitems to checked if any were unchecked, or set them all to unchecked if they were all checked:
(def next-state-2
(let [all-checked? (every? :checked? (get state :items))
c (not all-checked?)
old-items (get state :items)
new-items (mapv #(assoc % :checked? c) old-items)]
(assoc state :items new-items)))
and again you get to pretend you’re rendering an entire new frame on the screen!
You’ll be continually surprised at how simple your logic gets in the UI once you adjust to this way of thinking about the problem.
3.3. Data-Driven
Data-driven concepts were pioneered in web development by Facebook’s GraphQL and Netflix’s Falcor. The idea is quite powerful, and eliminates huge amounts of complexity in your network communication and application development.
The basic idea is this: Your UI, which might have various versions (mobile, web, tablet) all have different, but related, data needs. The prevalent way of talking to our servers is to use REST, but REST itself isn’t a very good query 'or' update language. It creates a lot of complexity that we have to deal with in order to do the simplest things. In the small, it is "easy". In the large, it isn’t the best fit.
Data-driven applications basically use a more detailed protocol that allows the client UIs to specify what they need, and also typically includes a "mutation on the wire" notation that allows the client to abstractly say what it needs the server to do.
So, instead of /person/3
you can instead say "I need person 3, but only their
name, age, and billing info. But in the billing info, I only need to know their
billing zip code".
Notice that this abstract expression (which of course has a syntax we’re not showing you yet) is "walking a graph". This is why Facebook calls their language "GraphQL".
You can imagine that the person and billing info might be stored in two tables of a database, with a to-one relationship, and our query is basically asking to query this little sub-graph:
Modifications are done in a similar, abstract way. We model them as if they were "function calls on the wire". Like RPC/RMI:
'(change-person {:id 3 :age 44})
but instead of actually 'calling' the function, we encode this list as a data structure (it is a list containing a symbol and a map: the power of Clojure!) and then process that data locally (in the back-end of the UI) and optionally also transmit it 'as data' over the wire for server processing!
3.4. Graph Database
The client-side of Fulcro keeps all relevant data in a simple graph database, which is referenced by a single top-level atom. The database itself is a persistent map.
The database should be thought of as a root-level node (the top-level map itsef), and tables that can hold data relevant to any particular component or entity in your program (component or entity nodes).
The tables are also simple maps, with a naming convention and well-defined structure. The name of the table is typically namespaced with the "kind" of thing you’re storing, and has a name that indicates the way it is indexed:
{ :person/by-id { 4 { :id 4 :person/name "Joe" }}}
; ^ ^ ^ ^
; kind indexed id entity value itself
3.4.1. Idents
Items are joined together into a graph using a tuple of the table name and the key of
an entity. For example, the item above is known as [:person/by-id 4]
. Notice that this
tuple is also exactly the vector you’d need in an operation that would pull data from that
entity or modify it:
(update-in state-db [:person/by-id 4] assoc :person/age 33)
(get-in state-db [:person/by-id 4])
These tuples are known as 'idents'. Idents can be used anywhere one node in the graph needs to point to another. If the idents (which are vectors) 'appear' in a vector, then you are creating a 'to-many' relation:
{ :person/by-id
{ 1 {:id 1 :person/name "Joe"
:person/spouse [:person/by-id 2] ; (1)
:person/children [ [:person/by-id 3]
[:person/by-id 4] ] } ; (2)
2 { :id 2 :person/name "Julie"
:person/spouse [:person/by-id 1]} ; (3)
3 { :id 3 :person/name "Billy" }
4 { :id 4 :person/name "Heather"}}
-
A to-one relation to Joe’s spouse (Julie)
-
A to-many relation to Joe’s kids
-
A to-relation back to Joe from Julie
Notice in the example above that Joe and Julie point at each other. This creates a 'loop' in the graph. This is perfectly legal. Graphs can contain loops. The table in the example contains 4 nodes.
The client database treats the 'root' node as a special set of non-table properties in the top of the database map. Thus, an entire state database with 'root node' properties might look like this:
The above data structure is now a graph database that looks like this:
This makes for a very compact representation of a graph with an arbitrary number of nodes and edges. All nodes but the special "root node" live in tables. The root node itself is special because it is the storage location for both root properties and for the tables themselves.
Important
|
Since the root node and the tables containing other nodes are merged together into the same overall map it is important that you use care when storing things so as not to accidentally collide on a name. Larger programs should namespace all keywords. |
3.4.2. A Special Note about The Client-Side Database
The graph database on the client is the most central and key concept to understand in Fulcro. Remember that we are doing pure rendering. This means that the UI is simply a function transforming this graph database into the UI.
There are two primary things to write in Fulcro: the UI and the mutations. The UI pulls data from this database and displays it. The mutations evolve this database to a new version. Every interaction that changes the UI should be thought of as a data manipulation. You’re making a new state of the world that your pure renderer turns into DOM.
The graph format of the database means that your data manipulation, the main dynamic thing in the entire application, is simplified down to updating properties/nodes, which themselves live at the top of the state atom or are only 2-3 levels deep:
; change the root list of people, and modify the name and age of person 2
(swap! state (fn [s]
(-> s
(assoc :people [[:people/by-id 1] [:people/by-id 2]])
(assoc-in [:people/by-id 2 :person/name] "George")
(assoc-in [:people/by-id 2 :person/age] 33))))
For the most part the UI takes care of itself. Clojure has very good functions for manipulating maps and vectors, so even when your data structures get more complex the task is still about as simple as it can be.
3.4.3. Client Database Naming Conventions
To avoid collisions in your database, the following naming conventions are recommended for use in the Fulcro client-side graph database:
UI-only Properties |
|
Tables |
|
Root properties |
|
Targeted Loads |
Loads temporarily place their results in root. Targeting relocates them. If you’ve followed the other naming conventions, then these can elide a namespace if that facilitates server interactions. |
Node properties |
|
4. Component Rendering
4.1. HTML5 Element Factories
The core HTML5 elements all have simple factory functions that generate the core elements that stand-in for the real DOM. These stand-ins (commonly referred to as the virtual DOM or VDOM) are ultimately what React uses to generate, diff, and update the real DOM.
So, there are functions for every possible HTML5 element. These are in the
fulcro.client.dom
namespace.
(ns app.ui
(:require [fulcro.client.dom :as dom]))
...
(dom/div :.some-class
(dom/ul {:style {:color :red}}
(dom/li ...)))
Important
|
If you’re writing your UI in CLJC files then you need to make sure you use a conditional
reader to pull in the proper server DOM functions for Clojure:
(ns app.ui (:require #?(:clj [fulcro.client.dom-server :as dom] :cljs [fulcro.client.dom :as dom]))
|
The notation allowed is as follows:
(dom/div "Hello") ; no props
(dom/div nil "Hello") ; nil props (may perform slightly better)
(dom/div #js {:data-x 1} ...) ; js objects are allowed
(dom/div :.cls.cls2#id ...) ; shorthand for specifying static classes and ID
(dom/div :.cls {:data-x 2} "Ho") ; shorthand + props
There is also a :classes
property that can be used to add (typically via expressions) additional
classes. It drops nil, and allows classname keywords and strings as well:
(dom/div :.a {:className "other" :classes [(when hidden "hidden") (if tall :.tall :.short)]} ...)
Assuming hidden
and (not tall)
would yield classes "a other hidden short" on output. Of course you probably don’t need
all of these at once, but supporting them all lets you programmatically combine them when generating props. NOTE: This feature
does not work on props sent with #js
notation.
Remember that this (nested) call of functions results in a representation (VDOM) of what you’d like to end up on the screen.
The next level of abstraction is simply a function. Combining more complex bits of UI into a function is a great way to group re-usable nested DOM:
(defn my-header []
(dom/div :.some-class
(dom/ul
(dom/li ...))))
4.1.1. Fulcro and React DOM – Notes
Here are some common things you’ll want to know how to do that are different when rendering with Fulcro:
-
dom/p
is a function for generating p tags for use in React components. There is one of these for every legal HTML tag.-
Attributes follow the react naming conventions for [Tags and Attributes](https://facebook.github.io/react/docs/tags-and-attributes.html)
-
-
As an example - CSS class names are specified with
:className
instead of:class
.-
Any time a VDOM includes a collection of elements they should each have a unique
:key
attribute. This helps the React diff figure out how that collection has changed. You will get warnings in the browser console if you fail to do so.
-
4.2. The Reconciler
When you start a Fulcro application your :client-did-mount
will get the completed app
as a parameter.
Note
|
Prior to Fulcro 2.8.1 the client was created with new-fulcro-client which had a :started-callback option.
The old method is still available (and unchanged). The new make-fulcro-client method changes some defaults,
and renames started-callback to client-did-mount .
|
Inside of this app is a reconciler under the key :reconciler
. The reconciler is a central component in the system
that is responsible reconciling the differences between the database and the UI. Therefore it
is involved in processing the queries, merging novel data into the database, network interactions, and tracking
mounted components that might need refresh.
You will see it mentioned in many places in this book, and we’ll point out where you’ll use it directly.
4.2.1. Useful Reconciler Options
When you create a new client you can pass options directly to the reconciler with :reconciler-options
.
There are a number of options that are used internally by the higher-level layers of Fulcro and should not
really be used directly, but there are a number of options that can be quite useful.
:shared
|
A map of global (immutable) properties that will be visible to all components. See Shared State. |
:shared-fn
|
A function to compute shared properties from the root props on UI refresh. only recomputed on
root-level refresh, such as a call to |
:root-render
|
The root render function. Defaults to ReactDOM.render. Useful for switching to React Native. |
:root-unmount
|
The root unmount function. Defaults to ReactDOM.unmountComponentAtNode. Useful for React Native. |
:render-mode
|
One of |
:lifecycle
|
A function (fn [component event]) that is called when react components either :mount or :unmount. Useful for debugging tools. |
:tx-listen
|
A function of 2 arguments that will listen to transactions. Called when |
:instrument
|
A function that will wrap all rendering. |
4.2.2. Rendering Optimizations
The reconciler does a number of things to optimize rendering beyond the basics of React.
-
Fulcro provides a built-in
shouldComponentUpdate
that uses a comparison of the prior props to tell React to skip no-op updates that would lead to a useless VDOM diff. -
When more than one component needs a refresh Fulcro analyzes the tree and only renders the parent of common children to to prevent multiple-rendering of children.
-
If a component is the target of a refresh and has an ident then Fulcro will run the query for just that component, avoiding quite a bit of database processing. This is the biggest performance win in "flame graph" tests.
-
NOTE: a component without an ident will always trigger a root query/refresh, since there is no way to figure out how to run that component’s query (queries are relative, and idents give you an anchor point for them).
-
The shouldComponentUpdate
optimization reduces the render load by quite a bit, but running the query from root can
be somewhat costly depending on how well you optimized your UI query. Thus, idents become a major
factor in both normalization and rendering performance since Fulcro relies on them in order to reduce
the query overhead of UI refresh. This isn’t something you typically need to remember, since you should be using
idents on all components so they are easier to refactor and reuse.
4.2.3. Rendering Modes
Version 2.1+ of Fulcro include the ability to tell the reconciler which rendering mode to use.
:normal
-
The default mode. Uses all possible optimizations.
:keyframe
-
Disables the ident-based targeted refresh. Thus every render is considered a key frame of the DOM. This means that every transaction/change will run the root UI query and render from root. The
shouldComponentUpdate
optimization is in force and prevents quite a bit of work from React. This mode can be plenty fast and has the advantage of not needing you to program with follow-on reads. :brutal
-
Disables all optimizations, runs queries from root, runs refresh from root, and forces React to do a full DOM diff. Primarily useful to compare how much benefit optimizations are actually giving you beyond React’s DOM diff.
4.3. The defsc
Macro
Note
|
If you’re new to Fulcro and started with version 2.0, you can safely ignore most comments about defui . The two
are rougly equivalent, with defsc being the newer.
|
Fulcro’s defsc is a front-end to the legacy defui macro. It is sanity-checked for the most common elements: ident (optional), query, render, and initial state (optional). The sanity checking prevents a lot of the most common errors when writing a component, and the concise syntax reduces boilerplate to the essential novelty. The name means "define stateful component" and is intended to be used with components that have queries (though that is not a requirement).
4.3.1. The Argument List
The primary argument list contains the common elements you might need to use in the body:
(defsc [this props <optional-computed> <optional-css-map-if-using-css>]
{ ...options... }
(dom/div {:onClick (:onClick computed)} (:db/id props)))
The last parameter will let you destructure fulcro-css names, if and only if you’re using the Fulcro CSS library.
Only the first two parameters are required, so you can even write:
(defsc [this props]
{ ...options... }
(dom/div (:db/id props)))
Argument Destructuring
The parameter list fully supports Clojure destructuring on the props, computed, and css-name-map without having to write a separate let:
(defsc DestructuredExample [this
{:keys [db/id] :as props}
{:keys [onClick] :as computed :or {onClick identity}}
{:keys [my-css-class] :as css-name-map}]
{:query [:db/id]
:initial-state {:db/id 22}
:css [[:.my-css-class {:color :black}]]}
(dom/div {:className my-css-class}
(str "Component: " id)))
4.3.2. Options – Lambda vs. Template
The core options (:query
, :ident
, :initial-state
, :css
, and :css-include
) of defsc
support both a lambda and a template
form. The template form is shorter and enables some sanity checks; however, it is not expressive enough to cover all
possible cases. The lambda form is slightly more verbose, but enables full flexibility at the expense of the sanity checks.
IMPORTANT NOTE: In lambda mode use this
and props
from the defsc
argument list. So, for example, (fn [] [:x])
is
valid for query (this is added by the macro), and (fn [] [:table/by-id id])
is valid for ident.
4.3.3. Ident Generation
If you include :ident
, it can take two forms: a template or lambda.
Template Idents
A template ident is just a vector that patterns what goes in the ident. The first element is always literal, and the second is the name of the property to pull from props to get the ID.
This is the most common case, and if you use the template mechanism you get some added sanity checks: it won’t compile if your ID key isn’t in your query, eliminating some possible frustration.
Lambda Idents
Template idents are great for the common case, but they don’t work if you have a single instance ever (i.e. you want a literal second element), and they won’t work at all for union queries. They also do not support embedded code. Therefore, if you want a more advanced ident, you’ll need to spell out the code.
defsc
at least eliminates some of the boilerplate:
(defsc UnionComponent [this {:keys [db/id component/type]}]
{:ident (fn [] (union-ident type id))} ; id and type are destructured into the method for you.
...)
4.3.4. Query
defsc
also allows you to specify the query as a template or lambda.
Template Query
The template form is strongly recommended for most cases, because without it many of the sanity checks won’t work.
In template mode, the following sanity checks are enabled:
-
The props destructuring
-
The ident’s id name is in the query (if ident is in template mode)
-
The initial app state only contains things that are queried for (if it is in template mode as well)
Lambda Query
This mode is necessary if you use more complex queries. The template mode currently does not support union queries.
To use this mode, specify your query as (fn [] [:x])
. In lambda mode, this
comes from the argument list of defsc
.
4.3.5. Initial State
As with :query
and :ident
, :initial-state
supports a template and lambda form.
The template form for initial state is a bit magical, because it tries to sanity check your initial state, but also has to support relations through joins. Finally it tries to eliminate typing for you by auto-wrapping nested relation initializations in get-initial-state for you by deriving the correct class to use from the query. This further reduces the chances of error; however, you may find the terse result more difficult to read and instead choose to write it yourself. Both ways are supported:
Lambda mode
This is simple to understand, and all you need to see is a simple example:
(defsc Component [this props]
{:initial-state (fn [params] ...exactly the state this component should start with...)}
...)
Template Mode
In template mode :initial-state
converts incoming parameters (which must use simple keywords) into :param/X keys. So,
(defsc Person [this props]
{:initial-state {:db/id :param/id}}
...)
means:
(defsc Person [this props]
{:initial-state (fn [params] {:db/id (:id params)}})}
...)
It is even more powerful than that, because it analyzes your query and can deal with to-one and to-many join initialization as well:
(defsc Person [this props]
{:query [{:person/job (prim/get-query Job)}]
:initial-state {:person/job {:job/name "Welder"}}
...)
means (in simplified terms):
(defsc Person [this props]
{:initial-state (fn [params] {:person/job (prim/get-initial-state Job {:job/name "Welder"})})}
...)
Notice the magic there. Job
was pulled from the query by looking for joins on the initialization keyword (:person/job
).
To-many relations are also auto-derived:
(defsc Person [this props]
{:query [{:person/prior-jobs (prim/get-query Job)}]
:initial-state {:person/prior-jobs [{:job/name "Welder"} {:job/name "Cashier"]}
...)
means (in simplified terms):
(defsc Person [this props]
{:initial-state (fn [params]
{:person/prior-jobs [(prim/get-initial-state Job {:job/name "Welder"})
(prim/get-initial-state Job {:job/name "Cashier"})]})}
...)
The internal steps for processing this template are:
-
Replace all uses of :param/nm with (get params :nm)
-
The query is analyzed for joins on keywords (ident joins are not supported).
-
If a key in the initial state matches up with a join, then the value in initial state must be a map or a vector. In that case (get-initial-state JoinClass p) will be called for each map (to-one) or mapped across the vector (to-many).
-
REMEMBER: the value that you use in the initial-state for children is the parameter map to use against that child’s initial state function. To-one and to-many relations are implied by what you pass (a map is to-one, a vector is to-many).
Step (1) means that nesting of param-namespaced keywords is supported, but realize that the params come from the declaring component’s initial state parameters, they are substituted before being passed to the child.
4.3.6. Pre-Merge
Available from Fulcro 2.8+.
The :pre-merge
option offers a hook to manipulate data entering your Fulcro app at component level. The option looks like this:
(defsc Countdown [this props]
{:pre-merge (fn [env] ...)}]
The :pre-merge
lambda receives a single map containing the following keys:
-
:data-tree
- the new data tree entering the database -
:current-normalized
- the current entity value (if any), comes normalized from the db (as in(get-in state ref)
) -
:state-map
- the current app state map (you may use to lookup refs). -
:query
- the query used to do this request (user may have modified the original using:focus
,:without
or:update-query
during the load call
and returns the data that should actually be merged into app state. This feature requires an more complete understanding of normalization and full stack operation, and is covered in a later chapter.
4.3.7. CSS Support
Support for using fulcrologic/fulcro-css
is built-in. Before 2.4 it was a dynamic dependency so to use needed to include
the fulcrologic/fulcro-css
library in your project dependencies and require the fulcro-css.css
namespace in any file
that used this support.
As of version 2.4 it is intregrated with Fulcro, and requires no special dependency.
The keys in the defsc
options map to leverage co-located CSS are:
-
:css
- The items to put in protocol method css/local-rules. Can be pure garden data, or(fn [] …)
-
:css-include
- The items to put in protocol method css/include-children. Can be a vector of classes, or(fn [] …)
Both are optional. If you use neither, then your code will not incur a dependency on the fulcro-css library.
See Colocated CSS for more details.
4.3.8. Component Constructor
It is sometimes useful to be able to run some code once on component construction. In React this is particularly useful
when you want a function for saving off :ref
or as other callbacks (so the function isn’t changing all the time).
There is no formal constructor for defsc
, but you can get exactly the effect of a constructor by placing the
code for it in :initLocalState
, which is guaranteed to run once and only once per instance construction:
(defsc Component [this props]
{:initLocalState (fn [] ;; this is accessible
(set! (.-saveref this) (fn [r] (set! (.-ref this) r)))
;; just also remember to return something to act as your React component initial local state!
{})}
(dom/input {:ref (.-saveref this)}))
4.3.9. React Lifecycle Methods
The options of defsc allow for React Lifecycle methods to be defined (as lambdas). The this
parameter of defsc
is
in scope for all of them, but not props
or computed
. You can obtain computed using prim/get-computed
. This is
because the lifecycle method may receive prior or next props, and using the top parameter list could be confusing.
The signatures are:
(defsc Component [this props]
; remember that 'this' is in scope for lifecycle
:initLocalState (fn [] ...)
:shouldComponentUpdate (fn [next-props next-state] ...)
:componentWillReceiveProps (fn [next-props] ...)
:componentWillUpdate (fn [next-props next-state] ...)
:componentDidUpdate (fn [prev-props prev-state] ...)
:componentWillMount (fn [] ...)
:componentDidMount (fn [] ...)
:componentWillUnmount (fn [] ...)
;; Replacements for deprecated methods in React 16.3+
:UNSAFE_componentWillReceiveProps (fn [next-props] ...)
:UNSAFE_componentWillUpdate (fn [next-props next-state] ...)
:UNSAFE_componentWillMount (fn [] ...)
;; ADDED for React 16:
:componentDidCatch (fn [error info] ...)
:getSnapshotBeforeUpdate (fn [prevProps prevState] ...)
:getDerivedStateFromProps (fn [props state] ...)
;; ADDED for React 16.6:
:getDerivedStateFromError (fn [error] ...) **NOTE**: Sets low-level state, Use get-react-state.
See the React documentation for more details on how these work.
Component Local State and Errors
Fulcro wraps React’s state such that you can easily store cljs data in the component (e.g.
arguments to methods like componentDidUpdate
prev-state
will be a cljs map, NOT the raw JS one
that React uses).
The same is true for the return value of initLocalState
: You return a CLJS map, and it is safely stored in react
state and behave just like the native.
The advantage of this is twofold:
-
Convenience: You get to use immutable data for component-local state
-
Speed: Fulcro is able to very quickly compare and short-circuit React rendering because immutable state is very fast to compare (it can mostly be a single reference compare).
This is accomplished by storing the "Fulcro" version of component state under the fulcro$state
key in the low-level js
state. Normally, this is of no concern to you at all, but React 16.7 added a static lifecycle method
called getDerivedStateFromError
whose return value is meant to be a js map that gets merged to the low-level state,
and there is no internal hook or hack (yet found) to merge that into the correct Fulcro location.
Thus, if you provide a getDerivedStateFromError
your return value (a CLJS map) will overwrite your current
component local state.
If you’re not using component local state for anything else, then this is probably just fine, but if you have important information in component-local state you may want to choose an alternative.
One workaround is to set state in componentDidCatch
. This is deprecated by React because it happens in a different
phase of the React processing, so you should read up on that in the React docs to see if it is acceptable for your
case.
Another (trivial but possibly annoying) workaround is to write a wrapper component that does nothing but deal with errors, so that the state is never used for anything but error handling.
Additional Protocol Support
If you need to include additional protocols (or lifecycle React methods) on the generated class then you can use the
:protocols
option. It takes a list of forms that have the same shape as the body of a defui
, and the static
qualifier
is supported. If you supply Object
methods then they will be properly combined with the generated render:
Here is an example of adding Fulcro CSS using protocols instead of options:
(defsc MyComponent [this props]
{:protocols (Object
static css/CSS
(local-rules [_] [])
(include-children [_] []))
...}
(dom/div ...))
This gives you the full protocol capabilities of defui
, but you only need the extra protocol additions when you
use methods and protocols beyond the central ones.
4.3.10. Sanity Checking
The sanity checking mentioned in the earlier sections causes compile errors. The errors are intended to be self-explanatory. They will catch common mistakes (like forgetting to query for data that you’re pulling out of props, or misspelling a property).
Feel free to edit the components in this source file and try out the sanity checking. For example, try:
-
Mismatching the name of a prop in options with a destructured name in props.
-
Destructuring a prop that isn’t in the query
-
Including initial state for a field that is not listed as a prop or child in options.
-
Using a scalar value for the initial value of a child (instead of a map or vector of maps)
-
Forget to query for the ID field of a component that is stored at an ident
In some cases the sanity checking is more aggressive that you might desire. To get around it simply use the lambda style.
4.4. Factories
Factories are how you generate React elements (the virtual DOM nodes) from your React classes defined with
defsc
. You make a new factory using fulcro.client.primitives/factory
:
(def ui-component (prim/factory MyComponent {:keyfn f :validator v :instrument? true}))
There are 3 supported options to a factory:
:keyfn
|
A function from |
:validator
|
A function from props to boolean. If it returns false then an assertion will be thrown at runtime. |
:instrument?
|
A boolean. If true, it indicates that instrumentation should be enabled on the component. |
Instrumentation is a function you can install on the reconciler that wraps component render
allowing you to add
measurement and debugging code to your component’s rendering.
In Fulcro documentation we generally adopt the naming convention for UI factories to be prefixed with ui-
. This
is because you often want to name joins the same thing as a component: e.g. your query might be
[{:child (prim/get-query Child)}]
, and then when you destructure in render: (let [{:keys [child]} (prim/props this) …
you have local data in the symbol child
. If your UI factory was also called child
then it would cause annoying name
collisions. Prefixing the factories with ui-
makes it very clear what is data and what is an element factory.
4.5. Render and Props
Properties are always passed to a component factory as the first argument. The properties can be accessed
from within render
by calling fulcro.client.primitives/props
on the parameter passed to render
(typically named this
to remind you that it is a reference to the instance itself).
In components with queries there is a strong correlation between the query (which must join the child’s query), props (from which you must extract the child’s props), and calling of the child’s factory (to which you must pass the child’s data).
If you are using components that do not have queries, then you may pass whatever properties you deem useful.
Details about additional aspects of rendering are in the sections that follow.
4.5.1. Derived Values
It is possible that your logic and state will be much simpler if your UI components derive some values at render time. A prime example of this is the state of a "check all" button. The state of such a button is dependent on other components in the UI, and it is not a separate value. Thus, your UI should compute it and not store it else it could easily become out of sync and lead to more complex logic.
(defn item-checked? [item] (:checked? item))
(defsc Checkboxes [this {:keys [items]}]
{:query [{:items (prim/get-query CheckboxItem)}]}
(let [all-checked? (every item-checked? items)]
(dom/div
"All: " (dom/input {:checked all-checked? ...})
(dom/ul ...))))
General Guidelines for Derived Values
You should consider computing a derived value when: * The known data from the props already gives you sufficient information to calculate the value. * The computation is relatively light.
Some examples where UI computation are effective, light, or even necessary:
-
Rendering an internationalized value. (e.g.
tr
) -
Rendering a check-all button
-
Rendering "row numbering" or other decorations like row highlighting
There are some trade-offs, but most significantly you generally do not want to compute things like the order/pagination of a list of items. The logic and overhead in sorting and pagination often needs caching, and there are clear and easy "events" (user clicking on sort-by-name) that make it clear when to call the mutation to update the database. You still have to store the selected sort order, and you have to have idents pointing to the list of items. It is possible for your "selected sort order" and list to become out of sync, but the trade-offs of sorting in the UI are typically high, particularly when pagination is involved and large amounts of data would have to be fed to the UI.
4.5.2. Computed Props and Callbacks
Many reusable components will need to tell their parent about some event. For example, a list item generally wants to tell the parent when the user has clicked on the "remove" button for that item. The item itself cannot be truly composable if it has to know details of the parent. But a parent must always know the details of a child (it rendered it, didn’t it?). As such, manipulations that affect the content of a parent should be communicated to that parent for processing. The mechanism for this is identical to what you’d do in stock React: callbacks from the child.
The one major difference is how you pass the callback to a component.
The query and data feed mechanisms that supply props to a component are capable of refreshing a child without refreshing a parent. This UI optimization can pull the props directly from the database using the query, and re-feed them to the child.
But this mechanism knows nothing about callbacks, because they are not (and should not be) stored in the client database. Such a targeted refresh of a component cannot pass callbacks through the props because the parent is where that is coded, but the parent may not be involved in the refresh!
So, any value (function or otherwise) that is generated on-the-fly by the parent must be passed via
fulcro.client.primitives/computed
. This tells the data feed system how to reconstruct the complete data should it do a targeted update.
(defsc Child [this {:keys [y]}]
{:query [:y]}
(let [onDelete (prim/get-computed this :onDelete)]
...))
(defsc Parent [this {:keys [x child]}]
{:query [:x {:child (prim/get-query Child)}]}
(let [onDelete (fn [id] (prim/transact! ...))
child-props-with-callbacks (prim/computed child {:onDelete onDelete})]
(ui-child child-props-with-callbacks)))
Warning
|
Not understanding this can cause a lot of head scratching: The initial render will always work perfectly, because the parent is involved. All events will be processed, and you’ll think everything is fine; however, if you have passed a callback incorrectly it will mysteriously stop working after a (possibly unnoticeable) refresh. This means you’ll "test it" and say it is OK, only to discover you have a bug that shows up during heavier use. |
4.5.3. Children
A very common pattern in React is to define a number of custom components that are intended to work in a nested fashion. So,
instead of just passing props
to a factory, you might also want to pass other React elements. This is fully supported
in Fulcro, but can cause confusion when you first try to mix it with the data-driven aspect of the system.
Working with Children
Fulcro includes a few functions that are helpful when designing React components that are intended to be nested as direct children within a single render:
(prim/children this)
|
Returns the React children of |
(cutil/react-instance? Component instance)
|
Returns true if the given element is an instance of the given component class. Otherwise |
(cutil/first-node Component child-seq)
|
Returns the first of a sequence of elements that has the given component class. |
So, say you wanted to create the following kind of rendering scheme:
(defsc Panel ...)
(def ui-panel (prim/factory Panel))
(defsc PanelHeader ...)
(def ui-panel-header (prim/factory PanelHeader))
(defsc PanelBody ...)
(def ui-panel-body (prim/factory PanelBody))
(ui-panel {}
(ui-panel-header {} "Some Heading Text")
(ui-panel-body {}
(dom/div "Some sub-DOM")))
The DOM generation for Panel
will need to find the header and body children:
(defsc Panel [this props]
(let [children (prim/children this)
header (util/first-node PanelHeader children)
body (util/first-node PanelBody children)]
(when header
(dom/h4 header))
(when body
(dom/div body))))
Basically, the child or children can simply be dropped into the place where they should be rendered.
React 16 Fragments and Returning Multiple Children
Fulcro 2.6+ with React 16 allows you to return a vector of children or a Fragment
from a component (so you no longer
are forced to have a single child as a return value). Fulcro DOM namespaces include a fragment
function to wrap elements in a fragment:
(defsc X [this props]
(prim/fragment ; in the prim namespace, because it isn't DOM specific. Could be used with native.
(dom/p "a")
(dom/p "b")))
or
(defsc X [this props]
[(dom/p {:key "a"} "a") (dom/p {:key "b"} "b")])
allows your component to return multiple elements that are spliced into the parent without any "wrapping" elements in the DOM. Notice when returning a vector you need to supply React keys.
(dom/div (ui-x)) => <div><p>a</p><p>b</p></div>
See React documentation for more details.
Mixing Data-Driven Children with UI-Only Concerns
At first this seems a little mind-bending, because you are in fact nesting components in the UI, but the query nesting need only mimic the stateful portion of the UI tree. This means there is ample opportunity to use React children in a way that looks incorrect from what you’ve learned so far. On deeper inspection it turns out it is alignment with the rules, but it takes a minute on first exposure.
Take the Bootstrap collapse component: It needs state of its own in order to know when it is collapsed, and we’d like that to be part of the application database so that the support history viewer can show the correct thing. However, the children of the collapse cannot be known in advance when writing the collapse reusable library component.
The solution is simple once you see it: Query for the collapse component’s state and the child state in the common parent component, then do the UI nesting in that component. Technically the component that is "laying out" the UI (the ultimate parent) is in charge of both obtaining and rendering the data. The fact that the UI child ends up nested in a query sibling is perfectly fine.
The collapse component itself is only concerned with the fact that it is open/closed, and that it has children that should be shown/hidden. The actual DOM elements of those children are immaterial, and can be assembled by the parent:
(defsc CollapseExample [this {:keys [collapse-1 child]}]
{:initial-state (fn [p] {:collapse-1 (prim/get-initial-state b/Collapse {:id 1 :start-open false})})
:query [{:collapse-1 (prim/get-query b/Collapse)}
{:child (prim/get-query SomeChild)}]}
(dom/div
(b/button {:onClick (fn [] (prim/transact! this `[(b/toggle-collapse {:id 1})]))} "Toggle")
(b/ui-collapse collapse-1
(ui-child child))))
4.6. Controlled Inputs
Form inputs in React can take two possible approaches: controlled and uncontrolled. The browser normally maintains the value state of inputs for you as mutable data; however, this breaks our overall model of pure rendering! The advantage is UI interaction speed: If your UI gets rather large, it is possible that UI updates on keystrokes in form inputs may be too slow. This is the same sort of trade-off that we talked about when covering component local state for rendering speed with more graphical components. If you follow the basic optimization guidelines then your application should be fast enough to do database updates on every keystroke, and you can keep all input changes in your client database.
In general it is recommended that you use controlled inputs and retain the benefits of pure rendering: no embedded state, your UI exactly represents your data representation, concrete devcards support for UI prototyping, and full support viewer support.
Most inputs become controlled when you set their :value
property. The table below lists the mechanism whereby
a form input is completely controlled by React:
Input type | Attribute | Notes |
---|---|---|
input |
:value |
(not checkboxes or radio) |
checkbox |
:checked |
|
radio |
:checked |
(only one in a group should be checked) |
textarea |
:value |
|
select |
:value |
Instead of marking an option selected. Match |
Important
|
React will consider nil to mean you want an uncontrolled component. This can result in
a warning about converting uncontrolled to controlled components. In order to prevent this warning you should make
sure that :checked is always a boolean, and that other inputs have a valid :value (e.g. an empty string). The
select input can be given an "extra" option that stands for "not selected yet" so that you can start its value
at something valid.
|
See React Forms for more details.
4.7. React Lifecycle Examples
Note
|
Fulcro 2.6+ includes React 16.4+ Lifecycle support. |
There are some common use-cases that can only be solved by working directly with the React Lifecycle methods.
Some topics you should be familiar with in React to accomplish many of these things are:
-
Component references: A mechanism that allows you access to the real DOM of the component once it’s on-screen.
-
Component-local state: A stateful mechanism where mutable data is stored on the component instance.
-
General DOM manipulation. Clojurescript builds using the Google Closure compiler and therefore includes the Google Closure library, which in turn has all sorts of helpful low-level functions should you need them.
4.7.1. Focusing an input
Focus is a stateful browser mechanism, and React cannot force the rendering of "focus". As such, when you need
to deal with UI focus it generally involves some interpretation, and possibly component local state. One way
of dealing with deciding when to focus is to look at a component’s prior vs. next properties. This can be
done in componentDidUpdate
. For example, say you have an item that renders as a string, but when clicked
turns into an input field. You’d certainly want to focus that, and place the cursor at the end of the
existing data (or highlight it all).
If your component had a property called editing?
that you made true to indicate it should render as an input
instead of just a value, then you could write your focus logic based on the transition of your component’s props
from :editing?
false to :editing?
true.
(ns book.ui.focus-example
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.client.mutations :as m]))
(defsc ClickToEditField [this {:keys [value editing?]}]
{:initial-state {:value "ABC"
:db/id 1
:editing? false}
:query [:db/id :value :editing?]
:ident [:field/by-id :db/id]
:componentDidUpdate (fn [prev-props _]
(when (and (not (:editing? prev-props)) (:editing? (prim/props this)))
(let [input-field (dom/node this "edit_field")
input-field-length (.. input-field -value -length)]
(.focus input-field)
(.setSelectionRange input-field input-field-length input-field-length))))}
(dom/div
; trigger a focus based on a state change (componentDidUpdate)
(dom/a {:onClick #(m/toggle! this :editing?)}
"Click to focus (if not already editing): ")
(dom/input {:value value
:onChange #(m/set-string! this :event %)
:ref "edit_field"})
; do an explicit focus
(dom/button {:onClick (fn []
(let [input-field (dom/node this "edit_field")
input-field-length (.. input-field -value -length)]
(.focus input-field)
(.setSelectionRange input-field 0 input-field-length)))}
"Highlight All")))
(def ui-click-to-edit (prim/factory ClickToEditField))
(defsc Root [this {:keys [field] :as props}]
{:query [{:field (prim/get-query ClickToEditField)}]
:initial-state {:field {}}}
(ui-click-to-edit field))
Note
|
React documentation encourages a more functional form of ref (you supply a function instead of a string).
This example could also cache that on the component like this:
|
(ns app
(:require [goog.object :as gobj]))
(defsc ClickToEditField [this {:keys [value editing?]}]
...
(dom/input {:ref (fn [r] (gobj/set this "input-ref" r))})
...
However, The wrapped inputs of Fulcro 2.3.0 and earlier (which fixed a different issue with React) did
not work with the functional ref technique (use strings and dom/node
instead). As of 2.3.1 this is fixed, and while inputs still have the internal wrapping to prevent "lost keys" bugs,
they work with both the older string support or functional refs.
4.7.2. Taking control of the sub-DOM (D3, etc)
Libraries like D3 are great for dynamic visualizations, but they need full control of the portion of the DOM that they create and manipulate.
In general this means that your render
method should be called once
(and only once) to install the base DOM onto which the other library
will control.
For example, let’s say we wanted to use D3 to render things. We’d first write a function that would take the real DOM node and the incoming props:
(defn db-render [DOM-NODE props] ...)
This function should do everything necessary to render the sub-dom (and
update it if the props change). Then we’d wrap that under a component that
doesn’t allow React to refresh that sub-tree via shouldComponentUpdate
.
Below is a demo of this:
(ns book.ui.d3-example
(:require [fulcro.client.dom :as dom]
;; REQUIRES shadow-cljs, with "d3" in package.json
["d3" :as d3]
[goog.object :as gobj]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.primitives :as prim :refer [defsc]]))
(defn render-squares [dom-node props]
(let [svg (-> d3 (.select dom-node))
data (clj->js (:squares props))
selection (-> svg
(.selectAll "rect")
(.data data (fn [d] (.-id d))))]
(-> selection
.enter
(.append "rect")
(.style "fill" (fn [d] (.-color d)))
(.attr "x" "0")
(.attr "y" "0")
.transition
(.attr "x" (fn [d] (.-x d)))
(.attr "y" (fn [d] (.-y d)))
(.attr "width" (fn [d] (.-size d)))
(.attr "height" (fn [d] (.-size d))))
(-> selection
.exit
.transition
(.style "opacity" "0")
.remove)
false))
(defsc D3Thing [this props]
{:componentDidMount (fn []
(when-let [dom-node (gobj/get this "svg")]
(render-squares dom-node (prim/props this))))
:shouldComponentUpdate (fn [next-props next-state]
(when-let [dom-node (gobj/get this "svg")]
(render-squares dom-node next-props))
false)}
(dom/svg {:style {:backgroundColor "rgb(240,240,240)"}
:width 200 :height 200
:ref (fn [r] (gobj/set this "svg" r))
:viewBox "0 0 1000 1000"}))
(def d3-thing (prim/factory D3Thing))
(defn random-square []
{
:id (rand-int 10000000)
:x (rand-int 900)
:y (rand-int 900)
:size (+ 50 (rand-int 300))
:color (case (rand-int 5)
0 "yellow"
1 "green"
2 "orange"
3 "blue"
4 "black")})
(defmutation add-square [params]
(action [{:keys [state]}]
(swap! state update :squares conj (random-square))))
(defmutation clear-squares [params]
(action [{:keys [state]}]
(swap! state assoc :squares [])))
(defsc Root [this props]
{:query [:squares]
:initial-state {:squares []}}
(dom/div
(dom/button {:onClick #(prim/transact! this
`[(add-square {})])} "Add Random Square")
(dom/button {:onClick #(prim/transact! this
`[(clear-squares {})])} "Clear")
(dom/br)
(dom/br)
(d3-thing props)))
The things to note for this example are:
-
We override the React lifecycle method
shouldComponentUpdate
to return false. This tells React to never ever call render once the component is mounted. D3 is in control of the underlying stuff. -
We override
componentWillReceiveProps
andcomponentDidMount
to do the actual D3 render/update. The former will get incoming data changes, and the latter is called on initial mount. Our render method delegates all of the hard work to D3.
4.7.3. Dynamically rendering into a canvas
Sometimes you need to use component-local state to avoid the overhead in running a query to feed props. An example of this is when handing mouse interactions like drag. You’ll typically use React refs to grab the actual low-level canvas.
In Fulcro version before 2.6 there are actually two ways to change component-local state. One of them defers rendering to the next animation frame, but it also reconciles the database with the stateful components. This one will not give you as much of a speed boost (though it may be enough, since you’re not changing the database or recording more UI history).
The other mechanism completely avoids this, and just asks React for an immediate forced update.
In Fulcro 2.6 set-state!
is a Fulcro wrapper of React’s setState
, but it let’s you use cljs maps instead of
js ones. The shallow merge described by React is honored as if you used an updater function (there is no need
for an updater function). The set-state!
also allows a callback to give you full React compatibility. Note, however,
that React 16’s setState
schedule a state update and render. It is not guaranteed to be immediate.
The update-state!
function in Fulcro is like Clojurescripts swap!
function, and uses set-state!
behind the
scenes.
In older versions of Fulcro:
-
(set-state! this data)
and(update-state! this data)
- trigger a reconcile against the database at the next animation frame. Limits frame rate to 60 fps (in 2.6+ is identical to ReactsetState
with anupdater
and optionalcallback
, but you just supply the map that will be merged. Useupdate-state!
if you need more functional control. -
(react-set-state! this data)
- triggers a React render according to React’ssetState
documentation.
In this example we’re using set-state!
, and you can see it is still plenty fast (in 2.6 there is no difference)!
(ns book.ui.hover-example
(:require
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.mutations :refer [defmutation]]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[goog.object :as gobj]
[fulcro.client.dom :as dom]))
(defn change-size*
"Change the size of the canvas by some (pos or neg) amount.."
[state-map amount]
(let [current-size (get-in state-map [:child/by-id 0 :size])
new-size (+ amount current-size)]
(assoc-in state-map [:child/by-id 0 :size] new-size)))
; Make the canvas smaller. This will cause
(defmutation ^:intern make-smaller [p]
(action [{:keys [state]}]
(swap! state change-size* -20)))
(defmutation ^:intern make-bigger [p]
(action [{:keys [state]}]
(swap! state change-size* 20)))
(defmutation ^:intern update-marker [{:keys [coords]}]
(action [{:keys [state]}]
(swap! state assoc-in [:child/by-id 0 :marker] coords)))
(defn event->dom-coords
"Translate a javascript evt to a clj [x y] within the given dom element."
[evt dom-ele]
(let [cx (.-clientX evt)
cy (.-clientY evt)
BB (.getBoundingClientRect dom-ele)
x (- cx (.-left BB))
y (- cy (.-top BB))]
[x y]))
(defn event->normalized-coords
"Translate a javascript evt to a clj [x y] within the given dom element as normalized (0 to 1) coordinates."
[evt dom-ele]
(let [cx (.-clientX evt)
cy (.-clientY evt)
BB (.getBoundingClientRect dom-ele)
w (- (.-right BB) (.-left BB))
h (- (.-bottom BB) (.-top BB))
x (/ (- cx (.-left BB))
w)
y (/ (- cy (.-top BB))
h)]
[x y]))
(defn render-hover-and-marker
"Render the graphics in the canvas. Pass the component props and state. "
[canvas props coords]
(let [marker (:marker props)
size (:size props)
real-marker-coords (mapv (partial * size) marker)
; See HTML5 canvas docs
ctx (.getContext canvas "2d")
clear (fn []
(set! (.-fillStyle ctx) "white")
(.fillRect ctx 0 0 size size))
drawHover (fn []
(set! (.-strokeStyle ctx) "gray")
(.strokeRect ctx (- (first coords) 5) (- (second coords) 5) 10 10))
drawMarker (fn []
(set! (.-strokeStyle ctx) "red")
(.strokeRect ctx (- (first real-marker-coords) 5) (- (second real-marker-coords) 5) 10 10))]
(.save ctx)
(clear)
(drawHover)
(drawMarker)
(.restore ctx)))
(defn place-marker
"Update the marker in app state. Derives normalized coordinates, and updates the marker in application state."
[child evt]
(let [canvas (gobj/get child "canvas")]
(prim/transact! child `[(update-marker
{:coords ~(event->normalized-coords evt canvas)})])))
(defn hover-marker
"Updates the hover location of a proposed marker using canvas coordinates. Hover location
is stored in component local state (meaning that a low-level app database query will not
run to do the render that responds to this change)"
[child evt]
(let [canvas (gobj/get child "canvas")
updated-coords (event->dom-coords evt canvas)]
(prim/set-state! child {:coords updated-coords})
(render-hover-and-marker canvas (prim/props child) updated-coords)))
(defsc Child [this {:keys [id size] :as props}]
{:query [:id :size :marker]
:initial-state (fn [_] {:id 0 :size 50 :marker [0.5 0.5]})
:ident (fn [] [:child/by-id id])
:initLocalState (fn [] {:coords [-50 -50]})}
; Remember that this "render" just renders the DOM (e.g. the canvas DOM element). The graphical
; rendering within the canvas is done during event handling.
; size comes from props. Transactions on size will cause the canvas to resize in the DOM
(when-let [canvas (gobj/get this "canvas")]
(render-hover-and-marker canvas props (prim/get-state this :coords)))
(dom/canvas {:width (str size "px")
:height (str size "px")
:onMouseDown (fn [evt] (place-marker this evt))
:onMouseMove (fn [evt] (hover-marker this evt))
; This is a pure React mechanism for getting the underlying DOM element.
; Note: when the DOM element changes this fn gets called with nil
; (to help you manage memory leaks), then the new element
:ref (fn [r]
(when r
(gobj/set this "canvas" r)
(render-hover-and-marker r props (prim/get-state this :coords))))
:style {:border "1px solid black"}}))
(def ui-child (prim/factory Child))
(defsc Root [this {:keys [child]}]
{:query [{:child (prim/get-query Child)}]
:initial-state (fn [params] {:ui/react-key "K" :child (initial-state Child nil)})}
(dom/div
(dom/button {:onClick #(prim/transact! this `[(make-bigger {})])} "Bigger!")
(dom/button {:onClick #(prim/transact! this `[(make-smaller {})])} "Smaller!")
(dom/br)
(dom/br)
(ui-child child)))
The component receives mouse move events to show a hover box. To make this move in real-time we use component local state. Clicking to set the box, or resize the container are real transactions, and will actually cause a refresh from application state to update the rendering.
4.8. Using Javascript React Components
One of the great parts about React is the ecosystem. There are some great libraries out there. However, the interop story isn’t always straight forward. The goal of this section is to make that story a little clearer.
4.8.1. Factory Functions for JS React Components
Integrating React components is fairly straightforward if you have used React from JS before. The curve comes having spent time with libraries or abstractions like Om and friends. JSX will also abstract some of this away, so it’s not just the cljs wrappers. For a good article explaining some of the concepts read, React Elements The take-aways here are:
-
If you are importing third party components, you should be importing the class, not a factory.
-
You need to explicitly create the react elements with factories. The relevant js functions are React.createElement, and React.createFactory.
It is very important to consider when using any of these functions - the children. JS does not have a built in notion of lazy sequences. Clojurescript does. This can create subtle bugs when evaluating the children of a component.
fulcro.util/force-children
helps us in this regard by taking a seq and returning a vector. We can use this to
create our own factory function, much like React.createFactory:
(defn factory-force-children
[class]
(fn [props & children]
(js/React.createElement class
props
(util/force-children children))))
This is fine, but you will notice that children in our factory may be missing keys. Because we passed a vector in, React won’t attach the key attribute. We can solve this problem by using the apply function.
(defn factory-apply
[class]
(fn [props & children]
(apply js/React.createElement
class
props
children)))
Here the apply function will pass the children in as args to React.createElement
, thus avoiding the key problem
as well as the issue with lazy sequences.
Now that we have some background on creating React Elements it’s pretty simple to implement something. Let' look at making a chart using Victory. We are going to make a simple line chart, with an X axis that contains years, and a Y axis that contains dollar amounts. Really the data is irrelavent, it’s the implementation we care about.
The running code and source are below:
(ns book.ui.victory-example
(:require [cljs.pprint :refer [cl-format]]
;; REQUIRES shadow-cljs, with "victory" in package.json
["victory" :refer [VictoryChart VictoryAxis VictoryLine]]
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.util :as util]))
(defn us-dollars [n]
(str "$" (cl-format nil "~:d" n)))
(defn factory-force-children
[class]
(fn [props & children]
(js/React.createElement class
props
(util/force-children children))))
(defn factory-apply
[class]
(fn [props & children]
(apply js/React.createElement
class
props
children)))
(def vchart (factory-apply VictoryChart))
(def vaxis (factory-apply VictoryAxis))
(def vline (factory-apply VictoryLine))
;; " [ {:year 1991 :value 2345 } ...] "
(defsc YearlyValueChart [this {:keys [label plot-data x-step]}]
(let [start-year (apply min (map :year plot-data))
end-year (apply max (map :year plot-data))
years (range start-year (inc end-year) x-step)
dates (clj->js (mapv #(new js/Date % 1 2) years))
{:keys [min-value
max-value]} (reduce (fn [{:keys [min-value max-value] :as acc}
{:keys [value] :as n}]
(assoc acc
:min-value (min min-value value)
:max-value (max max-value value)))
{}
plot-data)
min-value (int (* 0.8 min-value))
max-value (int (* 1.2 max-value))
points (clj->js (mapv (fn [{:keys [year value]}]
{:x (new js/Date year 1 2)
:y value})
plot-data))]
(vchart nil
(vaxis #js {:label label
:standalone false
:scale "time"
:tickFormat (fn [d] (.getFullYear d))
:tickValues dates})
(vaxis #js {:dependentAxis true
:standalone false
:tickFormat (fn [y] (us-dollars y))
:domain #js [min-value max-value]})
(vline #js {:data points}))))
(def yearly-value-chart (prim/factory YearlyValueChart))
(defsc Root [this props]
{:initial-state {:label "Yearly Value"
:x-step 2
:plot-data [{:year 1983 :value 100}
{:year 1984 :value 100}
{:year 1985 :value 90}
{:year 1986 :value 89}
{:year 1987 :value 88}
{:year 1988 :value 85}
{:year 1989 :value 83}
{:year 1990 :value 80}
{:year 1991 :value 70}
{:year 1992 :value 80}
{:year 1993 :value 90}
{:year 1994 :value 95}
{:year 1995 :value 110}
{:year 1996 :value 120}
{:year 1997 :value 160}
{:year 1998 :value 170}
{:year 1999 :value 180}
{:year 2000 :value 180}
{:year 2001 :value 200}
]}}
(dom/div
(yearly-value-chart props)))
4.8.2. The Function-As-a-Child Pattern
A common pattern in React libraries is to use a function as a single child instead of an actual element. This is an accepted and widely used pattern, but you need to do a simple extra step for it to work properly with Fulcro. You see, Fulcro components use some behind-the-scenes bindings to allow for targeted UI rendering optimizations, and when you embed them in a function that is invoked from external JS out of that context, things won’t work correctly.
For example, the react-motion
library gives you React tools that can animate DOM motion. It animates variables
that you apply to nested DOM, and it does this through the function-as-a-child pattern. Here’s an example from
a demo project (which uses shadow-cljs to get easy access to NPM
libraries):
(ns sample
(:require ["react-motion" :refer [Motion spring]]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[goog.object :as gobj]))
(def ui-motion (factory-apply Motion))
(defsc Demo [this {:keys [ui/slid? block]}]
{:query [:ui/slid? {:block (prim/get-query Block)}]
:initial-state {:ui/slid? false :block {:id 1 :name "N"}}
:ident (fn [] [:control :demo])}
(dom/div
(dom/button {:onClick (fn [] (m/toggle! this :ui/slid?))} "Toggle")
(ui-motion (clj->js {:style {"x" (spring (if slid? 400 0))}})
(fn [p]
(let [x (gobj/get p "x")]
; The binding wrapper ensures that internal fulcro bindings are held within the lambda
(prim/with-parent-context this
(dom/div :.demo
(ui-block (prim/computed block {:x x})))))))))
The key is the call to with-parent-context
. It causes the enclosed elements to have bindings pulled
from the component passed as the first parameter (in this case this
). Rendering will work
correctly without this wrapper, but interactions (particularly transact!
) will not operate correctly
without it.
4.9. Colocated CSS
Fulcro includes support for localizing CSS rules to components. It leverages Garden for interpreting component-localized hiccup-style CSS rules.
Before 2.4 you needed to include fulcrologic/fulcro-css
as a dependency. As of 2.4, this is no longer
the case.
The support make it easy to co-locate and compose localized component CSS with your UI! This leads to all sorts of wonderful and interesting results:
-
Your CSS is name-localized, so your component’s CSS is effectively private
-
You don’t have to go find the CSS file to include it in your application. It’s already in the code.
-
Since it’s code, you can use data manipualtion, variables, and more to create your CSS. It is the ultimate in code reuse and generality.
-
The composition facilities prevent duplication.
-
It becomes much easier to allow themed components with additional tool chains! Variables can be supplied that can simply be modified via code before CSS injection.
-
Google Closure’s minification can be applied to reduce the size of the resulting files during your normal compilation!
-
Server-side rendering can pre-inject CSS rules into the server-side rendered page! No more waiting for your CSS to load for the UI to look right!
4.9.1. Basics
CSS can be co-located on any component. This CSS does not take effect until it is embedded on the page (see Embedding The CSS below). The typical steps for usage are:
-
Add localized rules to your component via the
:css
option ofdefsc
or by implementing thefulcro-css.css-protocols/CSS
protocol’slocal-rules
. NOTE: (in olders versions this protocol was namedfulcro-css.css/CSS
) The result of this must be a vector in Garden notation. Any rules included here will be automatically prefixed with the CSSified namespace and component name to ensure name collisions are impossible. -
(optional) Add "global" rules. The vector of rules (in the prior step) can use a
$
prefix to prevent localization (:$container
instead of:.container
). -
(optional) Add the
:css-include
option todefsc
. This MUST be a vector of components that are used within therender
that also supply CSS. This allows the library to compose together your CSS according to what components you use. Pulling the CSS rules from some top-level component will dedupe any inclusions before generating the actual on-DOM CSS. See CSS Injection for a way to avoid this. -
Use one of many options to get the "munged" classnames for use in your code:
-
(fulcro-css.css/get-classnames Component)
returns a map of namespaced CSS classes keyed by the simple name you used in your garden rules. -
The 4th argument to
defsc
is that same map, on which you can apply destructuring to get the munged class names. -
Use
fulcro.client.localized-dom
for DOM rendering, and have it all automatically done for you!
-
-
Before Fulcro 2.6: Use the
fulcro-css.css/upsert-css
orfulcro-css.css/style-element
function (or your own style element) to embed the CSS. -
Fuclro 2.6+: Use the
fulcro-css.css-injection/upsert-css
orfulcro-css.css-injection/style-element
to embed the CSS. See CSS Injection.
A typical file will have the following layout:
(ns my-ui
(:require [fulcro.client.dom :as dom]
[fulcro-css.css :as css]
[fulcro.client.primitives :as prim :refer [defui defsc]]))
; the item binding is destructured as the fourth param. The actual CSS classname
; will be namespaced to the component as my_ui_ListItem__item, but will be available
; as the value :item in css-classnames map parameter, so you can easily
; destructure if and use it in the DOM without having to worry about how it is prefixed.
(defsc ListItem [this {:keys [label] :as props} computed {:keys [item] :as css-classes}]
{:css [[:.item {:font-weight "bold"}]]}
(dom/li {:className item} label))
(def ui-list-item (om/factory ListItem {:keyfn :id}))
(defsc ListComponent [this {:keys [id items]} computed {:keys [items-wrapper]}]
{:css [[:.items-wrapper {:background-color "blue"}]] ; this component's css
:css-include [ListItem]} ; components whose CSS should be included if this component is included (optional in 2.6+)
(dom/div {:className items-wrapper}
(dom/h2 (str "List " id))
(dom/ul (map ui-list-item items))))
(def ui-list (om/factory ListComponent {:keyfn :id}))
(defsc Root [this props computed {:keys [text]}]
{:css [[:.container {:background-color "red"}]]
:css-include [ListComponent]} ; NOTE: optional in Fulcro 2.6+
(let [the-list {:id 1 :items [{:id 1 :label "A"} {:id 2 :label "B"}]}]
(dom/div {:className text}
(ui-list the-list))))
; ...
; Add the CSS from Root as a HEAD style element. If it already exists, replace it. This
; will recursively follow all of the CSS includes *just* for components that Root includes!
(css/upsert-css "my-css" {:component Root}) ; NOTE: Use css-injection in Fulcro 2.6+
In the above example, the upsert results in this CSS on the page:
<style id="my-css">
.fulcro-css_cards-ui_Root__container {
background-color: red;
}
.text {
color: yellow;
}
.fulcro-css_cards-ui_ListComponent__items-wrapper {
background-color: blue;
}
.fulcro-css_cards-ui_ListItem__item {
font-weight: bold;
}
</style>
with a DOM for the UI of:
<div class="text">
<div class="fulcro-css_cards-ui_ListComponent__items-wrapper">
<h2>List 1</h2>
<ul>
<li class="fulcro-css_cards-ui_ListItem__item">A</li>
<li class="fulcro-css_cards-ui_ListItem__item">B</li>
</ul>
</div>
</div>
Garden’s selectors
are supported. These include the CSS combinators and the special &
selector. Using the
$
-prefix will also prevent the selectors from being localized.
:css [[(garden.selectors/> :.a :$b) {:color "blue"}]]
.namespace_Component__a > .b {
color: blue;
}
See Garden’s documentation for more details on more complex rules.
4.9.2. Upsert vs. Style Element
There are two ways of placing the CSS for a (group of) components on a page:
(upsert-css ID {:component Component})
will pull (recursively) the rules of Component, translate them to legal CSS, and then insert them
on the body of the page’s DOM at the given ID (overwriting the old style element with that same ID). If you ensure that
this upsert
happens on hot code reload, then your CSS will update as you edit your code.
Typically, you’ll run upsert-css
with your initial mount and in your hot code reload trigger. This means that the
computational overhead for the CSS is limited to initial startup.
(style-element {:component Component})
will pull (recursively) the rules for Component and return a React style element. This allows
you to embed CSS for a sub-tree of components in an "on-demand" fashion, since the element will only render of the component
that uses it renders. The problem with this method is that the computational overhead for computing the CSS is moved into
your primary rendering. This, however, is quite convenient in situations like devcards
where you’d like to easily ensure
that the CSS is there, but don’t want to have to worry about conflicting with existing style elements on the top-level page.
The live demo below upserts the co-located CSS from the code itself, but the embedded rules also base the color on the value in an atom (think theme color). At any time you can change your various embedded data on colocated CSS and re-upsert the generated result!
(ns book.demos.component-localized-css
(:require
[fulcro-css.css :as css]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.dom :as dom]
[fulcro.client.localized-dom :as ldom]))
(defonce theme-color (atom :blue))
(defsc Child [this {:keys [label]} _ {:keys [thing]}] ;; css destructuring on 4th argument, or use css/get-classnames
{; define local rules via garden. Defined names will be auto-localized
:css [[:.thing {:color @theme-color}]]}
(dom/div
(dom/h4 "Using css destructured value of CSS name")
(dom/div {:className thing} label)
(dom/h4 "Using automatically localized DOM in fulcro.client.localized-dom")
(ldom/div :.thing label)))
(def ui-child (prim/factory Child))
(declare change-color)
(defsc Root [this {:keys [ui/react-key]}]
{; Compose children with local reasoning. Dedupe is automatic if two UI paths cause re-inclusion.
:css-include [Child]}
(dom/div
(dom/button {:onClick (fn [e]
; change the atom, and re-upsert the CSS. Look at the elements in your dev console.
; Figwheel and Closure push SCRIPT tags too, so it may be hard to find on
; initial load. You might try clicking one of these
; to make it easier to find (the STYLE will pop to the bottom).
(change-color "blue"))} "Use Blue Theme")
(dom/button {:onClick (fn [e]
(change-color "red"))} "Use Red Theme")
(ui-child {:label "Hello World"})))
(defn change-color [c]
(reset! theme-color c)
(css/upsert-css "demo-css-id" Root))
; Push the real CSS to the DOM via a component. One or more of these could be done to, for example,
; include CSS from different modules or libraries into different style elements.
(css/upsert-css "demo-css-id" Root)
4.9.3. Localized CSS and Server-Side Rendering (Fulcro 2.5 and earlier)
Note
|
See CSS Injection for Fulcro 2.6+. |
The upsert-css
and style-element
functions are conveniences for CLJS DOM, but they do not work in CLJ.
Instead use (garden/css (fulcro-css.css/get-css Component))
to get a string version of the generated CSS and embed that
in the page. Version 2.4.1+ includes fulcro-css.css/raw-css
for this expression.
You can use the raw string in generated HTML, or use the server-compatible DOM functions:
(ns app.ui
(:require
[fulcro.client.dom :as dom]
[garden.core :as g]
[fulcro-css.css :as css]))
(dom/style {:dangerouslySetInnerHTML {:__html (g/css (css/get-css component))}})
4.10. Improved CSS Injection (Fulcro 2.6+)
Fulcro has had component-local CSS for quite some time. The ability to write your CSS inside of your components is a very powerful feature, but the injection support in prior versions had a couple of weaknesses:
-
You had to manually compose your CSS by including children in the parent.
-
It sorted components in breadth-first order.
-
The
style-element
method of injection had high overhead (computed new content on every refresh)
Fulcro 2.6 adds a new fulcro-css.css-injection
namespace that gives you
better control and performance:
(ns my-app
(:require [fulcro-css.css-injection :as injection]))
(defsc A [this props]
{:css [[ARules]]} ...)
(defsc MyRoot [this props]
{:css [[rules]]
; NO css includes
:query [{:a (prim/get-query A)}]}
(dom/div
(injection/style-element {:component this})
...))
The new style-element
has the following benefits:
-
By default it will automatically use the query to find all children (even through unions) that have CSS.
-
It defaults to a depth-first ordering in the output rules.
-
It caches the computed CSS, so it has much less rendering overhead than the old one.
You can get just the performance benefits (with exact behavior of the old one) by passing :auto-include? false
and
:order :breadth-first
in the options.
4.10.1. Upserts and SSR
The new injections namespace also has an improved upsert
(client-only DOM node upsert), and a compute-css
function the returns
the CSS as a string (useful for generating the upserted node in SSR). They take the exact same options map as the
style-element
.
4.11. CSS Localized DOM
If you choose to add localized CSS rules to your components, then you will probably also
want to use the DOM elements that support it natively. The fulcro.client.localized-dom
uses the same more compact notation of dom, but
it interprets the CSS keywords in the context of the component! This means that you can
use localized CSS without having to destructure the munged names at all.
Instead of writing:
(ns app.ui
(:require [fulcro.client.dom :as dom]))
(defsc Component [this props comp {:keys [a]}]
{:css [[:.a {:color :red}]]}
(dom/div {:className a} "Hi!))
you can instead write (note the different require for DOM):
(ns app.ui
(:require [fulcro.client.localized-dom :as dom]))
(defsc Component [this props]
{:css [[:.a {:color :red}]]}
(dom/div :.a "Hi!))
and avoid the argument destructuring, and JS properties map.
This localization of the keyword does require a small bit of runtime overhead, but inline macro expansion is used wherever possible to make this as performant as possible.
The features include:
-
A class keyword may precede the props.
:.c
will be localized to the class, whereas:$c
will be treated as a top-level (raw) class name. -
The keyword can contain any number of classes, and one ID:
:..c$c2.c3#id
-
Props are optional, but may contain
:className
or:classes
-
:className
is a literal string. Its contents will be combined with the:classes
and prefix keywords -
:classes
must be a vector of keywords (or expressions the result in keywords). Nil is allowed in the vector but is ignored.
Some examples:
(div :.a "Hi") ; <div class="namespace_Component__a">Hi</div>
(div :$a "Hi") ; <div class="a">Hi</div>
(div :$a$b "Hi") ; <div class="a b">Hi</div>
(div :$a.b "Hi") ; <div class="a namespace_Component__b">Hi</div>
(div {:classes [(when true :$hide) :.a]} "Hi") ; <div class="hide namespace_Component__a">Hi</div>
(div {:classes [(when false :$hide) :.a]} "Hi") ; <div class="namespace_Component__a">Hi</div>
(div :$c {:className "boo" :classes [:$a]} "Hi") ; <div class="c boo a">Hi</div>
The demo below shows most of these techniques combined with theme and rendering logic:
(ns book.demos.localized-dom
(:require
[fulcro-css.css :as css]
[fulcro.client.mutations :refer [defmutation]]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.localized-dom :as dom]))
(defonce theme-color (atom :blue))
(defsc Child [this {:keys [label invisible?]}]
{:css [[:.thing {:color @theme-color}]]
:query [:id :label :invisible?]
:initial-state {:id :param/id :invisible? false :label :param/label}
:ident [:child/by-id :id]}
(dom/div :.thing {:classes [(when invisible? :$hide)]} label))
(def ui-child (prim/factory Child))
(declare change-color)
(defmutation toggle-child [{:keys [id]}]
(action [{:keys [state]}]
(swap! state update-in [:child/by-id id :invisible?] not)))
(defsc Root [this {:keys [child]}]
{:css [[:$hide {:display :none}]] ; a global CSS rule ".hide"
:query [{:child (prim/get-query Child)}]
:initial-state {:child {:id 1 :label "Hello World"}}
:css-include [Child]}
(dom/div
(dom/button {:onClick (fn [e] (change-color "blue"))} "Use Blue Theme")
(dom/button {:onClick (fn [e] (change-color "red"))} "Use Red Theme")
(dom/button {:onClick (fn [e] (prim/transact! this `[(toggle-child {:id 1})]))} "Toggle visible")
(ui-child child)))
(defn change-color [c]
(reset! theme-color c)
(css/upsert-css "demo-css-id" Root))
; Push the real CSS to the DOM via a component. One or more of these could be done to, for example,
; include CSS from different modules or libraries into different style elements.
(css/upsert-css "demo-css-id" Root)
4.11.1. Localized DOM and Server-Side Rendering
There is a fulcro.client.localized-dom-server
namespace that provides the CLJ versions of the DOM functions. In
order to write UI in CLJC files you will need to make sure you use a conditional reader tag to include the
correct namespace for the correct langauge:
(ns app.ui
(:require #?(:clj [fulcro.client.localized-dom-server :as dom] :cljs [fulcro.client.localized-dom :as dom]))
... same as before
4.12. Complex Graphical UI Demo: An Image Clip Tool
Fulcro has the start of an image clip tool. Right now it is mainly for demonstration purposes, and is a good example of a complex UI component where two components have to talk to each other and share image data.
You should study the source code (src/main/fulcro/ui/clip_tool.cljs
) to get the full details,
but here is an overview of the critical facets:
-
ClipTool creates a canvas on which to draw
-
It uses initial state and a query to track the setup (e.g. size, aspect ratio, image to clip)
-
For speed (and because some data is not serializable), it uses component-local state to track current clip region, a javascript Image object and the DOM canvas (via React ref) for rendering, and the current active operation (e.g. dragging a handle).
-
The mouse events are essentially handled as updates to the component local state, which causes a local component render update.
-
-
PreviewClip is a React component, but not data-driven (no query). Everything is just passed through props.
-
It technically knows nothing of the clip tool.
-
It expects an :image-object and clip data to be passed in…it just renders it on a canvas.
-
It uses React refs to get the reference to the real DOM canvas for rendering
-
It renders whenever props change
-
-
A callback on the ClipTool communicates through the common parent’s component local state. The parent will re-render when its state changes, which will in turn force a new set of props to be passed to the preview).
As you can see, the interaction performance is quite good.
(ns book.ui.clip-tool-example
(:require [cljs.pprint :refer [cl-format]]
[fulcro.ui.clip-tool :as ct]
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc]]))
(def minion-image "https://s-media-cache-ak0.pinimg.com/736x/34/c2/f5/34c2f59284fcdff709217e14df2250a0--film-minions-minions-images.jpg")
(defsc Root [this {:keys [ctool]}]
{:initial-state
(fn [p] {:ctool (prim/get-initial-state ct/ClipTool {:id :clipper :aspect-ratio 0.5
:image-url minion-image})})
:query [:ctool]}
(dom/div
(ct/ui-clip-tool (prim/computed ctool {:onChange (fn [props] (prim/set-state! this props))}))
(ct/ui-preview-clip (merge (prim/get-state this) {:filename "minions.jpg"
:width 100 :height 200}))))
4.13. React Higher-Order Components
When using external libraries with Fulcro you’ll often run into the higher-order component pattern. React Higher Order Components (HOC) can be used with Fulcro but due to how Fulcro works internally interop/glue code is needed.
Higher Order Components is a pattern in React similar to the Decorator pattern in object-oriented programming: you wrap one component class with another component to decorate it with extra behaviour.
Some React reusable components provide HOC components to wrap cross cutting logic needed for the wrapped components to work.
For example google-maps-react provides GoogleApiWrapper
HOC that handles
dynamic loading and initialisation of Google Maps Javascript library so it doesn’t have to be handled manually in every place where
Google Maps React component (like Map
or Marker
) is used. In this particular example, GoogleApiWrapper
creates a wrapper
component class that behaves in the following way:
- it will display a placeholder "Loading" component and trigger Google Maps script loading and initialisation
- when the script loading and initialisation is complete it will replace "Loading" placeholder with the wrapped component
4.13.1. Fulcro and React HOC
So what’s the issue with using React HOC in Fulcro Interop: * Fulcro will embed React JS components (ones created by HOC) * React JS HOC will wrap Fulcro components
In the first case React JS components (like these from google-maps-react) expect props to be plain JavaScript objects, not ClojureScript maps. Fulcro components pass ClojureScript to nested components thus props need to be converted. This part is described in Fulcro Book’s chapter on Factory Functions for JS React Components. All we need to do is to have a factory function that will do the conversion. For example:
(ns hoc-example
(:require
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc]]
["google-maps-react" :refer [GoogleApiWrapper Map Marker]]))
(defn factory-apply [js-component-class]
(fn [props & children]
(apply js/React.createElement
js-component-class
(dom/convert-props props) ;; convert-props makes sure that props passed to React.createElement are plain JS object
children)))
(def ui-google-map (factory-apply Map)) ;; Fulcro wrapper factory for google-maps-react's Map component
(def ui-map-marker (factory-apply Marker)) ;; Another wrapper factory for Marker component
In the second scenario we will have React JS component passing plain JS object props to our Fulcro component and we need to add a layer that will do JS → Cljs props conversion.
Let’s create our sample Fulcro LocationView
component. It queries for view title, location’s lat and lng and google
which is the Google Maps API object required by google-maps-react components. It’s managed by google-maps-react HOC wrapper
and provided in props passed in our wrapped component.
(defsc LocationView [this {:keys [title lat lng google]}]
{:query [:lat :lng :google]}
(dom/div
(dom/h1 title)
(dom/div {:style {:width "250px" :height "250px"}}
(ui-google-map {:google google
:zoom 15
:initialCenter {:lat lat :lng lng}
:style {:width "90%" :height "90%"}}
(ui-map-marker {:position {:lat lat :lng lng}})))))
Now we need to create a factory for our LocationView
. It will need to sneak props as Cljs map so it’s available
for our wrapped LocationView
component:
(defn hoc-wrapper-factory-apply [component-class]
(fn [props & children]
(apply js/React.createElement
component-class
#js {:hocFulcroCljPropsPassthrough props}
children)))
We also need LocationView
factory that will get JS props received from HOC
and will recover our Cljs map props enhancing it also with google
object provided by HOC. We use "function as child" React pattern.
;; Plain Fulcro factory that will be used in our interop layer.
;; It won't be used directly in our client UI code.
(def ui-location-view-wrapped (prim/factory LocationView)
(defn ui-location-view-interop [js-props]
(let [fulcro-clj-props (.-hocFulcroCljPropsPassthrough js-props) ;; unwrapping Fulcro Cljs props wrapped by hoc-factory-apply
google (.-google js-props) ;; we need to extract google object passed by google-maps-react HOC
props (assoc fulcro-clj-props :google google)] ;; final version of cljs props that has a proper format for our LocationView component
(ui-location-view-wrapped props)))
Now we can finally create a factory for LocationView
component that will be used in our client UI code:
(def ui-location-view
;; GoogleApiWrapper is a function returning HOC with specified configuration parameters like API key
;; Notice that it's a plain JS function thus it requires its options to be plain JS map thus #js usage
(let [hoc (GoogleApiWrapper #js {:apiKey "AIzaSyDAiFHA9fwVjW83jUFjgL43D_KP9EFcIfE"})
WrappedLocationView (hoc ui-location-view-interop)]
(hoc-wrapper-factory-apply WrappedLocationView)))
Now we can use our LocationView
in our views:
(ui-location-view {:lat 37.778519 :lng -122.405640})
4.13.2. Reusable HOC factory
utils-hoc
namespace presented below provides a reusable hoc-factory
that can be used to handle all
the boilerplate code and interop gluing. It supports :extra-props-fn
in the opts argument that can be
used to customize the final props passed to the wrapped component. The code below shows its usage where google
value
from js-props
(injected by google-maps-react HOC wrapper) needs to be propagated under :google
entry in Cljs
props passed to the wrapped LocationView
component.
(ns utils.hoc
(:require
goog.object
[fulcro.client.primitives :as prim]))
(defn hoc-factory
"Creates a factory for HOC wrapped component class.
extra-props-fn allows for additional customization of props passed to the wrapped component. It will be provided
plain js-props and cljs-props unwrapped from js-props and must return cljs-props with modified contents if needed."
([hoc-wrapper-fn wrapped-component-class]
(hoc-factory hoc-wrapper-fn wrapped-component-class nil))
([hoc-wrapper-fn wrapped-component-class {:keys [extra-props-fn]}]
(let [cljs-props-key (name (gensym "fulcro-cljs-props"))
wrapped-component-factory (prim/factory wrapped-component-class)
js->clj-props-interop (fn js->clj-props-interop [js-props]
(let [clj-props (goog.object/get js-props cljs-props-key)
props (if extra-props-fn
(extra-props-fn js-props clj-props)
clj-props)]
(wrapped-component-factory props)))
hoc-wrapped-component-class (hoc-wrapper-fn js->clj-props-interop)]
(fn [props & children]
(apply js/React.createElement
hoc-wrapped-component-class
(js-obj cljs-props-key props)
children)))))
(comment
"Example usage"
(defsc LocationView [this {:keys [lat lon google]}]
{:query [:lat :lon :google]}
(dom/div {:style {:width "250px" :height "250px"}}
(ui-google-map {:zoom 15
:google google
:initialCenter {:lat lat :lng lon}
:style {:width "90%" :height "90%"}}
(ui-map-marker {:position {:lat lat :lng lon}}))))
(def google-maps-hoc (gmaps/google-api-wrapper #js {:apiKey "AIzaSyDAiFHA9fwVjW83jUFjgL43D_KP9EFcIfE"}))
(def ui-location-view
(hoc-factory
google-maps-hoc
LocationView
{:extra-props-fn (fn propagate-google-api [js-props cljs-props]
(assoc cljs-props :google (goog.object/get js-props "google")))}))
(ui-location-view {:lat 37.778519 :lng -122.405640}))
4.14. The defui
Macro (Fulcro 1.x. Legacy support in 2.x)
The defui
macro generates a React component. It does the same thing as the
defsc
macro, but looks more like a defrecord
and is a bit more OO in style. It
does not error-check your work, nor does it allow you to destructure incoming data
over the body or options; however, it is syntax-comptible with Om Next so if you’re
porting from that library it can be useful.
It is 100% compatible with the React ecosystem. The macro is intended
to look a bit like a class declaration, and borrows generation notation style from defrecord
. There is no
minimum required list of methods (e.g. you don’t even have to define render
). This latter fact is useful
for cases where you want a component for server queries and database normalization, but not for rendering.
4.14.1. React (Object) methods
defui
is aware of the following React-centric methods, which you can override:
(defui MyComponent
Object
(initLocalState [this] ...)
(shouldComponentUpdate [this next-props next-state] ...)
(componentWillReceiveProps [this next-props] ...)
(componentWillUpdate [this next-props next-state] ...)
(componentDidUpdate [this prev-props prev-state] ...)
(componentWillMount [this] ...)
(componentDidMount [this] ...)
(componentWillUnmount [this] ...)
(render [this] ...))
See React Lifecycle Examples for some specific examples, and the React documentation for a complete description of each of these.
Note
|
Fulcro does override shouldComponentUpdate to short-circuit renders of a component whose props have not changed. You
generally do not want to change this to make it render more frequently; however, when using Fulcro with
libraries like D3 that want to "own" the portion of the DOM they render you may need to make it so that
React never updates the component once mounted (by returning false always). The Developer’s Guide shows an example
of this in the UI section.
|
4.14.2. The static
Protocol Qualifier
defui
supports implementations of protocols in a static
context. It basically
means that you’d like the methods you’re defining to go on the class (instead of instance), but conform to the
given protocol. There is no Java analogue for this, but in Javascript the classes themselves are open.
Warning
|
Since there is no JVM equivalent of implementing static methods, a hack is used internally where the
protocol methods are placed in metadata on the resulting symbol. This is the reason functions like
get-initial-state exist. Calling the protocol (e.g. initial-state ) in Javascript will work, but if you
try that when doing server-side rendering on the JVM, it will blow up.
|
4.14.3. IQuery and Ident
There are two core protocols for supporting a component’s data in the graph database. They work in tandem to find data in the database for the component, and also to take data (e.g. from a server response or initial state) and normalize it into the database.
Both of these protocols must be declared static. The reason for this is initial normalization and query: The system has to be able to ask components about their ident and query generation in order to turn a tree of data into a normalized database.
Queries must be composed towards the root component (so you end up with a UI query that can pull the entire tree of data for the UI).
(defui ListItem
static prim/IQuery
(query [this] [:db/id :item/label])
static prim/Ident
(ident [this props] [:list-item/by-id (:db/id props)])
...)
(defui List
static prim/IQuery
(query [this] [:db/id {:list/items (prim/get-query ListItem)}])
static prim/Ident
(ident [this props] [:list/by-id (:db/id props)])
...)
;; queries compose up to root
Notes on the IQuery Protocol
Even though the method itself is declared statically, there are some interesting things about the query
method:
-
Once mounted, a component can have a dynamic query. This means calling
(prim/get-query this)
will return either the static query, or whatever has been set on that component via(prim/set-query! …)
. -
The
get-query
accessor method not only helps with server-side invocation, it annotates the query with metadata that includes the component info. This is what makes normalization work.
Some rules about the query itself:
-
A query must not be stolen from another component (even if it seems more DRY):
(defui PersonView1 static prim/IQuery (query [this] (prim/get-query PersonView2)) ;; WRONG!!!!
This is wrong because the query will end up annotated with
PersonView2’s metadata. Never use the return value of `get-query
as the return value for your ownquery
. -
The query will be structured with joins to follow the UI tree. In this manner the render and query follow form. If you query for some subcomponent’s data, then you should pass that data to that component’s factory function for rendering.
Notes on the Ident Protocol
The ident of a component is often needed in mutations, since you’re always manipulating the graph. To avoid typos, it is generally recommended that you write a function like this:
(defn person-ident [id-or-props]
(if (map? id-or-props)
[:person/by-id (:db/id id-or-props)]
[:person/by-id id-or-props]))
and use that in both your component’s ident implementation and all of your mutations:
(defui Person
static prim/Ident
(ident [this props] (person-ident props)))
...
(defmutation change-name [{:keys [id name]}]
(action [{:keys [state]}]
(let [name-path (conj (person-ident id) :person/name)]
(swap! state assoc-in name-path name))))
4.14.4. Om Next Compatibility
Fulcro’s defui
is identical in syntax to Om Next’s defui
. Porting
Om Next UI code to Fulcro is a simple matter of changing namespaces.
5. EQL — The Query and Mutation Language
Before reading this chapter you should make sure you’ve read The Graph Database Section. It details the low-level format of the application state, and talks about general details that are referenced in this chapter.
In Fulcro all data is queried and manipulated from the UI using the EDN Query Language (EQL). EQL is a subset of Datomic’s pull query syntax with extensions for unions and mutations. A query is a graph walk, so it is a relative notation: it must start at some specific spot, but that spot is not always named in the query itself. On the client side the starting point is usually the root node of your database. Thus, a complete query from the Root UI component will be a graph query that can start at the root node of the database.
However, you’ll note that any query fragment is implied to be relative to where we are in the walk of the graph
database. This is important to understand: no component’s query can just be grabbed and run against the database
as-is. Then again, if you know the ident
of a component, then you can start at that table entry in the database
and go from there.
The mutation portion of the language is a data representation of the abstract actions you’d like to take on the data model. It is intended to be network agnostic: The UI need not be aware that a given mutation does local-only modifications and/or remote operations against any number of remote servers. As such, the mutations, like queries, are simply data. Data that can be interpreted by local logic, or data that can be sent over the wire to be interpreted by a server.
Queries can either be a vector or a map of vectors. The former is a regular component query, and the latter is known as a union query. Union queries are useful when you’re walking a graph edge and the target could be one of many different kinds of nodes, so you’re not sure which query to use until you actually are walking the graph.
5.1. Properties
The simplest thing to query are properties "right here" in the relative node of the graph. Properties are queried by a simple keyword. Their values can be any scalar data value that is serializable in EDN.
The query
[:a :b]
is asking for the properties known as :a
and :b
at the "current node" in the graph traversal.
5.2. Joins
A join represents a traversal of an edge to another node in the graph. The notation is a map with a single key (the local key on the current node that holds the "pointer" to another node) whose single value is the query for the remainder of the graph walk:
[{:children (prim/get-query Child)}]
The query itself cannot specify that this is a to-one or to-many join. The data in the database graph itself determines the arity when the query is being run. Basically, if walking the join property leads to a vector of links, it is to-many. If it leads to a single link, then it is to-one. Rendering the data is going to have the same concern so the arity of the relation more strongly affects the rendering code.
Joins should always use get-query
to get the next component in the graph. This annotates (with metadata) the sub-query
so that normalization can work correctly.
5.3. Unions
Unions represent a map of queries, only one of which applies at a given graph edge. This is a form of dynamic query that adjusts based on the actual linkage of data. Unions cannot stand alone. They are meant to select one of many possible alternate queries when a link (to-one or to-many join) in the graph is reached. Unions are always used in tandem with a join, and can therefore not be used on root-level components. The union query itself is a map of the possible queries:
(defsc PersonPlaceOrThingUnion [this props]
; lambda form required for unions (a limitation of the error checking routines in defsc)
{:query (fn [] {:person/by-id (prim/get-query Person)
:place/by-id (prim/get-query Place)
:thing/by-id (prim/get-query Thing)})}
...)
and such a query must be joined by a parent component. Therefore, you’ll always end up with something like this:
(defsc Parent [this props]
{:query [{:person-place-or-thing (prim/get-query PersonPlaceOrThingUnion)}]})
Union queries take a little getting used to because there are a number of rules to follow when using them in order for everything to work correctly (normalization, queries, and rendering).
Here is what a graph database might look like for the above query assuming we started at Parent
:
{ :person-place-or-thing [:place/by-id 3]
:place/by-id { 3 { :id 3 :location "New York" }}}
The query would start at the root. When it saw the join it would detect a union. The union would be resolved
by looking at the first element of the ident in the database (in this case :place
from [:place 3]
). That keyword
would then be used to select the query from the subcomponent union (in this example, (prim/get-query Place)
).
Processing of the query then continues as normal as if the join was just on Place
.
A to-many linkage works just as well:
{ :peron-place-or-thing [[:person/by-id 1] [:place/by-id 3]]
:person/by-id { 1 { :id 1 :name "Julie" }}
:place/by-id { 3 { :id 3 :location "New York" }}}
and now you have a mixed to-many relationship where the correct sub-query will be used for each item in turn.
Normalization of unions requires that the union component itself have an ident function that can properly generate idents for all of the possible kinds of things that could be found. Often this means that you’ll need to encode some kind of type indicator in the data itself.
Say you had this incoming tree of data:
{:person-place-or-thing [ {:id 1 :name "Joe"} {:id 3 :location "New York"} ]}
In order to normalize this correctly we need to end up with the correct person
and place
idents. The resulting
ident function might look like this:
(defsc PersonPlaceOrThingUnion [this props]
{:ident (fn []
(cond
(contains? props :name) [:person/by-id (:id props)]
(contains? props :location) [:place/by-id (:id props)]
:else [:thing/by-id (:id props)]))}
...)
Often it is easier to just include a :type
field so that ident
can look up both the type and id.
Rendering the correct thing in the UI of the union component has the same concern: you must detect what
kind of data (among the options) that you actually receive, and pass that on to the correct child factory (e.g.
ui-person
, ui-place
, or ui-thing
. This is most commmonly done with a simple case
statement.
5.3.1. Demo – Using Unions as a UI Type Selector
Important
|
There is an older defrouter that is supported but no longer recommended. It has been superceeded by the
more functional macro called defsc-router in the same namespace in Fulcro 2.6.18+.
|
The fulcro.client.routing/defsc-router
macro (2.6.18+) emits a union component that can be switched to point at any kind
of component that it knows about. The support for parameterized routers in the routing tree makes it possible
to very easily reuse the UI router as a component that can show one of many screens in the same location.
This is particularly useful when you have a list of items that have varying types, and you’d like to, for example, show the list on one side of the screen and the detail on the other.
To write such a thing one would follow these steps:
-
Create one component for each item type that represents how it will look in the list.
-
Create one component for each item type that represents the fine detail view for that item.
-
Join (1) together into a union component and use it in a component that shows them as a list. In other words the union will represent a to-many edge in your graph. Remember that unions cannot stand alone, so there will be a union component (to switch the UI) and a list component to iterate through the items.
-
Combine the detail components from (2) into a
defsc-router
(e.g. with ID :detail-router). -
Create a routing tree that includes the :detail-router, and parameterize both elements of the target ident (kind and id)
-
Hook a click event from the items to a
route-to
mutation, and send route parameters for the kind and id.
The output of the macro:
(defsc-router ItemDetail [this props]
{:router-id :detail-router
:ident (fn [] (item-ident props))
:default-route PersonDetail
:router-targets {:person/by-id PersonDetail
:place/by-id PlaceDetail
:thing/by-id ThingDetail}}
(dom/div "Route not set"))
is roughly this (cleaned up from macro output):
(defsc ItemDetail-Union [this props]
{:initial-state [clz params] (prim/get-initial-state PersonDetail params)) ;; defaults to the first one listed
:ident [this props] (item-ident props))
:query (fn [] {:person/by-id (prim/get-query PersonDetail),
:place/by-id (prim/get-query PlaceDetail),
:thing/by-id (prim/get-query ThingDetail)}}
(let [page (first (prim/get-ident this))]
(case page
:person/by-id ((prim/factory PersonDetail) (prim/props this))
:place/by-id ((prim/factory PlaceDetail) (prim/props this))
:thing/by-id ((prim/factory ThingDetail) (prim/props this))
(dom/div (str "Cannot route: Unknown Screen " page))))
(defsc ItemDetail [this props]
{:initial-state (fn [params] {:fulcro.client.routing/id :detail-router
:fulcro.client.routing/current-route (prim/get-initial-state ItemDetail-Union params)})
:ident (fn [] [:fulcro.client.routing.routers/by-id :detail-router])
:query [this] [:fulcro.client.routing/id {:fulcro.client.routing/current-route (prim/get-query ItemDetail-Union)}]}
(let [computed (prim/get-computed this)
props (:fulcro.client.routing/current-route (prim/props this))
props-with-computed (prim/computed props computed)]
((prim/factory ItemDetail-Union) props-with-computed)))))
You can see that this just defines a union whose "selection" is controlled by the :current-route
property!
Here is a complete working example that uses this to make a UI around displaying things of various types:
(ns book.queries.union-example-1
(:require [fulcro.client.dom :as dom]
[fulcro.client.routing :as r :refer [defsc-router]]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client :as fc]
[fulcro.ui.bootstrap3 :as b]
[fulcro.ui.elements :as ele]
[fulcro.client.cards :refer [defcard-fulcro]]))
(defn item-ident
"Generate an ident from a person, place, or thing."
[props] [(:kind props) (:db/id props)])
(defn item-key
"Generate a distinct react key for a person, place, or thing"
[props] (str (:kind props) "-" (:db/id props)))
(defn make-person [id n] {:db/id id :kind :person/by-id :person/name n})
(defn make-place [id n] {:db/id id :kind :place/by-id :place/name n})
(defn make-thing [id n] {:db/id id :kind :thing/by-id :thing/label n})
(defsc PersonDetail [this {:keys [db/id person/name] :as props}]
; defsc-router expects there to be an initial state for each possible target. We'll cause this to be a "no selection"
; state so that the detail screen that starts out will show "Nothing selected". We initialize all three in case
; we later re-order them in the defsc-router.
{:ident (fn [] (item-ident props))
:query [:kind :db/id :person/name]
:initial-state {:db/id :no-selection :kind :person/by-id}}
(dom/div
(if (= id :no-selection)
"Nothing selected"
(str "Details about person " name))))
(defsc PlaceDetail [this {:keys [db/id place/name] :as props}]
{:ident (fn [] (item-ident props))
:query [:kind :db/id :place/name]
:initial-state {:db/id :no-selection :kind :place/by-id}}
(dom/div
(if (= id :no-selection)
"Nothing selected"
(str "Details about place " name))))
(defsc ThingDetail [this {:keys [db/id thing/label] :as props}]
{:ident (fn [] (item-ident props))
:query [:kind :db/id :thing/label]
:initial-state {:db/id :no-selection :kind :thing/by-id}}
(dom/div
(if (= id :no-selection)
"Nothing selected"
(str "Details about thing " label))))
(defsc PersonListItem [this
{:keys [db/id person/name] :as props}
{:keys [onSelect] :as computed}]
{:ident (fn [] (item-ident props))
:query [:kind :db/id :person/name]}
(dom/li {:onClick #(onSelect (item-ident props))}
(dom/a {:href "javascript:void(0)"} (str "Person " id " " name))))
(def ui-person (prim/factory PersonListItem {:keyfn item-key}))
(defsc PlaceListItem [this {:keys [db/id place/name] :as props} {:keys [onSelect] :as computed}]
{:ident (fn [] (item-ident props))
:query [:kind :db/id :place/name]}
(dom/li {:onClick #(onSelect (item-ident props))}
(dom/a {:href "javascript:void(0)"} (str "Place " id " : " name))))
(def ui-place (prim/factory PlaceListItem {:keyfn item-key}))
(defsc ThingListItem [this {:keys [db/id thing/label] :as props} {:keys [onSelect] :as computed}]
{:ident (fn [] (item-ident props))
:query [:kind :db/id :thing/label]}
(dom/li {:onClick #(onSelect (item-ident props))}
(dom/a {:href "javascript:void(0)"} (str "Thing " id " : " label))))
(def ui-thing (prim/factory ThingListItem item-key))
(defsc-router ItemDetail [this props]
{:router-id :detail-router
:ident (fn [] (item-ident props))
:default-route PersonDetail
:router-targets {:person/by-id PersonDetail
:place/by-id PlaceDetail
:thing/by-id ThingDetail}}
(dom/div "No route"))
(def ui-item-detail (prim/factory ItemDetail))
(defsc ItemUnion [this {:keys [kind] :as props}]
{:ident (fn [] (item-ident props))
:query (fn [] {:person/by-id (prim/get-query PersonListItem)
:place/by-id (prim/get-query PlaceListItem)
:thing/by-id (prim/get-query ThingListItem)})}
(case kind
:person/by-id (ui-person props)
:place/by-id (ui-place props)
:thing/by-id (ui-thing props)))
(def ui-item-union (prim/factory ItemUnion {:keyfn item-key}))
(defsc ItemList [this {:keys [items]} {:keys [onSelect]}]
{
:initial-state (fn [p]
; These would normally be loaded...but for demo purposes we just hand code a few
{:items [(make-person 1 "Tony")
(make-thing 2 "Toaster")
(make-place 3 "New York")
(make-person 4 "Sally")
(make-thing 5 "Pillow")
(make-place 6 "Canada")]})
:ident (fn [] [:lists/by-id :singleton])
:query [{:items (prim/get-query ItemUnion)}]}
(dom/ul
(map (fn [i] (ui-item-union (prim/computed i {:onSelect onSelect}))) items)))
(def ui-item-list (prim/factory ItemList))
(defsc Root [this {:keys [item-list item-detail]}]
{:query [{:item-list (prim/get-query ItemList)}
{:item-detail (prim/get-query ItemDetail)}]
:initial-state (fn [p] (merge
(r/routing-tree
(r/make-route :detail [(r/router-instruction :detail-router [:param/kind :param/id])]))
{:item-list (prim/get-initial-state ItemList nil)
:item-detail (prim/get-initial-state ItemDetail nil)}))}
(let [; This is the only thing to do: Route the to the detail screen with the given route params!
showDetail (fn [[kind id]]
(prim/transact! this `[(r/route-to {:handler :detail :route-params {:kind ~kind :id ~id}})]))]
; devcards, embed in iframe so we can use bootstrap css easily
(ele/ui-iframe {:frameBorder 0 :height "300px" :width "100%"}
(dom/div {:key "example-frame-key"}
(dom/style ".boxed {border: 1px solid black}")
(dom/link {:rel "stylesheet" :href "bootstrap-3.3.7/css/bootstrap.min.css"})
(b/container-fluid {}
(b/row {}
(b/col {:xs 6} "Items")
(b/col {:xs 6} "Detail"))
(b/row {}
(b/col {:xs 6} (ui-item-list (prim/computed item-list {:onSelect showDetail})))
(b/col {:xs 6} (ui-item-detail item-detail))))))))
5.4. Mutations
Mutations are also just data, as we mentioned earlier. However, they are intended to look like single- argument function calls where the single argument is a map of parameters:
[(do-something)]
The main concern is that this expression, in normal Clojure, will be evaluated because it contains a raw list.
In order to keep it data, one must quote expressions with mutations. Of course you may use syntax quoting
or literal quoting. Usually we recommend namespacing your mutations (with defmutation
) and then using
syntax quoting to get reasonably short expressions:
(ns app.mutations)
(defmutation do-something [params] ...)
(ns app.ui
(:require [app.mutations :as am]))
...
(prim/transact! this `[(am/do-something {})])
We talked about syntax quoting transactions in the Getting Started chapter. You may want to review that or just read more online about Clojure syntax quoting.
The parameter map on mutations is optional, but recommended, if you’re using IDE support since the IDE will always see mutations as if they were function calls with an arity of one.
5.5. Parameters
Most of the query elements also support a parameter map. In Fulcro these are mainly useful when sending a query to the server, and it is rare you will write such a query "by hand". However, for completeness you should know what these look like. Basically, you just surround the property or join with parentheses, and add a map as parameters. This is just like mutations, except instead of a symbol as the first element of the list it is either a keyword (prop) or a map (join).
Thus a property can be parameterized:
[(:prop {:x 1})]
This would cause, for example, a server’s query processing to see {:x 1}
in the params
when handling the read
for :prop
.
A join is similarly parameterized:
[({:child (prim/get-query Child)} {:x 1})]
with the same kind of effect.
Note
|
The plain list has the same requirement as for mutations: quoting. Generally syntax quoting is again the best choice, since you’ll often need unquoting. For example, the join example above would actually be written in code as: |
...
(query [this] `[({:child ~(prim/get-query Child)} {:x 1})])
...
to avoid trying to use the map as a function for execution, yet allowing the nested get-query
to run and embed
the proper subquery.
5.6. Queries on Idents
Idents are valid in queries as a plain prop or a join. When used alone (not in a join) this will pull a table entry from the database without normalizing it or following any subquery:
[ [:person/by-id 1] ]
results in something like this:
(defsc Phone [this props]
{:query [:id :phone/number]
:ident [:phone/by-id :id]}
...)
(defsc Person [this props]
{:query [:id {:person/phone (prim/get-query Phone)}]
:ident [:person/by-id :id]}
...)
(defsc X [this props]
{:query [ [:person/by-id 1] ] } ; query just for the ident
(let [person (get props [:person/by-id 1]) ; NOT get-in. The key of the result is an ident
...
; person contains {:id 1 :person/phone [:phone/by-id 4]}
This is not typically what you want because you’d typically want it to follow the graph links (resolving phone number).
Therefore these kinds of queries are normally done via a join:
(defsc X [this props]
{:query [{[:person/by-id 1] (prim/get-query Person)}]}
(let [person (get props [:person/by-id 1])
...
; person contains {:id 1 :person/phone {:phone/id 4 :phone/number "555-1212"}}
This has the effect of "re-rooting" the graph walk at that ident’s table entry and continuing from there for the rest of the subtree. In fact this is how Fulcro’s ident-based rendering optimization works.
5.7. Link Queries
There are times when you want to start "back at the root" node. This is useful for pulling data that has a singleton representation in the root node itself. For example, the current UI locale or currently logged-in user. There is a special notation for this the looks like an ident without an ID:
[ [:ui/locale '_] ]
This component query would result in :ui/locale
in your props (not an ident) with a value that came from the
overall root node of the database. Of course, denormalization just requires you use a join:
[ {[:current-user '_] (prim/get-query Person)} ]
would pull :current-user
into the component’s props with a continued walk of the graph. In other words this is
just like the ident join, except the special symbol _
indicates there is only one of them and it is in the root
node.
5.8. A Warning About Ident and Link Queries
Components can query for whatever they want, and sometimes it is useful to write components that only query for things "elsewhere" in the database:
(defsc LocaleSwitcher [this {:keys [ui/locale]}]
{:query [[:ui/locale '_]]}
(dom/div ...))
When the database is constructed for such components they will have no state of their own.
Sadly, even if you compose it into your UI properly it may not receive any data:
(defsc Root [this {:keys [locale-switcher]}]
{:query [{:locale-switcher (prim/get-query LocaleSwitcher)}]}
(ui-locale-switcher locale-switcher)) ; data comes back nil!!!
The problem is that the query engine walks the database and query in tandem. When it sees a join
(:locale-switcher
in this case) it goes looking for an entry in the database at the current location
(root node in this case) to process the subquery against. If it finds an ident it follows it and processes
the subquery. If it is a map it uses that to fulfill the subquery. If it is a vector then it processes the
subquery against every entry. But if it is missing then it stops.
The fix is simple: make sure the component has a presence in the database, even if empty:
(defsc LocaleSwitcher [this {:keys [ui/locale]}]
{:query [[:ui/locale '_]]
:initial-state {}} ; empty map
(dom/div ...))
(defsc Root [this {:keys [locale-switcher]}]
{:query [{:locale-switcher (prim/get-query LocaleSwitcher)}]
:initial-state (fn [params] {:locale-switcher (prim/get-initial-state LocaleSwitcher)})} ; empty map from child
(ui-locale-switcher locale-switcher)) ; link query data is found/passed
5.8.1. Using Shared State
An alternative to ident and link queries is shared state. The first thing to note is that it is not a complete replacement for link queries. It is a low-level feature that is meant for two basic scenarios:
-
Data that needs to be visible to all components, but never changes once the app is mounted.
-
Data that is derived from the UI props (from root) or globals, but only updates on root-level renders (not component-local updates).
The first use-case might be handy if you pass some data to your mount through the HTML page itself. The latter is useful for data that affects everything in your application, such as the current user.
The primary thing to remember is that components that look at shared state will not see updates unless a
root render occurs with those updates. This typically means calling prim/force-root-render!
.
Say we wanted all components to be able to see :pi
(a constant) and :current-user
(a value from
the database). We could declare this as follows:
(fc/make-fulcro-client
{:reconciler-options {:shared {:pi 3.14} ; never changes
:shared-fn #(select-keys % :current-user)}}) ; updates on root render
Now any component can access these as follows:
(defsc C [this props]
(let [{:keys [pi current-user]} (prim/shared this)]
; current-user will be DEnormalized...it comes from the root props (Root must query for it still)
...))
Warning
|
Remember that this is not equivalent to a link query for [:current-user '_] . There are two differences.
The first is that pulling :current-user still requires that your root component query for it (or it
won’t even be in the props). Second, the shared value will not visibly change until a root render happens, where
link queries can refresh locally with a component. The final difference is that if you use data in
your shared-fn that is derived from anything other than the state database then it will not work correctly
in the history support viewer.
|
5.9. Recursive Queries
EQL includes support for recursive queries. Recursion is always expressed on a join,
and it always means that the recursive item has the same type as the component you’re on.
There are two notations for this: …
and a number. The former means "recurse until there are no more links
(circular detection is included to prevent infinite loops)", and the other is the recursion limit:
Note
|
At the time of this writing you must use the lamba mode of defsc for queries that include recursion.
|
The following demo (with source) demonstrates the core basics of recursion:
(ns book.queries.recursive-demo-1
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]))
(defn make-person
"Make a person data map with optional children."
[id name children]
(cond-> {:db/id id :person/name name}
children (assoc :person/children children)))
(declare ui-person)
; The ... in the query means there will be children of the same type, of arbitrary depth
; it is equivalent to (prim/get-query Person), but calling get query on yourself would
; lead to infinite compiler recursion.
(defsc Person [this {:keys [:person/name :person/children]}]
{:query (fn [] [:db/id :person/name {:person/children '...}])
:initial-state (fn [p]
(make-person 1 "Joe"
[(make-person 2 "Suzy" [])
(make-person 3 "Billy" [])
(make-person 4 "Rae"
[(make-person 5 "Ian"
[(make-person 6 "Zoe" [])])])]))
:ident [:person/by-id :db/id]}
(dom/div
(dom/h4 name)
(when (seq children)
(dom/div
(dom/ul
(map (fn [p]
(ui-person p))
children))))))
(def ui-person (prim/factory Person {:keyfn :db/id}))
(defsc Root [this {:keys [person-of-interest]}]
{:initial-state {:person-of-interest {}}
:query [{:person-of-interest (prim/get-query Person)}]}
(dom/div
(ui-person person-of-interest)))
5.9.1. Circular Recursion
It is perfectly legal to include recursion in your graph, and it is equally fine to query for it. The query engine will automatically stop if a loop is detected.
However, this is not the whole story. You see, components can be updated in a relative fasion when all optimizations are enabled. This means that a refresh could happen anywhere in the (recursive) UI, and the query would run until it detects the loop again. This can lead to funny-looking results.
The demo below lets you modify people and their spouse (a circular relation). Try it out and you’ll see that something isn’t quite right (try making Sally older):
(ns book.queries.recursive-demo-2
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :refer [defmutation]]
[fulcro.client.dom :as dom]))
(declare ui-person)
(defmutation make-older [{:keys [id]}]
(action [{:keys [state]}]
(swap! state update-in [:person/by-id id :person/age] inc)))
(defsc Person [this {:keys [db/id person/name person/spouse person/age]}]
{:query (fn [] [:db/id :person/name :person/age {:person/spouse 1}]) ; force limit the depth
:initial-state (fn [p]
; this does look screwy...you can nest the same map in the recursive position,
; and it'll just merge into the one that was previously normalized during normalization.
; You need to do this or you won't get the loop in the database.
{:db/id 1
:person/name "Joe"
:person/age 20
:person/spouse {:db/id 2
:person/name "Sally"
:person/age 22
:person/spouse {:db/id 1 :person/name "Joe"}}})
:ident [:person/by-id :db/id]}
(dom/div
(dom/div "Name:" name)
(dom/div "Age:" age
(dom/button {:onClick
#(prim/transact! this `[(make-older {:id ~id})])} "Make Older"))
(when spouse
(dom/ul
(dom/div "Spouse:" (ui-person spouse))))))
(def ui-person (prim/factory Person {:keyfn :db/id}))
(defsc Root [this {:keys [person-of-interest]}]
{:initial-state {:person-of-interest {}}
:query [{:person-of-interest (prim/get-query Person)}]}
(dom/div
(ui-person person-of-interest)))
The problem is that when you touch Sally the UI refresh updates just that component; however, that component has a recursive query of depth 1, so it ends up returning Joe as her spouse! This is technically correct, but almost certainly isn’t what you want!
The fix is equally simple: calculate depth and pass it to the child. Use the calculated depth to prevent the extra rendering when local refresh gives you data you don’t need. The demo below has this fix.
(ns book.queries.recursive-demo-3
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :refer [defmutation]]
[fulcro.client.dom :as dom]))
(declare ui-person)
(defmutation make-older [{:keys [id]}]
(action [{:keys [state]}]
(swap! state update-in [:person/by-id id :person/age] inc)))
; We use computed to track the depth. Targeted refreshes will retain the computed they got on
; the most recent render. This allows us to detect how deep we are.
(defsc Person [this
{:keys [db/id person/name person/spouse person/age]} ; props
{:keys [render-depth] :or {render-depth 0}}] ; computed
{:query (fn [] [:db/id :person/name :person/age {:person/spouse 1}]) ; force limit the depth
:initial-state (fn [p]
{:db/id 1 :person/name "Joe" :person/age 20
:person/spouse {:db/id 2 :person/name "Sally"
:person/age 22
:person/spouse {:db/id 1 :person/name "Joe"}}})
:ident [:person/by-id :db/id]}
(dom/div
(dom/div "Name:" name)
(dom/div "Age:" age
(dom/button {:onClick
#(prim/transact! this `[(make-older {:id ~id})])} "Make Older"))
(when (and (= 0 render-depth) spouse)
(dom/ul
(dom/div "Spouse:"
; recursively render, but increase the render depth so we can know when a
; targeted UI refresh would accidentally push the UI deeper.
(ui-person (prim/computed spouse {:render-depth (inc render-depth)})))))))
(def ui-person (prim/factory Person {:keyfn :db/id}))
(defsc Root [this {:keys [person-of-interest]}]
{:initial-state {:person-of-interest {}}
:query [{:person-of-interest (prim/get-query Person)}]}
(dom/div
(ui-person person-of-interest)))
5.9.2. Duplicates and Recursion
Of course it is perfectly fine for there to be multiple edges in your graph that point
to the same node. Below is a recursive bullet list example. We’ve intentionally nested
item B.1
under B and D so you can see that it all works itself out.
Normalization of initial state (which must be a tree) is perfectly happy to see duplcate entries. It simply merges the multiple copies into the same normalized entry in the table.
Since the two entries merge to the same entry, it also means the modifications will
be shared among them. Try checking item B.1
in either location.
(ns book.queries.recursive-demo-bullets
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.dom :as dom]
[clojure.string :as str]))
(declare ui-item)
(defsc Item [this {:keys [ui/checked? item/label item/subitems]}]
{:query (fn [] [:ui/checked? :db/id :item/label {:item/subitems '...}])
:ident [:item/by-id :db/id]}
(dom/li
(dom/input {:type "checkbox"
:checked (if (boolean? checked?) checked? false)
:onChange #(m/toggle! this :ui/checked?)})
label
(when subitems
(dom/ul
(map ui-item subitems)))))
(def ui-item (prim/factory Item {:keyfn :db/id}))
(defsc ItemList [this {:keys [db/id list/items] :as props}]
{:query [:db/id {:list/items (prim/get-query Item)}]
:ident [:list/by-id :db/id]}
(dom/ul
(map ui-item items)))
(def ui-item-list (prim/factory ItemList {:keyfn :db/id}))
(defsc Root [this {:keys [list]}]
{:initial-state (fn [p]
{:list {:db/id 1
:list/items [{:db/id 2 :item/label "A"
:item/subitems
[{:db/id 7
:item/label "A.1"
:item/subitems
[{:db/id 8
:item/label "A.1.1"
:item/subitems []}]}]}
{:db/id 3
:item/label "B"
:item/subitems
[{:db/id 6 :item/label "B.1"}]}
{:db/id 4
:item/label "C"
:item/subitems []}
{:db/id 5
:item/label "D"
; just for fun..nest a dupe under D
:item/subitems [{:db/id 6 :item/label "B.1"}]}]}})
:query [{:list (prim/get-query ItemList)}]}
(dom/div
(ui-item-list list)))
5.10. The AST
You can convert any expression of EQL into an AST (abstract syntax tree) and vice
versa. This lends itself to doing complex parsing of the query (typically on the server). The functions
of interest are fulcro.client.primitives/query→ast
and ast→query
.
There are many uses for this. One such use might be to convert the graph expression into another form. For
example, say you wanted to run a query against an SQL database. You could write an algorithm that translates
the AST into a series of SQL queries to build the desired result. The AST is always available as one
of the parameters in the mutation/query env
on the client and server.
Another use for the AST is in mutations targeted at a remote: it turns out you can morph a mutation before sending it to the server.
5.10.1. Morphing Mutations
The most common use of the AST is probably adding parameters that the UI is unaware need to be sent to
a remote. When processing a mutation with defmutation
(or just the raw defmethod) you will receive
the AST of the mutation in the env
. It is legal to return any valid AST from the remote side of a
mutation. This has the effect of changing what will be sent to the server:
(defmutation do-thing [params]
(action [env] ...)
(remote [{:keys [ast]}] ast)) ; same effect as `true`
(defmutation do-thing [params]
(action [env] ...)
(remote [{:keys [ast]}] (prim/query->ast `[(do-other-thing)])) ; completely change what gets sent to `remote`
(defmutation do-thing [params]
(action [env] ...)
(remote [{:keys [ast]}] (assoc ast :params {:y 3}))) ; change the parameters
For more on mutations see the chapter on Handling Mutations
6. Initial Application State
When starting any application one thing has to be done before just about anything else: Establish a starting state. In Fulcro this just means generating a client-side application database (normalized). Other parts of this guide have talked about the Graph Database. You can well imagine that hand-coding one of these for a large application’s starting state could be kind of a pain. Actually, even though coding it would be a pain, it turns out that the bigger pain happens later when you want to refactor! That can become a real mess!
However, Fulcro already knows how to normalize a tree of data, and your UI is already the tree you’re interested in. So, Fulcro encourages you to co-locate initial application state with the components that need the state and compose it towards the root, just like you do for queries. This gives some nice results:
-
Your initial application state is reasoned about locally to each component, just like the queries.
-
Refactoring the UI just means modifying the local composition of queries and initial state from one place to another in the UI.
-
Fulcro understands unions (you can only initialize one branch of a to-one relation), and can scan for and initialize alternate branches.
6.1. Adding Initial State to Components
To add initial state, follow these steps:
-
For each component that should appear initially: add the
:initial-state
option. -
Compose the components in (1) all the way to your root.
That’s it! Fulcro will automatically detect initial state on the root, and use it for the application!
Note
|
Pulling the initial state from a component should be done with fulcro.core/get-initial-state . Calling a static
protocol cannot work on the server, so this helper method makes server-side rendering possible for your components.
|
(defui Child
static fulcro.core/InitialAppState
(initial-state [cls params] { :x 1 }) ; set :x to 1 for this component's state
static prim/IQuery
(query [this] [:x]) ; query for :x
static prim/Ident
(ident [this props] ...) ; how to normalize
Object
(render [this]
(let [{:keys [x]} (prim/props this)] ; pull x from props
...)))
(defui Parent
static fulcro.core/InitialAppState
(initial-state [cls params] { :y 2 :child (prim/get-initial-state Child {}) }) ; set y, and compose in child's state
static prim/IQuery
(query [this] [:y {:child (prim/get-query Child)}]) ; query for :y and compose child's query
static prim/Ident
(ident [this props] ...) ; how to normalize
Object
(render [this]
(let [{:keys [y child]} (prim/props this)] ; pull y and child from props
...)))
...
Notice the nice symmetry here. The initial state is (usually) a map that represents (recursively) the entity and
its children. The query is a vector that lists the "scalar" props, and joins as maps. So, in Child
we have
initial state for :x
and a query for :x
. In the parent we have a query for the property :y
and a join to
the child, and initial state for the scalar value of :y
and the composed initial state of the Child
. Render has
the same thing: the things you pull out of props will be the things for which you queried. Thus, all three essentially
list the same things, but in slightly different forms.
6.1.1. Initial State and Alternate Branches of Unions
The one "extra" feature that initial state support does for you is to initialize alternate branches of components that have to-one union query. Remember that a to-one relation from a union could be to any number of alternates.
Take this union query: {:person (prim/get-query Person) :place (prim/get-query Place)}
It means "if you find an ident in the graph pointing to a :person
, then query for the person. If you find one
for :place
, then query for a place. The problem is: if it is a to-one relation then only one can be in the
initial state tree at startup!
{ :person-or-place [:person 2]
:person {2 {:id 2 ...}}}
If you look at a proposed initial state, it will make the problem more clear:
(defui Person
static prim/InitialAppState
(initial-state [c {:keys [id name]}] {:id id :name name :type :person})
...)
(defui PersonPlaceUnion
static prim/InitialAppState
(initial-state [c p] (prim/get-initial-state Person {:id 1 :name "Joe"})) ; I can only put in one of them!
static prim/IQuery
(query [this] {:person (prim/get-query Person) :place (prim/get-query Place)})
...)
(defui Parent
static prim/InitialAppState
(initial-state [c p] {:person-or-place (prim/get-initial-state PersonPlaceUnion)})
static prim/IQuery
(query [this] [{:person-or-place (prim/get-query PersonPlaceUnion)}]))
This would result in a person in the initial state, but not a place.
Fulcro solves this at startup in the following manner: It pulls the query from root and walks it. If it finds
a union component, then for each branch it sees if that component (via the query metadata) has initial state. If
it does, it places it in the correct table in app state. This does not, of course, join it to anything in the graph
since it isn’t the "default branch" that was explicitly listed (in PersonPlaceUnion’s `InitialAppState
).
This behavior is critical when using unions to handle UI routing, which is in turn essential for good application performance.
6.1.2. Initial State Demo
The following demo shows all of this in action.
(ns book.demos.initial-app-state
(:require
[fulcro.client.dom :as dom]
[fulcro.client :as fc]
[fulcro.client.mutations :as m]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]))
(defonce app (atom (fc/make-fulcro-client {})))
(defmethod m/mutate 'nav/settings [{:keys [state]} sym params]
{:action (fn [] (swap! state assoc :panes [:settings :singleton]))})
(defmethod m/mutate 'nav/main [{:keys [state]} sym params]
{:action (fn [] (swap! state assoc :panes [:main :singleton]))})
(defsc ItemLabel [this {:keys [value]}]
{:initial-state (fn [{:keys [value]}] {:value value})
:query [:value]
:ident (fn [] [:labels/by-value value])}
(dom/p value))
(def ui-label (prim/factory ItemLabel {:keyfn :value}))
;; Foo and Bar are elements of a mutli-type to-many union relation (each leaf can be a Foo or a Bar). We use params to
;; allow initial state to put more than one in place and have them be unique.
(defsc Foo [this {:keys [label]}]
{:query [:type :id {:label (prim/get-query ItemLabel)}]
:initial-state (fn [{:keys [id label]}] {:id id :type :foo :label (prim/get-initial-state ItemLabel {:value label})})}
(dom/div
(dom/h2 "Foo")
(ui-label label)))
(def ui-foo (prim/factory Foo {:keyfn :id}))
(defsc Bar [this {:keys [label]}]
{:query [:type :id {:label (prim/get-query ItemLabel)}]
:initial-state (fn [{:keys [id label]}] {:id id :type :bar :label (prim/get-initial-state ItemLabel {:value label})})}
(dom/div
(dom/h2 "Bar")
(ui-label label)))
(def ui-bar (prim/factory Bar {:keyfn :id}))
;; This is the to-many union component. It is the decision maker (it has no state or rendering of it's own)
;; The initial state of this component is the to-many (vector) value of various children
;; The render just determines which thing it is, and passes on the that renderer
(defsc ListItem [this {:keys [id type] :as props}]
{:initial-state (fn [params] [(prim/get-initial-state Bar {:id 1 :label "A"}) (prim/get-initial-state Foo {:id 2 :label "B"}) (prim/get-initial-state Bar {:id 3 :label "C"})])
:query (fn [] {:foo (prim/get-query Foo) :bar (prim/get-query Bar)}) ; use lambda for unions
:ident (fn [] [type id])} ; lambda for unions
(case type
:foo (ui-foo props)
:bar (ui-bar props)
(dom/p "No Item renderer!")))
(def ui-list-item (prim/factory ListItem {:keyfn :id}))
;; Settings and Main are the target "Panes" of a to-one union (e.g. imagine tabs...we use buttons as the tab switching in
;; this example). The initial state looks very much like any other component, as does the rendering.
(defsc Settings [this {:keys [label]}]
{:initial-state (fn [params] {:type :settings :id :singleton :label (prim/get-initial-state ItemLabel {:value "Settings"})})
:query [:type :id {:label (prim/get-query ItemLabel)}]}
(ui-label label))
(def ui-settings (prim/factory Settings {:keyfn :type}))
(defsc Main [this {:keys [label]}]
{:initial-state (fn [params] {:type :main :id :singleton :label (prim/get-initial-state ItemLabel {:value "Main"})})
:query [:type :id {:label (prim/get-query ItemLabel)}]}
(ui-label label))
(def ui-main (prim/factory Main {:keyfn :type}))
;; This is a to-one union component. Again, it has no state of its own or rendering. The initial state is the single
;; child that should appear. Fulcro (during startup) will detect this component, and then use the query to figure out
;; what other children (the ones that have initial-state defined) should be placed into app state.
(defsc PaneSwitcher [this {:keys [id type] :as props}]
{:initial-state (fn [params] (prim/get-initial-state Main nil))
:query (fn [] {:settings (prim/get-query Settings) :main (prim/get-query Main)})
:ident (fn [] [type id])}
(case type
:settings (ui-settings props)
:main (ui-main props)
(dom/p "NO PANE!")))
(def ui-panes (prim/factory PaneSwitcher {:keyfn :type}))
;; The root. Everything just composes to here (state and query)
;; Note, in core (where we create the app) there is no need to say anything about initial state!
(defsc Root [this {:keys [panes items]}]
{:initial-state (fn [params] {:panes (prim/get-initial-state PaneSwitcher nil)
:items (prim/get-initial-state ListItem nil)})
:query [{:items (prim/get-query ListItem)}
{:panes (prim/get-query PaneSwitcher)}]}
(dom/div
(dom/button {:onClick (fn [evt] (prim/transact! this '[(nav/settings)]))} "Go to settings")
(dom/button {:onClick (fn [evt] (prim/transact! this '[(nav/main)]))} "Go to main")
(ui-panes panes)
(dom/h1 "Heterogenous list:")
(dom/ul
(mapv ui-list-item items))))
6.2. Initial State and State Progressions
If you remember from the diagram about pure rendering, then you’ll also note that this step generates the first state in that progression. Rendering any state results in the UI for that state.
An interesting note is that this model also results in a really useful property: You can take the initial state, run it though the implementation of one or more mutations, and end up with any other state. This means you can easily reason about initializing your application into any state, which is useful for things like testing and server-side rendering.
There are all sorts of very useful features that fall out of this. For example, it is also possible to record a series of "user interactions" (which can be recorded as a list of the mutations that ran) and replay those. This could be used to send a tester a sequence of steps to show recent development work, run automated demos/tests, teleport your development environment to a specific page, etc.
Writing tests against the state model and mutation implementations is a great way to unit test your application without needing to involve the UI itself at all! You can read more about that in the section on Visual Regression Testing
7. Normalization
Normalization is a central mechanism in Fulcro. It is the means by which your data trees (which you receive from component queries against servers) can be placed into your normalized graph database.
7.1. Internals
The function prim/tree→db
is the workhorse that turns an incoming tree of data into normalized data (which can then
be merged into the overall database).
Imagine an incoming tree of data:
{ :people [ {:db/id 1 :person/name "Joe" ...} {:db/id 2 ...} ... ] }
and the query:
[{:people (prim/get-query Person)}]
which expands to:
[{:people [:db/id :person/name]}]
^ metadata {:component Person}
tree→db
recursively walks the data structure and query:
-
At the root, it sees
:people
as a root key and property. It remembers it will be writing:people
to the root. -
It examines the value of
:people
and finds it to be a vector of maps. This indicates a to-many relationship. -
It examines the metadata on the subquery of
:people
and discovers that the entries are represented by the componentPerson
-
For each map in the vector, it calls the
ident
function ofPerson
(which it found in the metadata) to get a database location. It then places the "person" values into the result viaassoc-in
on the ident. -
It replaces the entries in the vector with the idents.
If the metadata was missing then it would assume the person data did not need normalization. This is why it is
critical to compose queries correctly. The query and tree of data must have a parallel structure, as should the
UI. In template mode defsc
will try to check some things for you, but you must ensure that you
compose the queries correctly.
7.2. Normalization: Initial State, Server Interactions, and Mutations
The process described above is how most data interactions occur. At startup the :initial-state
supplies data that
exactly matches the tree of the UI. This gives your UI some initial state to render. The normalization mechanism
described above is exaclty what happens to that initial tree when it is detected by Fulcro at startup.
Network interactions send a UI-based query (which remember is annotated with the components). The query is remembered and when a response tree of data is received (which must match the tree structure of the query), the normalization process is applied and the resulting normalized data is merged with the database.
If using websockets, it is the same thing: A server push gives you a tree of data. You could hand-normalize that data,
but actually if you know the structure of the incoming data you can easily generate a client-side query (using
defsc
or defui
) that can be used in conjunction with prim/tree→db
to normalize that incoming data.
Mutations can do the same thing. If a new instance of some entity is being generated by the UI as a tree of data, then the query for that UI component can be used to turn it into normalized data that can be merged into the state within the mutation.
Some useful functions to know about:
-
prim/merge-component
- A utility function for merging new instances of a (possibly recursive) entity state into the normalized database. Usable from within mutations. -
prim/merge-state!
- A utility function for merging out-of-band (e.g. push notification) data into your application. Includes ident integration options, and honors the Fulcro merge clobbering algorithm (if the query doesn’t ask for it, then merge doesn’t affect it). Also queues rendering for affected components (derived from integration of idents). Generally not used within mutations (usemerge-component
andintegrate-ident*
instead). -
prim/tree→db
- General utility for normalizing data via a query and chunk of data. -
fulcro.client.mutations/integrate-ident*
- A utility for adding an ident into existing to-one and to-many relations in your database. Can be used within mutations. -
fulcro.client.util/deep-merge
- An implementation of merge that is recursive
8. Handling Mutations
Mutations are known by their symbol and are dispatched to the internal multimethod
fulcro.client.mutations/mutate
. To handle a mutation you can do two basic things: use defmethod
to add a mutation support, or use the macro defmutation
. The macro is recommended for most cases
because it namespaces the mutation, prevents some common errors, and works better with IDEs.
8.1. Stages of Mutation
There are multiple passes on a mutation: one local, and one for each possible remote. It is
technically the job of the mutation handler to return a lambda for the local pass, and a boolean (or AST)
for each remote. Returning nil
from any pass means to not do anything for that concern.
For example, say you have three remotes: one for normal API, one that hits a REST API, and one for file uploads. Each would have a name, and each pass of the mutation handling would be interested in knowing what you’d like to do for the local or remote.
The mutation environment (env
in the examples) contains a target that is set to a remote’s name when
the mutation is being asked for details about how to handle the mutation with respect to that remote.
For each pass the mutation is supposed to return a map whose key is :action
or the name of the remote, and
whose value is the thing to do (a lambda for :action
, and AST or true/false for remotes).
Summary:
-
You
transact!
somewhere in the UI -
The internals call your mutation with
:target
set to nil inenv
. You return a map with an:action
key whose value is the function to run. -
The internals call your mutation once for each remote, with
:target
set. You return a map with that remote’s keyword as the key, and either a boolean or AST as the remote action. (true means send the AST for the expression sent in (1) to the remote)
8.2. Using the Multimethod Directly
Typically the multipass nature is ignored by the mutation itself, and it just returns a map containing all of the possible things that should be done. This looks like:
(defmethod fulcro.client.mutations/mutate `mutation-symbol [{:keys [state ast target] :as env} k params]
{:action (fn [] ...)
:rest-api true ; trigger this remotely on the rest API AND the normal one.
:remote true })
Since the action is just data, it doesn’t matter that we "generate" it for the multiple passes. Same for the remotes.
Some common possible mistakes are:
-
You side-effect. Your mutation will be called at least two times so this is a bad idea. Side effects should be wrapped in the action.
-
You assume that the remote expression "sees" the old state (e.g. you might build an AST based on what is in app state). The local action is usually run before the remote passes, meaning the state has already changed and the remote logic is seeing the "new" client database state.
-
You forget to return a map with the correct keys (usually if you made mistake 1).
There is no guaranteed order to evaluation. Therefore if you need a value from state as it was seen when the mutation was triggered: send it as a parameter to the mutation from the UI (where you knew the old value). That way the call itself has captured the old value.
8.3. Using defmutation
defmutation
is a macro that writes the multimethod for you. It looks like this:
(defmutation mutation-symbol
"docstring"
[params]
(action [{:keys [state] :as env}]
(swap! state ...))
(rest-api [env] true)
(remote [env] true))
Thus it ends up looking more like a function definition. IDE’s like Cursive can be told how to resolve
the macro (as defn
in this case) and will then let you read the docstrings and navigate to the definition
from the usage site in transact. This makes development a lot easier.
Another advantage is that the symbol is placed into the namespace in which it is declared (not interned, just given the namespace…it is still just symbol data). Syntax quoting can expand aliasing, which means you get a very nice tool experience at usage site:
(ns app
(:require [app.mutations :as am]))
...
(prim/transact! this `[(am/mutation-symbol {})]) ; am gets expanded to app.mutations
The final advantage is it is harder to accidentally side-effect. The action
section of defmutation
will wrap the logic in a lambda, meaning that it can read as-if you’re side-effecting, but in fact
will do the right thing.
In general these advantages mean you should generally use the macro to define mutations, but it is good to be aware that underneath is just a multimethod.
8.4. Refreshing Non-local UI
In the default rendering mode Fulcro is capable of optimizing your rendering refresh so that the bare minimum amount of activity happens in the query engine and React. The basic rules are as follows:
-
Refresh the subtree starting at the component that ran
transact!
. If it was the reconciler, run from root. -
Refresh any components that share the same
ident
with the component that ran the transaction. -
Refresh any component that queries for any of the data that was indicated in "follow-on" reads.
8.4.1. Parent-Child Relationships
The most common case of non-local UI refresh comes up with parent-child relationships. In these cases, the parent can be seen as a UI component in control of the children (it is responsible for telling them to render). In such cases it is usually better to reason about the management logic (i.e. deleting, reordering, etc) from the parent; however, it is commonly the case the you want the child to render some controls, such as a delete button. Thus, the control that wants to modify the state (the delete button on the item) is not local to the component that will need to refresh (the parent’s list).
The solution is simple: create a callback in the parent that can run the delete transaction and pass it through as a computed value.
In our example, we’ll assume the delete is global (removes the item from the list and the normalized table):
(m/defmutation delete-item
"Mutation: Delete an item from a list"
[{:keys [id]}]
(action [{:keys [state]}]
(swap! state
(fn [s]
(-> s
(update :items dissoc id) ; remove the item from the db (optional)
(m/remove-ident* [:lists 1 :list/items] [:items id])))))) ; remove the item from the list of idents
The source of the components for this demo are below. Make note of the use of computed
.
(ns book.demos.parent-child-ownership-relations
(:require
[fulcro.client.dom :as dom]
[fulcro.client.mutations :as m]
[fulcro.client :as fc]
[fulcro.client.primitives :as prim :refer [defsc]]))
; Not using an atom, so use a tree for app state (will auto-normalize via ident functions)
(def initial-state {:ui/react-key "abc"
:main-list {:list/id 1
:list/name "My List"
:list/items [{:item/id 1 :item/label "A"}
{:item/id 2 :item/label "B"}]}})
(defonce app (atom (fc/make-fulcro-client {:initial-state initial-state})))
(m/defmutation delete-item
"Mutation: Delete an item from a list"
[{:keys [id]}]
(action [{:keys [state]}]
(swap! state
(fn [s]
(-> s
(update :items dissoc id)
(m/remove-ident* [:items id] [:lists 1 :list/items]))))))
(defsc Item [this
{:keys [item/id item/label] :as props}
{:keys [on-delete] :as computed}]
{:initial-state (fn [{:keys [id label]}] {:item/id id :item/label label})
:query [:item/id :item/label]
:ident [:items :item/id]}
(dom/li label (dom/button {:onClick #(on-delete id)} "X")))
(def ui-list-item (prim/factory Item {:keyfn :item/id}))
(defsc ItemList [this {:keys [list/name list/items]}]
{:initial-state (fn [p] {:list/id 1
:list/name "List 1"
:list/items [(prim/get-initial-state Item {:id 1 :label "A"})
(prim/get-initial-state Item {:id 2 :label "B"})]})
:query [:list/id :list/name {:list/items (prim/get-query Item)}]
:ident [:lists :list/id]}
(let [; pass the operation through computed so that it is executed in the context of the parent.
item-props (fn [i] (prim/computed i {:on-delete #(prim/transact! this `[(delete-item {:id ~(:item/id i)})])}))]
(dom/div
(dom/h4 name)
(dom/ul
(map #(ui-list-item (item-props %)) items)))))
(def ui-list (prim/factory ItemList))
(defsc Root [this {:keys [main-list]}]
{:initial-state (fn [p] {:main-list (prim/get-initial-state ItemList {})})
:query [{:main-list (prim/get-query ItemList)}]}
(dom/div (ui-list main-list)))
8.4.2. Follow-on Reads
A follow-on read is a property listed in a transaction:
(transact! this `[(f) :person/name])
It indicates that the given data will have changed, and therefore any on-screen component that queries for that particular data should also be refreshed when the transaction completes the optimistic update (and again after the remote interaction, if there is one).
Follow-on reads allow the developer to reason more abstractly about non-local UI refresh. They need only think about what data is changing, and not about what components might be displaying it. This allows the UI to evolve without additional concerns that refresh will be slow (due to expensive data analysis) or will become broken (because you didn’t know a component needed refresh).
(defsc Right [this {:keys [db/id right/value]}]
{:query [:db/id :right/value]}
(dom/div
; needs a follow-on read because it is updating something that is queried elsewhere...
(dom/button {:onClick #(prim/transact! this `[(ping-left {}) :left/value])} "Ping Left")
value))
This model was inherited from Om Next, and it is the correct model to use when the transaction might, for example, modify opaque data that is not directly queried but which would cause a component to need a refresh; however, since most transactions run a mutation that is really more aware of what data is changing it makes quite a bit of sense for you to be able to declare this on the mutation itself.
8.4.3. Declarative Refresh
Fulcro 2.0+ supports a way of declaring follow-on reads that allows for better local reasoning: co-locate the
follow-on reads with the mutation itself. The mechanism is quite simple: add a refresh
section on your mutation
and return the list of keywords for the data that changed:
(defmutation ping-left [params]
(action [{:keys [state]}]
(swap! state update-in [:left/by-id 5 :left/value] inc))
(refresh [env] [:left/value]))
The above mutation just indicates what it changed: :left/value
.
The UI can then drop the follow-on reads:
(defsc Right [this {:keys [db/id right/value]}]
{:query [:db/id :right/value] }
(dom/div
; no longer need the :left/value as a follow-on read
(dom/button {:onClick #(prim/transact! this `[(ping-left {})])} "Ping Left")
value))
In this case the transaction is running on a component that doesn’t query for the data being changed (it is pinging the Left component). The built-in refresh list on the mutation takes care of the update!
The live example below is a full-stack demo of this. The buttons update data that the other button displays. The `transact!`s on these would normally require follow-on reads or a callback to the parent to refresh properly. With the refresh list on the mutation itself the UI designer is freed from this responsibility.
The right button uses data from the server in a pessimistic fashion (it does no optimistic update, and you can increase the simulated delay on the server), so pinging it from the left actually reads a value from the server. This demonstrates that the refresh is working for full-stack operations.
(ns book.demos.declarative-mutation-refresh
(:require [fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]
[fulcro.util :as util]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(server/defmutation ping-right [params]
(action [env]
{:db/id 1 :right/value (util/unique-key)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmutation ping-left [params]
(action [{:keys [state]}]
(swap! state update-in [:left/by-id 5 :left/value] inc))
(refresh [env] [:left/value]))
(declare Right)
(defmutation ping-right [params]
(remote [{:keys [state ast]}]
(m/returning ast state Right))
(refresh [env] [:right/value]))
(defsc Left [this {:keys [db/id left/value]}]
{:query [:db/id :left/value]
:initial-state {:db/id 5 :left/value 42}
:ident [:left/by-id :db/id]}
(dom/div {:style {:float "left"}}
(dom/button {:onClick #(prim/transact! this `[(ping-right {})])} "Ping Right")
value))
(def ui-left (prim/factory Left {:keyfn :db/id}))
(defsc Right [this {:keys [db/id right/value]}]
{:query [:db/id :right/value]
:initial-state {:db/id 1 :right/value 99}
:ident [:right/by-id :db/id]}
(dom/div {:style {:float "right"}}
(dom/button {:onClick #(prim/transact! this `[(ping-left {})])} "Ping Left")
value))
(def ui-right (prim/factory Right {:keyfn :db/id}))
(defsc Root [this {:keys [left right]}]
{:query [{:left (prim/get-query Left)}
{:right (prim/get-query Right)}]
:initial-state {:left {} :right {}}}
(dom/div {:style {:width "600px" :height "50px"}}
(ui-left left)
(ui-right right)))
8.5. Mutations Without Quoting or Circular References
There are times when using just defmutation
is inconvenient. If you need to use a component in a mutation (for a merge, for example),
then you can easily end up with circular references in your code. You could just use the fully-qualified mutation name in
the UI, but that is quite inconvenient (we’d rather alias it, but circular refs are not allowed).
Another inconvenience is quoting. It is very easy to foget to unquote values in a tx.
What if you could write your transactions "as-if" they were functions, but have them work as data? Well, we live in Clojure-land, so that is not only possible, but relatively easy.
Fulcro Incubator now includes suppport for declaring mutations in any namespace (think of it like an interface namespace), and those declarations can be used quote-free!
To use this support, add Fulcro Incubator to your project:
[fulcrologic/fulcro-incubator "0.0.2"]
and then declare your mutations together in some namespace:
(ns my-mutations
(:require [fulcro.incubator.mutation-interface :as mi :refer [declare-mutation]]
[cljs.spec.alpha :as s])
(s/def ::a string?)
(s/def ::b pos-int?)
(declare-mutation boo
"Mutation to do blah" ; doc string optional
'real-ns.of.mutations/boo
(s/keys :req-un [::a ::b])) ; spec not currently optional, probably should be :)
and now you can use boo
without quoting!
(ns real-ui
(:require
[my-mutations :refer [boo]]
...))
...
(prim/transact! this [(boo {:a "hello" :b 22})])
There’s really not much to the magic. boo
becomes a function-like thing that just returns a list with the proper
stuff in it:
(boo {:a "hello"}) => '(real-ns.of.mutations/boo {:a "hello"})
Additionally, since your real mutation is in a whole different namespace that is never required by the UI or the interface you are now free to require the UI in the mutation namespace if you need it for merges and other component-centric mutations concerns without causing circular references.
This requires an extra namespace and a little extra declaration work, but cleans up the UI and ns requires quite well.
Fulcro 2.8.8 includes a global component registry. Any component that is (transitively) required in your application will appear in the registry, which makes it possible to track component classes in app state (since the name is serializable) and also look up a component class for merges from within mutations:
(defmutation f [params]
(action [{:keys [state]}]
(let [todo-list-class (prim/classname->class :com.company.app/TodoList)]
(swap! prim/merge-component todo-list-class {:list/id ...}))))
8.5.1. Parameter specs
Currently the specs are not enforced unless checked-mutations
is bound to true in the context they are used:
(binding [mi/*checked-mutations* true]
(prim/transact! this [(boo {:a 22})]))
should throw an exception.
This should be considered an experimental part of the API. Feedback is welcome.
8.5.2. IDE Integration
The syntax of defmutation
and declare-mutation
are both intentionally "shaped" like defn
and def
so that you
can tell your IDE how to resolve them. In IntelliJ, you can do this by clicking on the macro name (e.g. defmutation
)
and waiting for the light bulb. Then select "Resolve as" and then defn
for defmutation
and def
for delcare-mutation
.
You may not need to do this for defmutation
, as IntelliJ does have some predefined resolutions for Fulcro built-in.
8.5.3. Future Status of declare-mutation
Expect this to make it into Fulcro proper in the near future, but for now we’ve got it in incubator while we work on refining the API.
8.6. Recommendations About Writing Mutations
Mutations themselves are meant to be abstractions across the entire stack; however, the optimistic side of them are really just functions on your application state. Components have nice clean abstractions, and you will often benefit from writing low-level functions that represent the general operations on a component’s data. As you move towards higher-level abstractions you’ll want to compose those lower-level functions. As such, it pays to think a little about how this will look over time.
If you write the actual logic of a mutation into a defmutation
, then composition is difficult because the
model does not encourage recursive calls to transact!
. This will either lead to code duplication or other bad
practices.
To maximize code reuse, local reasoning, and general readability it pays to think about your mutations in the following manner:
-
A mutation is a function that changes the state of the application:
state → mutation → state'
-
Within a mutation, you are essentially doing operations to a graph, which means you have operations that work on some node in the graph:
node → op → node'
. These operations may modifiy a scalar or an edge to another node.
8.6.1. Node-Specific Mutation
You can run a node-specific operation with:
(swap! state update-in node-ident op args)
For example, say you want to implement a mutation that adds a person to another person’s friend list. The data
representation of a person is: {:db/id 1 :person/name "Nancy" :person/friends []}
.
The ident
function can be coded into a top-level function and used by the component:
(defn person-ident [id-or-props]
(if (map? id-or-props)
(person-ident (:db/id id-or-props))
[:person/by-id id-or-props]))
(defsc Person [this props]
{:ident (fn [] (person-ident props))}
...)
and our desired operation on the general state of a person can also be written as a simple function (add an ident to the :person/friends field):
(defn add-friend*
"Add a friend to an existing person. Returns the updated person."
[person target-person-id]
(update person :person/friends (fnil conj []) (person-ident target-person-id)))
Note, in particular, that we always choose to put the item to be updated as the first argument so that functions
like update
are easier to use with it.
Now (add-friend* {:db/id 1 :person/name "Nancy"} 33)
results in
{:db/id 1, :person/name "Nancy", :person/friends [[:person/by-id 33]]}
.
You can write a mutation to support this operation within Fulcro:
(defmutation add-friend [{:keys [source-person-id target-id]}]
(action [{:keys [state]}]
(swap! state update-in (person-ident source-person-id)
add-friend* target-id)))
You could even take it a step further with a little more sugar by defining a helper that can turn a node-specific op into a db-level operation (again note that the thing being updated is the first argument):
(defn update-person [state-map person-id person-fn & args]
(apply update-in state-map (person-ident person-id) person-fn args))
(defmutation add-friend [{:keys [source-person-id target-id]}]
(action [{:keys [state]}]
(swap! state update-person source-person-id add-friend* target-id)))
If you find that a given entity is always modified in the context of the state map itself (a common case) then it can be a bit shorter to just push the table logic (ident resolution) into the operation itself:
(defn add-friend**
"Add a friend to an existing person in the client database."
[app-state person-id friend-id]
(update-in app-state [:person/by-id person-id :person/friends] (fnil conj []) (person-ident friend-id)))
(defmutation add-friend [{:keys [source-person-id target-id]}]
(action [{:keys [state]}]
(swap! state add-friend** source-person-id target-id)))
8.6.2. Composition in Mutations
Once you have your general operations written as basic functions on either the entire state (like update-person
) or
targeted to nodes or the state map itself (like add-friend*
), then it becomes much easier to create mutations that
compose together operations to accomplish any higher-level task.
For example, the fulcro.client.routing/update-routing-links
function takes a state map, and changes all of the routers
in the application state to show that particular screen. So, say you wanted add-friend to also
take you to the screen that shows the details of that particular person. The top-level abstract mutation in the UI
might still be called add-friend
, but the internals now have two things to do.
Having all of these functions on the graph database allows you to write this in a very nice form as a sequence of operations on the state map itself through threading:
(defmutation add-friend
"Locally add a friend, and show them. Full stack operation.
[{:keys [source-id target-id]}]
(action [{:keys [state]}]
(swap! state
(fn [s]
(-> s
(r/update-routing-links {:handler :show-friend :route-params {:person-id target-id}})
; Could use (add-friend** source-id target-id) or:
(update-person source-id add-friend* target-id)))))
(remote [env] true)
and your mutations become a thing of beauty!
Of course, this can also be overkill. It is true that it is often handy to be able to compose many db operations together
into one abstract mutation, but don’t forget that more that one mutation can be triggered by a single call
to transact!
:
(prim/transact! this `[(route-to {:handler :show-friend :route-params {:person-id ~target-id}})
(add-friend {:source-person-id ~my-id :target-id ~target-id})])
You’ll want to balance your mutations just like you do any other library of code: so that reuse and clarity are maximized. In the case of mutations the deciding factor is often how you want to deal with remote mutations.
8.7. Advanced Mutation Topics
This section covers a number of additional mutation techniques that arise in more advanced situations.
Almost all of these circumstances arise
from needing to modify your application database outside of the normal prim/transact!
mechanism at the UI layer.
A lot of these things are handled for you with normal full-stack operations, so you might want to skip this section until you’re comfortable with that material.
8.7.1. Saving a Reconciler
The first note is that prim/transact!
can be used on the reconciler. If you’ve saved your Fulcro Application
in a top-level atom then you can run a transaction "globally" like this:
(prim/transact! (:reconciler @app) ...)
This should generally be used in cases where there is an abstract operation (e.g. you want setTimeout
to update
a js/Date to the current time and have the screen refresh). Using (prim/transact! (:reconciler @app) '[(update-time) :current-time])
is
much clearer and in the spirit of the framework than any other low-level data tweaking. That could also be done in
the context of a component to prevent an overall root re-render, though you’d want to be careful to use both sides of
the component lifecycle to install and remove a timer that triggers such an update.
8.7.2. Swapping on the State Atom?
Yes. There is a watch on Fulcro’s state atom. Doing so will cause a refresh from the root of your UI.
(let [{:keys [reconciler]} @app
state-atom (prim/app-state reconciler)]
(swap! state-atom …))
8.7.3. Leveraging fulcro.client.primitives/tree→db
In some cases you will have obtained some data (or perhaps invented it) and you need to integrate that data into the
database. If the data matches your UI structure (as a tree) and you have proper Ident
declarations on those components
then you can simply transform the data into the correct shape via the tree→db
function using a component’s
query.
Unfortunately, you would then need to follow that transform by a sequence of operations on app state to merge those various bits.
The function also requires a query in order to do normalization (split the tree into tables).
Important
|
The general interaction with the world requires integration of external data (often in a tree format) with your app database (normalized graph of maps/vectors). As a result, you almost always want a component-based query when integrating data so that the result is normalized. |
8.7.4. Using integrate-ident
When in a mutation you very often need to place an ident in various spots in your graph database. The helper
function fulcro.client.mutations/integrate-ident*
can by used from within mutations to help you do this. It accepts
any number of named parameters that specify operations to do with a given ident:
(swap! state
(fn [s]
(-> s
(do-some-op)
(integrate-ident* the-ident
:append [:table id :field]
:prepend [:other id :field]))))
This function checks for the existence of the given ident in the target list, and will refuse to add it if it is
already there. The :replace
option can be used on a to-one or to-many relation. When replacing on a to-many, you
use an index in the target path (e.g. [:table id :field 2]
would replace the third element in a to-many :field
)
8.7.5. Creating Components Just For Their Queries
If your UI doesn’t have a query that is convenient for sending to the server (or for working on tree data like this), then it is considered perfectly fine to generate components just for their queries (no render). This is often quite useful, especially in the context of pre-loading data that gets placed on the UI in a completely different form (e.g. the UI queries don’t match what you’d like to ask the server).
(defsc SubQuery [t p]
{:ident [:sub/by-id :id]
:query [:id :data]})
(defsc TopQuery [t p]
{:ident [:top/by-id :id]
:query [:id {:subs (prim/get-query SubQuery)}]})
(ns book.tree-to-db
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[devcards.util.edn-renderer :refer [html-edn]]
[fulcro.client.mutations :as m :refer [defmutation]]))
(defsc SubQuery [t p]
{:ident [:sub/by-id :id]
:query [:id :data]})
(defsc TopQuery [t p]
{:ident [:top/by-id :id]
:query [:id {:subs (prim/get-query SubQuery)}]})
(defmutation normalize-from-to-result [ignored-params]
(action [{:keys [state]}]
(let [result (prim/tree->db TopQuery (:from @state) true)]
(swap! state assoc :result result))))
(defmutation reset [ignored-params] (action [{:keys [state]}] (swap! state dissoc :result)))
(defsc Root [this {:keys [from result]}]
{:query [:from :result]
:initial-state (fn [params]
; some data we're just shoving into the database from root...***not normalized***
{:from {:id :top-1 :subs [{:id :sub-1 :data 1} {:id :sub-2 :data 2}]}})}
(dom/div
(dom/div
(dom/h4 "Pretend Incoming Tree")
(html-edn from))
(dom/div
(dom/h4 "Normalized Result (click below to normalize)")
(when result
(html-edn result)))
(dom/button {:onClick (fn [] (prim/transact! this `[(normalize-from-to-result {})]))} "Normalized (Run tree->db)")
(dom/button {:onClick (fn [] (prim/transact! this `[(reset {})]))} "Clear Result")))
of course, you can see that you’re still going to need to merge the database table contents into your main app state and carefully integrate the other bits as well.
8.7.6. Using fulcro.client.primitives/merge!
Fulcro includes a function that takes care of the rest of these bits for you. It requires the reconciler (which
as we mentioned earlier can be obtained from the Fulcro App). The arguments are similar to tree→db
:
(prim/merge! (:reconciler @app) ROOT-data ROOT-query)
The same things apply as tree→db
(idents especially), however, the result of the transform will make it’s way into
the app state (which is owned by the reconciler).
IMPORTANT: The biggest challenge with using this function is that it requires the data and query to be structured from the ROOT of the database! That is sometimes perfectly fine, but our next section talks about a helper that might be easier to use.")
8.7.7. Using fulcro.client.primitives/merge-component!
There is a common special case that comes up often: You want to merge something that is in the context of some particular UI component.
(prim/merge-component! app ComponentClass ComponentData)
Think of this case as: I have some data for a given component (which MUST have an ident). I want to merge into that component’s entry in a table, but I want to make sure the recursive tree of data also gets normalized properly.
merge-component!
also integrates the functionality of integrate-ident*
to pepper the ident of the merged entity throughout
your app database, and can often serve as a total one-stop
shop for merging data that is coming from some external source.
This first argument can be an application or reconciler.
Merge-component! Demo
In the card below the button simulates some external event that has brought in data that we’d like to merge (a newly arrived counter entity):
{ :counter/id 5 :counter/n 66 }
We’ll want to:
-
Add the counter to the counter’s table (which is not even present because we have none in our initial app state)
-
Add the ident of the counter to the UI panel so its UI shows up
(defsc Counter [this {:keys [counter/id counter/n] :as props} {:keys [onClick] :as computed}]
{:query [:counter/id :counter/n]
:ident [:counter/by-id :counter/id]}
...
(defn add-counter
"Merge the given counter data into app state and append it to our list of counters"
[reconciler counter]
(prim/merge-component! reconciler Counter counter
:append [:panels/by-kw :counter :counters]))
...
(defsc Root ...
(let [reconciler (prim/get-reconciler this)] ; one way to get to the reconciler
(dom/button {:onClick #(add-counter reconciler {:counter/id 4 :counter/n 22})} "Simulate Data Import")
...
Here is the full running example with source:
(ns book.merge-component
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.data-fetch :as df]))
(defsc Counter [this {:keys [counter/id counter/n] :as props} {:keys [onClick] :as computed}]
{:query [:counter/id :counter/n]
:ident [:counter/by-id :counter/id]}
(dom/div :.counter
(dom/span :.counter-label
(str "Current count for counter " id ": "))
(dom/span :.counter-value n)
(dom/button {:onClick #(onClick id)} "Increment")))
(def ui-counter (prim/factory Counter {:keyfn :counter/id}))
; the * suffix is just a notation to indicate an implementation of something..in this case the guts of a mutation
(defn increment-counter*
"Increment a counter with ID counter-id in a Fulcro database."
[database counter-id]
(update-in database [:counter/by-id counter-id :counter/n] inc))
(defmutation increment-counter [{:keys [id] :as params}]
; The local thing to do
(action [{:keys [state] :as env}]
(swap! state increment-counter* id))
; The remote thing to do. True means "the same (abstract) thing". False (or omitting it) means "nothing"
(remote [env] true))
(defsc CounterPanel [this {:keys [counters]}]
{:initial-state (fn [params] {:counters []})
:query [{:counters (prim/get-query Counter)}]
:ident (fn [] [:panels/by-kw :counter])}
(let [click-callback (fn [id] (prim/transact! this
`[(increment-counter {:id ~id}) :counter/by-id]))]
(dom/div
; embedded style: kind of silly in a real app, but doable
(dom/style ".counter { width: 400px; padding-bottom: 20px; }
button { margin-left: 10px; }")
; computed lets us pass calculated data to our component's 3rd argument. It has to be
; combined into a single argument or the factory would not be React-compatible (not would it be able to handle
; children).
(map #(ui-counter (prim/computed % {:onClick click-callback})) counters))))
(def ui-counter-panel (prim/factory CounterPanel))
(defonce timer-id (atom 0))
(declare sample-of-counter-app-with-merge-component-fulcro-app)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; The code of interest...
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn add-counter
"NOTE: A function callable from anywhere as long as you have a reconciler..."
[reconciler counter]
(prim/merge-component! reconciler Counter counter
:append [:panels/by-kw :counter :counters]))
(defsc Root [this {:keys [panel]}]
{:query [{:panel (prim/get-query CounterPanel)}]
:initial-state {:panel {}}}
(let [reconciler (prim/get-reconciler this)] ; pretend we've got the reconciler saved somewhere...
(dom/div {:style {:border "1px solid black"}}
; NOTE: A plain function...pretend this is happening outside of the UI...we're doing it here so we can embed it in the book...
(dom/button {:onClick #(add-counter reconciler {:counter/id 4 :counter/n 22})} "Simulate Data Import")
(dom/hr)
"Counters:"
(ui-counter-panel panel))))
9. Full Stack Operation
One of the most interesting and powerful things about Fulcro is that the model for server interaction is unified into a clean data-driven structure. At first the new concepts can be challenging, but once you’ve seen the core primitive (component-based queries/idents for normalization) we think you’ll find that it dramatically simplifies everything!
In fact, now that you’ve completed the materials of this guide on the graph database, queries, idents, and normalization, it turns out that the server interactions become nearly trivial!
Not only is the structure of server interaction well-defined, Fulcro come with pre-written server-side code that handles all of the Fulcro plumbing for you. You can choose to provide as little or as much as you like. The easy server code provides everything in a Ring-based stack, and you can also choose to hand-build your server using a simple API handler for the Fulcro API route.
Even then, there are a lot of possible pitfalls when writing distributed applications. People often underestimate just how hard it is to get web applications right because they forget that.
So, while the API and mechanics of how you write Fulcro server interactions are as simple as possible there is no getting around that there are some hairy things to navigate in distributed apps independent of your choice of tools. Fulcro tries to make these things apparent, and it also tries hard to make sure you’re able to get it right without pulling out your hair.
Here are some of the basics:
-
Networking is provided.
-
The protocol is EDN on the wire (via transit), which means you just speak Clojure data on the wire, and can easily extend it to encode/decode new types.
-
-
All network requests (queries and mutations) are processed sequentially unless you specify otherwise. This allows you to reason about optimistic updates (Starting more than one at a time via async calls could lead to out-of-order execution, and impossible-to-reason-about recovery from errors).
-
You may provide fallbacks that indicate error-handling mutations to run on failures.
-
Writes and reads that are enqueued together will always be performed in write-first order. This ensures that remote reads are as current as possible.
-
Any
:ui/
namespaced query elements are automatically elided when generating a query from the UI to a server, allowing you to easily mix UI concerns with server concerns in your component queries. You can also globally modify queries via load’s:update-query
option, and even set it to a global default via an option tomake-fulcro-client
. -
Normalization of a remote query result is automatic.
-
Deep merge of query results uses intelligent overwrite for properties that are already present in the client database.
-
Any number of remotes can be defined (allowing you to easily integrate with microservices).
-
Protocol and communication is strictly constrained to the networking layer and away from your application’s core structure, meaning you can actually speak whatever and however you want to a remote. In fact the concept of a remote is just "something you can talk to via queries and mutations". You can easily define a "remote" that reads and writes browser local storage or a Datascript database in the browser. This is an extremely powerful generalization for isolating side-effect code from your UI.
Note
|
To those of you with REST or GraphQL APIs: See the Pathom library for ways of converting those services into custom client remotes that isolate the specific of those APIs to the networking layers of your client. |
9.1. General Theory of Operation
There are only a few general kinds of interactions with a server:
-
Initial loads when the application starts
-
Incremental loads of sub-graphs of something that was previously loaded.
-
Event-based loads (e.g. user or timed events)
-
Integrating data from other external sources (e.g. server push)
In standard Fulcro networking, all of the above have the following similarities:
-
A component-based graph query needs to be involved to enable auto-normalization. This is true even for a server push (though in that case the client needs to know what implied question the server is sending data about).
-
The data from the server will be a tree that has the same shape as the query.
-
The data needs to be normalized into the client database.
-
Optionally: after integrating new data there may be some need to transform the result to a form the UI needs (e.g. perhaps you need to sort or paginate some list of items that came in).
IMPORTANT: Remember what you learned about the graph database, queries, and idents. This section cannot possibly be understood properly if you do not understand those topics!
9.1.1. Integration of ALL new external data is just a Query-Based Merge
So, here is the secret: When external data needs to go into your database it all uses the exact same mechanism: a query-based merge. So, for a simple load: you send a UI-based query to the server, the server responds with a tree of data that matches that graph query, and then the query itself (which is annotated with the components and ident functions) can be used to normalize the result. Finally, the normalized result can be merged into your existing client database.
Query --> Server --> Response + Original Query w/Idents --> Normalized Data --> Database Merge --> New Database
Any other kind of external data integration just starts at the "Response" step by manually providing the query that is annotated with components/idents:
New External Data + Query w/Idents --> Normalized Data --> Database Merge --> New Database
There is a primitive function merge!
function that implements this, so that you can simplify the picture to:
Tree of Data + Query --> merge! --> New Database
9.1.2. The Central Functions: transact!
and merge!
The two core functions that allow you to trigger abstract operations or data merges externally (via a reconciler) are:
prim/transact!
-
The central function for running abstract changes in the application. Can be run with a component or reconciler. If run with the reconciler, will typically cause a root re-render.
prim/merge!
-
A function that can be run against the reconciler to merge a tree of data via a UI query. This is the primary function that is used to integrate data in response to things like websocket server push.
These (and related/derived helpers) are the primary tools to use when trying to make your application respond to external stimuli that is not triggered from the UI.
9.1.3. Query Mismatch
We have all sorts of ways we’d like to view data. Perhaps we’d like to view "all the people who’ve ever had a particular phone number". That is something we can very simply represent with a UI graph, but may not be trivial to pull from our database.
In general, there are a few approaches to resolving our graph differences:
-
Use a query parser on the server to piece together data based on the graph of the query.
-
Ask the server for exactly what you want, using an invented well-known "root" keyword, and hand-code the database code to create the UI-centric view.
-
Ask the server for the data in a format it can easily provide and morph it on the client.
The first two have the advantage of making the client blissfully unaware of the server schema. It just asks for what it needs, and someone on the server programming team is stuck with satisfying the query. This is the down-side: the number of possible UI-centric queries could become quite large. Theoretically a parser solution makes this more tractable than a hand-coded variant, but in practice the parser is hard to make general in a way that allows UI developers to just run willy-nilly queries and get what they want.
In the example of "people who’ve had a particular phone number", the graph would be phone-number centric. Maybe
[:db/id :phone/number {:phone/historical-owners (prim/get-query Person)}]
. There probably isn’t a graph edge in the
real database called :phone/historical-owners
. One could write parser code that understood this particular edge,
and did the logic. In this case, that really isn’t even that hard and may be a good choice.
Fulcro gives you another easy-to-access option: morph something the server can easily provide (on the real graph without custom code). We’ll show you an example of this as we explore the data fetch options.
9.1.4. Server Interaction Order
Fulcro will serialize requests unless you mark queries as parallel (an option you can specify on load
). Two different
events that queue mutations or loads will be processed in order. For example, if the user clicks on something and you trigger two loads
during that event, then both of those will be combined (if they don’t conflict by querying for the same thing)
and sent as one network request. If the user clicks on something else and that handler queues two more loads, then the latter two
loads will not start over the network until the first load sequence has completed.
This ensures that you don’t get out-of-order server execution. This is a distributed system, so it is possible for a second request to hit a less congested server (or even thread) than the first and get processed out of order. That would hurt your ability to reason about your program, so the default behavior in Fulcro is to ensure that server interactions happen on the server in the same order as they do on the client.
If you combine mutations and reads in the same event processing (before giving up the thread), then Fulcro also ensures that remote mutations go over the wire and complete before reads. The idea being that you don’t want to fetch data and then immediately make it stale through mutations. This additional detail is also aimed at preventing subtle classes of application bugs.
In Summary:
-
Loads/transactions queued while "holding the UI thread" will be joined together in a single network request.
-
Remote writes go before reads
-
Loads/transactions queued at a later event "new UI thread event" are guaranteed to be processed after ones queued earlier.
-
You can override this behavior with the
parallel
option ofload
.
9.1.5. Server Result Query Merging
There is a potential for a data-driven app to create a new class of problem related to merging data. The normalization guarantees that the data for any number of views of the same thing are normalized to the same node in the graph database.
Thus, your PersonListRow
view and PersonDetail
view should both normalize the same person data to
a location like [:person/by-id id]
. Let’s say you have a quick list of people on the screen that is paginated and
demand-loaded, where each row is a PersonListRow
with query [:db/id :person/name {:person/image (prim/get-query Image)}]
.
Now say that you can click on one of these rows and a side-by-side view of that person’s PersonDetail
is shown,
but the query for that is a whole bunch of stuff: name, age, address, phone numbers, etc. A much larger query.
A naive merge could cause you all sorts of nightmares. For example, Refreshing the list rows would load only name and image, but if merge overwrote the entire table entry then the current detail would suddenly empty out!
Fulcro provides you with an advanced merging algorithm that ensures these kinds of cases don’t easily occur. It does an intelligent merge with the following algorithm:
-
It is a detailed deep merge. Thus, the target table entry is updated, not overwritten.
-
If the query asks for a value but the result does not contain it, then that value is removed.
-
If the query didn’t ask for a value, then the existing database value is untouched.
This reduces the problem to one of potential staleness. It is technically possible for an entity in the resulting client database to be in a state that has never existed on the server because of such a partial update. This is considered to be a better result than the arbitrary UI madness that a more naive approach would cause.
For example, in the example above a query for a list row will update the name and image. All of the other details (if already loaded) would remain the same; however, it is possible that the server has run a mutation that also updated this person’s phone number. The detail part of the UI will be showing an updated name and image, but the old phone number. Technically this "state of Person" has never existed in the server’s timeline, but from a user’s perspective it looks better than the phone number disappearing.
In practice this isn’t that big of a deal; however, if you are switching the UI to an edit mode it is generally a good practice to on-demand load the entity being edited to help prevent user confusion and accidental overwrite based on stale values.
9.1.6. React Lifecycle and Load
React lifecycle and Load may not mix well. It is technically legal to issue transactions and loads from React Lifecycle, methods, but it is recommended that you carefully check the state of the system in said mutations before actually queuing network traffic. Perhaps you wish to ensure something is loaded. To do that: trigger a mutation that checks state and optionally submits a load. You’ll learn how to do this in the sections on the data fetch API.
9.2. The Data Fetch API: load
Let’s say we want to load all of our friends from the server. A query has to be rooted somewhere, so we’ll invent
a root-level keyword, :all-friends
, and join it to the UI query for a Person
:
[{:all-friends (prim/get-query Person)}]
What we’d like to see then from the server is a return value with the shape of the query:
{ :all-friends [ {:db/id 1 ...} {:db/id 2 ...} ...] }
If we combine that query with that tree result and were to manually call merge!
we’d end up with this:
{ :all-friends [ [:person/by-id 1] [:person/by-id 2] ...]
:person/by-id { 1 { :db/id 1 ...}
2 { :db/id 2 ...}
...}}
The data fetch API has a simple function for this that will do all of these together (query derivation, server interaction, and merge):
(ns my-thing
(:require [fulcro.client.data-fetch :as df]))
...
(df/load comp-reconciler-or-app :all-friends Person)
An important thing to notice is that load
can be thought of as a function that loads normalized data into the
root node of your graph (:all-friends
appears in your root node).
This is sometimes what you want, but more often you really want data loaded at some other spot in your graph. We’ll talk about this more in a moment, first, let’s see how to handle that on the server.
9.2.1. Handling a Root Query
Note
|
The defquery* macros are meant as a quick entry point. Once inside you’ll have access to the subquery in
the env as :query . It is highly recommended that you use pathom to further
process queries. Specifically, we recommend using the "Connect" functionality of Pathom.
|
If you’re using the standard server support of Fulcro, then the API hooks are already predefined for you and you
can use helper macros to generate handlers for queries. If your load
specified a keyword then this is seen by
the server as a load targeted to your root node. Thus, the server macro is called defquery-root
:
(ns api
(:require [fulcro.server :refer [defquery-root defquery-entity defmutation]]))
(defquery-root :all-friends
"optional docstring"
(value [env params]
[{:db/id 1 :person/name ....} {:db/id 2 ...} ...]))
It’s as simple as that! Write a function that returns the correct value for the query. The query itself will be available
in the env
, and you can use libraries like pathom
, Datomic
, and fulcro-sql
to parse those queries into the proper tree
result from various data sources. See the Query Parsing chapter.
9.2.2. Entity Queries
You can also ask to load a specific entity. You do this simply by telling load the ident of the thing you’d like to load:
(load this [:person/by-id 22] Person)
Such a load will send a query to the server that is a join on that ident. The helper macro to handle those
is defquery-entity
, and is dispatched by the table name:
(defquery-entity :person/by-id
"optional docstring"
(value [env id params]
{:db/id id ...}))
Note that this call gets an id
in addition to parameters (in the above call, id
would be 22). Again, the full query
is in env
so you can process the data driven response properly.
9.2.3. A Loading Example
This example shows two very common ways that data is loaded (or reloaded) in an application.
On startup, the following loads are run via the started callback:
(fc/make-fulcro-client
{:client-did-mount
(fn [app]
(df/load app :load-samples/people Person {:target [:lists/by-type :enemies :people]
:params {:kind :enemy}})
(df/load app :load-samples/people Person {:target [:lists/by-type :friends :people]
:params {:kind :friend}}))})
In this demo we’re loading lists of people (thus the keyword’s name). There are two kinds of people available from the
server: friends and enemies. We use the :params
config parameter to add a map of parameters to the network request
to specify what we want. The :target
key is the path to the (normalized) entity’s property under which the response
should be stored once received.
Since all components should be normalized, this target path is almost always 2 (loading a whole component into a table) or 3 elements (loading one or more things into a property of an existing component).
The server-side code for handling these queries uses a global table on the server, and is:
(def all-users [{:db/id 1 :person/name "A" :kind :friend}
{:db/id 2 :person/name "B" :kind :friend}
{:db/id 3 :person/name "C" :kind :enemy}
{:db/id 4 :person/name "D" :kind :friend}])
; incoming param (kind) is destructured from third arg
(server/defquery-root :load-samples/people
(value [env {:keys [kind]}]
(let [result (->> all-users
(filter (fn [p] (= kind (:kind p))))
(mapv (fn [p] (-> p
(select-keys [:db/id :person/name])
(assoc :person/age-ms (now))))))]
result)))
Once started, any given person can be refreshed at any time. Timeouts, user event triggers, etc. There are two ways
to refresh a given entity in a database. In the code of Person
below you’ll see that we are using load
again, but
this time with an ident
:
(df/load this (prim/ident this props) Person)
All of the parameters of this call can be easily derived when calling it from the component needing refreshed, so
there is a helper function called refresh!
that makes this a bit shorter to type:
(df/refresh! this)
The server-side implementation of refresh for person looks like this:
(server/defquery-entity :load-samples.person/by-id
(value [{:keys [] :as env} id p]
(let [person (first (filter #(= id (:db/id %)) all-users))]
(assoc person :person/age-ms (now))))) ; include a timestamp so we can see refreshes change something
(ns book.demos.loading-data-basics
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-users [{:db/id 1 :person/name "A" :kind :friend}
{:db/id 2 :person/name "B" :kind :friend}
{:db/id 3 :person/name "C" :kind :enemy}
{:db/id 4 :person/name "D" :kind :friend}])
(server/defquery-entity :load-samples.person/by-id
(value [{:keys [] :as env} id p]
(let [person (first (filter #(= id (:db/id %)) all-users))]
(assoc person :person/age-ms (now)))))
(server/defquery-root :load-samples/people
(value [env {:keys [kind]}]
(let [result (->> all-users
(filter (fn [p] (= kind (:kind p))))
(mapv (fn [p] (-> p
(select-keys [:db/id :person/name])
(assoc :person/age-ms (now))))))]
result)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defsc Person [this {:keys [db/id person/name person/age-ms] :as props}]
{:query [:db/id :person/name :person/age-ms :ui/fetch-state]
:ident (fn [] [:load-samples.person/by-id id])}
(dom/li
(str name " (last queried at " age-ms ")")
(dom/button {:onClick (fn []
; Load relative to an ident (of this component).
; This will refresh the entity in the db. The helper function
; (df/refresh! this) is identical to this, but shorter to write.
(df/load this (prim/ident this props) Person))} "Update")))
(def ui-person (prim/factory Person {:keyfn :db/id}))
(defsc People [this {:keys [people]}]
{:initial-state (fn [{:keys [kind]}] {:people/kind kind})
:query [:people/kind {:people (prim/get-query Person)}]
:ident [:lists/by-type :people/kind]}
(dom/ul
; we're loading a whole list. To sense/show a loading marker the :ui/fetch-state has to be queried in Person.
; Note the whole list is what we're loading, so the render lambda is a map over all of the incoming people.
(df/lazily-loaded #(map ui-person %) people)))
(def ui-people (prim/factory People {:keyfn :people/kind}))
(defsc Root [this {:keys [friends enemies]}]
{:initial-state (fn [{:keys [kind]}] {:friends (prim/get-initial-state People {:kind :friends})
:enemies (prim/get-initial-state People {:kind :enemies})})
:query [{:enemies (prim/get-query People)} {:friends (prim/get-query People)}]}
(dom/div
(dom/h4 "Friends")
(ui-people friends)
(dom/h4 "Enemies")
(ui-people enemies)))
(defn initialize
"To be used in :started-callback to pre-load things."
[app]
; This is a sample of loading a list of people into a given target, including
; use of params. The generated network query will result in params
; appearing in the server-side query, and :people will be the dispatch
; key. The subquery will also be available (from Person). See the server code above.
(df/load app :load-samples/people Person {:target [:lists/by-type :enemies :people]
:params {:kind :enemy}})
(df/load app :load-samples/people Person {:target [:lists/by-type :friends :people]
:params {:kind :friend}}))
9.2.4. Targeting Loads
A short while ago we noted that loads are targeted at the root of your graph, and that this wasn’t always what you wanted. After all, your graph database will always have other UI stuff. For example there might be the concept of a "current screen" (join from root) that might currently point to a "friends screen", which in turn is where you want to load that list of friends:
{ :current-screen [:screens/by-type :friends]
...
:screens/by-type { :friends { :friends/list [] ... }}}
If you’ve followed our earlier recommendations then your application’s UI is normalized and any given
spot in your graph is really just an entry in a top-level table. Thus, the path to the desired location
of our friends is usually just 3 deep. In this case: [:screens/by-type :friends :friends/list]
.
If we were to merge the earlier load into that database we could get what we want by just moving graph edges (where a to-one edge is an ident, and a to-many edge is a vector of idents):
{ :all-friends [ [:person/by-id 1] [:person/by-id 2] ...] ; (1) MOVE this to (2)
:person/by-id { 1 { :db/id 1 ...}
2 { :db/id 2 ...}
:current-screen [:screens/by-type :friends]
:screens/by-type { :friends { :friends/list [] ... }}} ; (2) where friends *should* be
Since the graph database is just nodes and edges, there really aren’t many more operations to worry about! You’ve essentially got to normalize a tree, merge normalized data, and move graph edges.
The load API supports several kinds of graph edge targeting to allow you to put data where you want it in your graph.
Warning
|
Technically data is always loaded into the root, then relocated. So, be careful not to name your top-level edge after something that already exists there! |
Simple Targeting
The simplest targeting is to just relocate an edge from the root to somewhere else. The load
function can do
that with a simple additional parameter:
(df/load comp :all-friends Person {:target [:screens/by-type :friends :friends/list]})
So, the server will still see the well-known query for :all-friends
, but your local UI graph will end up seeing the
results in the list on the friends screen.
Advanced Targets
You can also ask the target parameter to modify to-many edges instead of replacing them. For example, say you were loading one new person, and wanted to append it to the current list of friends:
(df/load comp :best-friend Person {:target (df/append-to [:screens/by-type :friends :friends/list])})
The append-to
function in the data-fetch
namespace augments the target to indicate that the incoming items
(which will be normalized) should have their idents appended onto the to-many edge found at the given location.
Note
|
append-to will not create duplicates.
|
The other available helper is prepend-to
. Using a plain target is equivalent to full replacement.
You may also ask the targeting system to place the result(s) at more than one place in your graph. You do this
with the multiple-targets
wrapper:
(df/load comp :best-friend Person {:target (df/multiple-targets
(df/append-to [:screens/by-type :friends :friends/list])})
[:other-spot]
(df/prepend-to [:screens/by-type :summary :friends]))})
Note that multiple-targets
can use plain target vectors (replacement) or any of the special wrappers.
An Example of Targeting
In the example below, you’ll see we’ve set up two panes in a panel, and each pane can render a person.
Initially, there are no people loaded (none in initial state). The load buttons in the root component let you see entity loads targeted at one or both of them.
The main loader looks like this (and is called from root):
(defn load-random-person [component where]
(let [load-target (case where
(:left :right) [:pane/by-id where :pane/person]
:both (df/multiple-targets
[:pane/by-id :left :pane/person]
[:pane/by-id :right :pane/person]))
person-ident [:person/by-id (rand-int 100)]]
(df/load component person-ident Person {:target load-target})))
The server is rather simple. It just makes up a person for any given ID:
(server/defquery-entity :person/by-id
(value [env id params]
{:db/id id :person/name (str "Person" id)}))
(ns book.demos.loading-data-targeting-entities
(:require
[fulcro.client.mutations :as m]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.server :as server]
[fulcro.client.data-fetch :as df]
[fulcro.client.primitives :as prim]))
;; SERVER
(server/defquery-entity ::person-by-id
(value [env id params]
{:db/id id :person/name (str "Person " id)}))
;; CLIENT
(defsc Person [this {:keys [person/name]}]
{:query [:db/id :person/name]
:ident [::person-by-id :db/id]}
(dom/div (str "Hi, I'm " name)))
(def ui-person (prim/factory Person {:keyfn :db/id}))
(defsc Pane [this {:keys [db/id pane/person] :as props}]
{:query [:db/id {:pane/person (prim/get-query Person)}]
:initial-state (fn [{:keys [id]}] {:db/id id :pane/person nil})
:ident [:pane/by-id :db/id]}
(dom/div
(dom/h4 (str "Pane " id))
(if person
(ui-person person)
(dom/div "No person loaded..."))))
(def ui-pane (prim/factory Pane {:keyfn :db/id}))
(defsc Panel [this {:keys [panel/left-pane panel/right-pane]}]
{:query [{:panel/left-pane (prim/get-query Pane)}
{:panel/right-pane (prim/get-query Pane)}]
:initial-state (fn [params] {:panel/left-pane (prim/get-initial-state Pane {:id :left})
:panel/right-pane (prim/get-initial-state Pane {:id :right})})
:ident (fn [] [:PANEL :only-one])}
(dom/div
(ui-pane left-pane)
(ui-pane right-pane)))
(def ui-panel (prim/factory Panel {:keyfn :db/id}))
(defn load-random-person [component where]
(let [load-target (case where
(:left :right) [:pane/by-id where :pane/person]
:both (df/multiple-targets
[:pane/by-id :left :pane/person]
[:pane/by-id :right :pane/person]))
person-ident [::person-by-id (rand-int 100)]]
(df/load component person-ident Person {:target load-target :marker false})))
(defsc Root [this {:keys [root/panel] :as props}]
{:query [{:root/panel (prim/get-query Panel)}]
:initial-state (fn [params] {:root/panel (prim/get-initial-state Panel {})})}
(dom/div
(ui-panel panel)
(dom/button {:onClick #(load-random-person this :left)} "Load into Left")
(dom/button {:onClick #(load-random-person this :right)} "Load into Right")
(dom/button {:onClick #(load-random-person this :both)} "Load into Both")))
9.2.5. Refreshing the UI After Load
The component that issued the load will automatically be refreshed when the load completes. You may use the data-driven
nature of the app to request other components refresh as well. The :refresh
option tells the system what data has
changed due to the load. It causes all live components that have queried those things to refresh.
You can supply keywords and/or idents:
; load my best friend, and re-render every live component that queried for the name of a person
(df/load comp :best-friend Person {:refresh [:person/name]})
9.2.6. Other Load Options
Loads allow a number of additional arguments. Many of these are discussed in more detail in later sections:
:post-mutation and :post-mutation-params
|
A mutation to run once the load is complete (local data transform only). |
:remote
|
The name of the remote you want to load from. |
:refresh
|
A vector of keywords and idents. Any component that queries these will be re-rendered once the load completes. |
:parallel
|
Boolean. Defaults to false. When true, bypasses the sequential network queue. Allows multiple loads to run at once, but causes you to lose any guarantees about ordering since the server might complete them out-of-order. |
:fallback
|
A mutation to run if the server throws an error during the load. |
:focus
|
A subquery to filter from your component query. Covered in Incremental Loading. |
:without
|
A set of keywords to elide from the query. Covered in Incremental Loading. |
:update-query
|
A function that will take the component original query and should return a query that will be sent to the remote |
:params
|
A map. If supplied the params will appear as the params of the query on the server. |
:initialize bool|map
|
If true, uses component’s initial state as a basis for incoming merge. If a map, uses the map as the basis for incoming merge. |
Parallel vs. Sequential Loading
The :parallel
option of load
bypasses the normal network sequential queue. Below is a simple live
example that shows off the difference between regular loads and those marked parallel. In
order to see the effect increase your server latency to something like 5 seconds.
Normally, Fulcro runs separate event-based loads in sequence, ensuring that your reasoning can be synchronous;
however, for loads that might take some time to complete, and for which you can guarantee order of
completion doesn’t matter, you can specify an option on load (:parallel true
) that allows them to proceed in parallel.
Pressing the sequential buttons on all three (in any order) will take at least 3x your server latency to complete from the time you click the first one (since each will run after the other is complete). If you rapidly click the parallel buttons, then the loads will not be sequenced and you will see them all complete in roughly 1x your server latency (from the time you click the last one).
(ns book.demos.parallel-vs-sequential-loading
(:require
[fulcro.server :refer [defquery-root defquery-entity defmutation]]
[fulcro.client.data-fetch :as df]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defquery-entity :background.child/by-id
(value [{:keys [parser query] :as env} id params]
(when (= query [:background/long-query])
(parser env query))))
(defquery-root :background/long-query
(value [{:keys [ast query] :as env} params] 42))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn render-result [v] (dom/span v))
(defsc Child [this {:keys [name name background/long-query]}]
{:query [:id :name :background/long-query]
:ident [:background.child/by-id :id]}
(dom/div {:style {:display "inline" :float "left" :width "200px"}}
(dom/button {:onClick #(df/load-field this :background/long-query :parallel true)} "Load stuff parallel")
(dom/button {:onClick #(df/load-field this :background/long-query)} "Load stuff sequential")
(dom/div
name
(df/lazily-loaded render-result long-query))))
(def ui-child (prim/factory Child {:keyfn :id}))
(defsc Root [this {:keys [children] :as props}]
; cheating a little...raw props used for child, instead of embedding them there.
{:initial-state (fn [params] {:children [{:id 1 :name "A"} {:id 2 :name "B"} {:id 3 :name "C"}]})
:query [{:children (prim/get-query Child)}]}
(dom/div
(mapv ui-child children)
(dom/br {:style {:clear "both"}}) (dom/br)))
Initializing Loaded Items
Warning
|
This support is not complete yet (in version 2.0.0). It should be considered ALPHA and subject to change. |
On occasion you may find that your entities have :ui/???
attributes that you would like to
default to something on a loaded entity. This is the purpose of the :initialize
option to load.
If it is set to true
, then load
will call get-initial-state
on the component of the
load, and merge the return value from the server into that before merging it to app state.
Alternatively, you can pass :initialize
a map, and that will be used as the target for
the server response merge before normalizing the result into app state.
Note
|
The value of :initialize must either be true or a map that matches the correct
shape of the component’s sub-tree of data. It must not be a normalized database fragment.
|
The steps are:
-
Send the request
-
Merge the response into the basis defined by
:initialize
. -
Merge the result of (2) into the database using the component’s query (auto-normalize)
Post Mutations – Morphing Loaded Data
The targeting system that we discussed in the prior section is great for cases where your data-driven query gets you exactly what you need for the UI. In fact, since you can process the query on the server it is entirely possible that load with targeting is all you’ll ever need; however, from a practical perspective it may turn out that you’ve got a server that can easily understand certain shapes of data-driven queries, but not others.
For example, say you were pulling a list of items from a database. It might be trivial to pull that graph of data from the server from the perspective of a list of items, but let’s say that each item had a category. Perhaps you’d like to group the items by category in the UI.
The data-driven way to handle that is to make the server understand the UI query that has them grouped by category; however, that implies that you might end up embedding code on your server to handle a way of looking at data that is really specific to one kind of UI. That tends to push us back toward a proliferation of code on the server that was a nightmare in REST.
Another way of handling this is to accept the fact that our data-driven queries have some natural limits: If the database on the server can easily produce the graph, then we should let it do so from the data-driven query; however, in some cases it may make more sense to let the UI morph the incoming data into a shape that makes more sense to that UI.
We all understand doing these kinds of transforms. It’s just data manipulation. So, you may find this has some distinct advantages:
-
Simple query to the server (only have to write one query handler) that is a natural fit for the database there.
-
Simple layout in resulting UI database (normalized into tables and a graph)
-
Straightforward data transform into what we want to show
Using defsc
For Server Queries
It is perfectly legal to use defsc
to define a graph query (and normalization) for something like this that doesn’t exactly
exist on your UI. This can be quite useful in the presence of post mutations that can re-shape the data.
Simply code your (nested) queries using defsc
, and skip writing the body:
(defsc ItemQuery [this props]
{:query [:db/id :item/name {:item/category (prim/get-query CategoryQuery)}]
:ident [:items/by-id :db/id]})
(df/load this :all-items ItemQuery {:post-mutation `group-items})
Note
|
We know that the name defsc seems a bit of a misnomer for this, so feel free to create an alias for it.
|
Live Demo of Post Mutations
The example below simulates post mutations to show how a load of simple data could be morphed into something that the UI wants to display. In this case we’re pretending that the load has brought in a number of items (as a collection) and normalized it, but we’d prefer to show the items organized by category..
You can interact with it and view the database to A/B compare the before/after state.
(ns book.server.morphing-example
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[book.macros :refer [defexample]]
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.mutations :refer [defmutation]]
[fulcro.client :as fc]))
(defsc CategoryQuery [this props]
{:query [:db/id :category/name]
:ident [:categories/by-id :db/id]})
(defsc ItemQuery [this props]
{:query [:db/id :item/name {:item/category (prim/get-query CategoryQuery)}]
:ident [:items/by-id :db/id]})
(def sample-server-response
{:all-items [{:db/id 5 :item/name "item-42" :item/category {:db/id 1 :category/name "A"}}
{:db/id 6 :item/name "item-92" :item/category {:db/id 1 :category/name "A"}}
{:db/id 7 :item/name "item-32" :item/category {:db/id 1 :category/name "A"}}
{:db/id 8 :item/name "item-52" :item/category {:db/id 2 :category/name "B"}}]})
(def component-query [{:all-items (prim/get-query ItemQuery)}])
(def hand-written-query [{:all-items [:db/id :item/name
{:item/category [:db/id :category/name]}]}])
(defsc ToolbarItem [this {:keys [item/name]}]
{:query [:db/id :item/name]
:ident [:items/by-id :db/id]}
(dom/li name))
(def ui-toolbar-item (prim/factory ToolbarItem {:keyfn :db/id}))
(defsc ToolbarCategory [this {:keys [category/name category/items]}]
{:query [:db/id :category/name {:category/items (prim/get-query ToolbarItem)}]
:ident [:categories/by-id :db/id]}
(dom/li
name
(dom/ul
(map ui-toolbar-item items))))
(def ui-toolbar-category (prim/factory ToolbarCategory {:keyfn :db/id}))
(defmutation group-items-reset [params]
(action [{:keys [state]}]
(reset! state (prim/tree->db component-query sample-server-response true))))
(defn add-to-category
"Returns a new db with the given item added into that item's category."
[db item]
(let [category-ident (:item/category item)
item-location (conj category-ident :category/items)]
(update-in db item-location (fnil conj []) (prim/ident ItemQuery item))))
(defn group-items*
"Returns a new db with all of the items sorted by name and grouped into their categories."
[db]
(let [sorted-items (->> db :items/by-id vals (sort-by :item/name))
category-ids (-> db (:categories/by-id) keys)
clear-items (fn [db id] (assoc-in db [:categories/by-id id :category/items] []))
db (reduce clear-items db category-ids)
db (reduce add-to-category db sorted-items)
all-categories (->> db :categories/by-id vals (mapv #(prim/ident CategoryQuery %)))]
(assoc db :toolbar/categories all-categories)))
(defmutation ^:intern group-items [params]
(action [{:keys [state]}]
(swap! state group-items*)))
(defsc Toolbar [this {:keys [toolbar/categories]}]
{:query [{:toolbar/categories (prim/get-query ToolbarCategory)}]}
(dom/div
(dom/button {:onClick #(prim/transact! this `[(group-items {})])} "Trigger Post Mutation")
(dom/button {:onClick #(prim/transact! this `[(group-items-reset {})])} "Reset")
(dom/ul
(map ui-toolbar-category categories))))
(defexample "Morphing Data" Toolbar "morphing-example" :initial-state (atom (prim/tree->db component-query sample-server-response true)))
9.2.7. Pre-Merge
Available from Fulcro 2.8+.
Pre merge offers a hook to manipulate data entering your Fulcro app at component level.
During the lifetime of a Fulcro application data will enter the system, the most common are:
-
During app initialization
-
New data loaded from remote
-
Data add via mutations
In the first case the data comes from the component initial state or user provided initial state, these usually include both domain and ui initialized data.
In the second case the data comes from a remote and contain domain data, but not UI data since
it’s not a concern from the remote to know about then, but the UI might still need to
do some type of initialization when creating data for new entities in the local database. Before
pre hooks the only way to populate these UI specific data was to use a :post-mutation
with
the load call to manually populate it. This solution is powerful because it allows any changes
to be done at any part of the DB, but using it offers some challanges:
-
Component specific data initialization is separated from the component itself, reducing local reasoning
-
Composition gets affected, every parent component has to write new logic to manually compose the initialization tree
-
Initialization of to-many lists requires tedios code to manipulate tables in the app db
To illustrate these issues we are going to write a small app that has some count down buttons, the idea is that each counter gets clicked until it reaches zero, the important detail will be how we can handle the initial counter value, first using post-mutations and then pre-merge.
(defsc Countdown [this {::keys [counter-label]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label :ui/count]}
(dom/div
(dom/h4 counter-label)
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
The important part is the :ui/count
must start with some value, we can arbitrarily
choose 5
as a start, whatever it is we need to initialize when the data for this
enter the system.
We are going to use this post-mutation to initialize this ui property, at this point the example is loading a single counter from the remote:
(m/defmutation initialize-counter [{::keys [counter-id]}]
(action [{:keys [state]}]
(swap! state update-in [::counter-id counter-id] #(merge {:ui/count 5} %))))
And this is the call to load:
(df/load this [::counter-id 1] Countdown
{:target [:counter]
:post-mutation `initialize-counter
:post-mutation-params {::counter-id 1}})
With all hook up here is the demo:
(ns book.demos.pre-merge.post-mutation-countdown
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}])
(server/defquery-entity ::counter-id
(value [_ id _]
(first (filter #(= id (::counter-id %)) all-counters))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(m/defmutation initialize-counter [{::keys [counter-id]}]
(action [{:keys [state]}]
(swap! state update-in [::counter-id counter-id] #(merge {:ui/count 5} %))))
(defsc Countdown [this {::keys [counter-label]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label :ui/count]}
(dom/div
(dom/h4 counter-label)
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {:keys [counter]}]
{:initial-state (fn [_] {})
:query [{:counter (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq counter)
(ui-countdown counter)
(dom/button {:onClick #(df/load this [::counter-id 1] Countdown
{:target [:counter]
:post-mutation `initialize-counter
:post-mutation-params {::counter-id 1}})}
"Load one counter"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
Now let’s see in case of a to-many, this is what our new mutation looks like:
(m/defmutation initialize-counters [_]
(action [{:keys [state]}]
(swap! state
(fn [state]
(reduce
(fn [state ref]
(update-in state ref #(merge {:ui/count 5} %)))
state
(get state ::all-counters))))))
(ns book.demos.pre-merge.post-mutation-countdown-many
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}
{::counter-id 2 ::counter-label "B"}
{::counter-id 3 ::counter-label "C"}
{::counter-id 4 ::counter-label "D"}])
(server/defquery-root ::all-counters
(value [_ _]
all-counters))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(m/defmutation initialize-counters [_]
(action [{:keys [state]}]
(swap! state
(fn [state]
(reduce
(fn [state ref]
(update-in state ref #(merge {:ui/count 5} %)))
state
(get state ::all-counters))))))
(defsc Countdown [this {::keys [counter-label]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label :ui/count]}
(dom/div
(dom/h4 counter-label)
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {})
:query [{::all-counters (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq all-counters)
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))
(dom/button {:onClick #(df/load this ::all-counters Countdown
{:post-mutation `initialize-counters})}
"Load many counters"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
As you can see, adding a single depth to the query can yield a fair amount of extra code to hook things up, and as you app grows each join depth will add more post-mutation code to compose over. Solving this composition issue is the main motivation behind pre-merge.
Working with pre-merge
Pre merge addresses these issues by providing a component level hook that allows the data entering logic to be colocated with the rest of the component.
It works by hooking in the new data normalization process, if you are not familiar with how the normalization works I suggest you check it there and come back after.
You can setup the pre-merge by adding the :pre-merge
key to your component configuration,
this is how we can setup our Countdown
using :pre-merge
:
(defsc Countdown [this {::keys [counter-label]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
; (1)
(merge
{:ui/count 5} ; (2)
current-normalized ; (3)
data-tree))} ; (4)
(dom/div
(dom/h4 counter-label)
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
-
This merge setup works for most of the cases
-
We start the merge with a map containing defaults
-
Then we merge with the data that is already there for this component (may be
nil
if component doesn’t has previous data) -
Finally merge in the data tree, since it’s the last it will win over any of the previous
The :pre-merge
lambda receives a single map containing the following keys:
-
:data-tree
- the new data tree entering the database -
:current-normalized
- the current entity value (if any), comes normalized from the db (as in(get-in state ref)
) -
:state-map
- the current app state map (you may use to lookup refs). -
:query
- the query used to do this request (user may have modified the original using:focus
,:without
or:update-query
during the load call
With this setup we can get rid of the post-mutation
. The to-one case example with pre-merge:
(ns book.demos.pre-merge.countdown
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}])
(server/defquery-entity ::counter-id
(value [_ id _]
(first (filter #(= id (::counter-id %)) all-counters))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defsc Countdown [this {::keys [counter-label]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
{:ui/count 5}
current-normalized
data-tree))}
(dom/div
(dom/h4 counter-label)
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {:keys [counter]}]
{:initial-state (fn [_] {})
:query [{:counter (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq counter)
(ui-countdown counter)
(dom/button {:onClick #(df/load this [::counter-id 1] Countdown {:target [:counter]})}
"Load one counter"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
For the to-many case it’s the same, no need for post-mutation.
(ns book.demos.pre-merge.countdown-many
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}
{::counter-id 2 ::counter-label "B"}
{::counter-id 3 ::counter-label "C"}
{::counter-id 4 ::counter-label "D"}])
(server/defquery-root ::all-counters
(value [_ _]
all-counters))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defsc Countdown [this {::keys [counter-label]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
{:ui/count 5}
current-normalized
data-tree))}
(dom/div
(dom/h4 counter-label)
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {})
:query [{::all-counters (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq all-counters)
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))
(dom/button {:onClick #(df/load this ::all-counters Countdown)}
"Load many counters"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
To spicy this example, let’s make so the initial counter number can be defined by the server, but we want to keep this is a separated attribute that doesn’t going to be displayed to the right of the counter label.
(ns book.demos.pre-merge.countdown-with-initial
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}
{::counter-id 2 ::counter-label "B" ::counter-initial 10}
{::counter-id 3 ::counter-label "C" ::counter-initial 2}
{::counter-id 4 ::counter-label "D"}])
(server/defquery-root ::all-counters
(value [_ _]
all-counters))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-count 5)
(defsc Countdown [this {::keys [counter-label counter-initial]
:ui/keys [count]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label ::counter-initial :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
; (1)
{:ui/count (or (prim/nilify-not-found (::counter-initial data-tree)) default-count)}
current-normalized
data-tree))}
(dom/div
(dom/h4 (str counter-label " [" (or counter-initial default-count) "]"))
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count))))))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {})
:query [{::all-counters (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq all-counters)
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))
(dom/button {:onClick #(df/load this ::all-counters Countdown)}
"Load many counters"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
-
During the normalization step of a load Fulcro will put
::prim/not-found
value on keys that were not delivered by the server, this is used to do asweep-merge
later, theprim/nilify-not-found
will return the same input value (likeidentity
) unless it’s a::prim/not-found
, in which case it returnsnil
, this way theor
works in both cases.
Next, we are going to extract the counter button into its own component, this serves to illustrate how you can hook up pure UI components in remote based components.
(defsc CountdownButton [this {:ui/keys [count]}]
{:ident [:ui/id :ui/id]
:query [:ui/id :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
; (1)
{:ui/id (random-uuid)
:ui/count default-count}
current-normalized
data-tree))}
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count)))))
(defsc Countdown [this {::keys [counter-label counter-initial]
:ui/keys [counter]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label ::counter-initial
{:ui/counter (prim/get-query CountdownButton)}]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(let [initial (prim/nilify-not-found (::counter-initial data-tree))]
(merge
; (2)
{:ui/counter (cond-> {} initial (assoc :ui/count initial))}
current-normalized
data-tree)))}
(dom/div
(dom/h4 (str counter-label " [" (or counter-initial default-count) "]"))
(ui-countdown-button counter)))
-
For the UI element we can also set the initial id using pre-merge, and now we moved the default there
-
Note we pass a blank map to the
:ui/counter
, this will reach the pre-merge fromCountdownButton
as thedata-tree
part
Full demo:
(ns book.demos.pre-merge.countdown-extracted
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}
{::counter-id 2 ::counter-label "B" ::counter-initial 10}
{::counter-id 3 ::counter-label "C" ::counter-initial 2}
{::counter-id 4 ::counter-label "D"}])
(server/defquery-root ::all-counters
(value [_ _]
all-counters))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-count 5)
(defsc CountdownButton [this {:ui/keys [count]}]
{:ident [:ui/id :ui/id]
:query [:ui/id :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
; (1)
{:ui/id (random-uuid)
:ui/count default-count}
current-normalized
data-tree))}
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count)))))
(def ui-countdown-button (prim/factory CountdownButton {:keyfn ::counter-id}))
(defsc Countdown [this {::keys [counter-label counter-initial]
:ui/keys [counter]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label ::counter-initial
{:ui/counter (prim/get-query CountdownButton)}]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(let [initial (prim/nilify-not-found (::counter-initial data-tree))]
(merge
; (2)
{:ui/counter (cond-> {} initial (assoc :ui/count initial))}
current-normalized
data-tree)))}
(dom/div
(dom/h4 (str counter-label " [" (or counter-initial default-count) "]"))
(ui-countdown-button counter)))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {})
:query [{::all-counters (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq all-counters)
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))
(dom/button {:onClick #(df/load this ::all-counters Countdown)}
"Load many counters"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
Another interesting property about pre-merge is that it also runs during normalization of app initialization, this means you can provide the minimum amount of data as the initial state and let the pre-merge fill the UI needs, to illustrate let’s add some initial state to our demo:
(defsc Root [this _]
{:initial-state (fn [_] {::all-counters
[{::counter-id (prim/tempid)
::counter-label "X"}
{::counter-id (prim/tempid)
::counter-label "Y"}
{::counter-id (prim/tempid)
::counter-label "Z"
::counter-initial 9}]})
:query [{::all-counters (prim/get-query Countdown)}]}
...)
Pre merge will be applied on top of that running the pre-merge on the respective data parts. Full demo:
(ns book.demos.pre-merge.countdown-initial-state
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]
[fulcro.client.data-fetch :as df]
[fulcro.server :as server]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def all-counters
[{::counter-id 1 ::counter-label "A"}
{::counter-id 2 ::counter-label "B" ::counter-initial 10}
{::counter-id 3 ::counter-label "C" ::counter-initial 2}
{::counter-id 4 ::counter-label "D"}])
(server/defquery-root ::all-counters
(value [_ _]
all-counters))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-count 5)
(defsc CountdownButton [this {:ui/keys [count]}]
{:ident [:ui/id :ui/id]
:query [:ui/id :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
{:ui/id (random-uuid)
:ui/count default-count}
current-normalized
data-tree))}
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count)))))
(def ui-countdown-button (prim/factory CountdownButton {:keyfn ::counter-id}))
(defsc Countdown [this {::keys [counter-label counter-initial]
:ui/keys [counter]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label ::counter-initial
{:ui/counter (prim/get-query CountdownButton)}]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(let [initial (prim/nilify-not-found (::counter-initial data-tree))]
(merge
{:ui/counter (cond-> {} initial (assoc :ui/count initial))}
current-normalized
data-tree)))}
(dom/div
(dom/h4 (str counter-label " [" (or counter-initial default-count) "]"))
(ui-countdown-button counter)))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {::all-counters
[{::counter-id (prim/tempid)
::counter-label "X"}
{::counter-id (prim/tempid)
::counter-label "Y"}
{::counter-id (prim/tempid)
::counter-label "Z"
::counter-initial 9}]})
:query [{::all-counters (prim/get-query Countdown)}]}
(dom/div
(dom/h3 "Counters")
(if (seq all-counters)
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))
(dom/button {:onClick #(df/load this ::all-counters Countdown)}
"Load many counters"))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
Time to talk about mutations the third case, the mutations. Fulcro already offered some
ways to integrate new data given a component and some data using functions like prim/merge-component
.
This means we can leverage the pre-merge hooks also when adding new data on mutations, like so:
(m/defmutation create-countdown [countdown]
(action [{:keys [state ref]}]
(swap! state prim/merge-component Countdown countdown :append [::all-counters])
(swap! state update-in ref assoc :ui/new-countdown-label "")))
And add some UI to trigger this mutation:
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {::all-counters
[{::counter-id (prim/tempid)
::counter-label "X"}
{::counter-id (prim/tempid)
::counter-label "Y"}
{::counter-id (prim/tempid)
::counter-label "Z"
::counter-initial 9}]})
:query [{::all-counters (prim/get-query Countdown)}
:ui/new-countdown-label]}
(dom/div
(dom/h3 "Counters")
(dom/button {:onClick #(prim/transact! this [`(create-countdown ~{::counter-id (prim/tempid)
::counter-label "New"})])}
"Add counter")
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))))
Full demo:
(ns book.demos.pre-merge.countdown-mutation
(:require
[book.demos.util :refer [now]]
[fulcro.client.mutations :as m]
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc InitialAppState initial-state]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def default-count 5)
(defsc CountdownButton [this {:ui/keys [count]}]
{:ident [:ui/id :ui/id]
:query [:ui/id :ui/count]
:pre-merge (fn [{:keys [current-normalized data-tree]}]
(merge
{:ui/id (random-uuid)
:ui/count default-count}
current-normalized
data-tree))}
(let [done? (zero? count)]
(dom/button {:disabled done?
:onClick #(m/set-value! this :ui/count (dec count))}
(if done? "Done!" (str count)))))
(def ui-countdown-button (prim/factory CountdownButton {:keyfn ::counter-id}))
(defsc Countdown [this {::keys [counter-label counter-initial]
:ui/keys [counter]}]
{:ident [::counter-id ::counter-id]
:query [::counter-id ::counter-label ::counter-initial
{:ui/counter (prim/get-query CountdownButton)}]
:pre-merge (fn [{:keys [current-normalized data-tree] :as x}]
(let [initial (prim/nilify-not-found (::counter-initial data-tree))]
(merge
{:ui/counter (cond-> {} initial (assoc :ui/count initial))}
current-normalized
data-tree)))}
(dom/div
(dom/h4 (str counter-label " [" (or counter-initial default-count) "]"))
(ui-countdown-button counter)))
(def ui-countdown (prim/factory Countdown {:keyfn ::counter-id}))
(m/defmutation create-countdown [countdown]
(action [{:keys [state ref]}]
(swap! state prim/merge-component Countdown countdown :append [::all-counters])
(swap! state update-in ref assoc :ui/new-countdown-label "")))
(defsc Root [this {::keys [all-counters]}]
{:initial-state (fn [_] {::all-counters
[{::counter-id (prim/tempid)
::counter-label "X"}
{::counter-id (prim/tempid)
::counter-label "Y"}
{::counter-id (prim/tempid)
::counter-label "Z"
::counter-initial 9}]})
:query [{::all-counters (prim/get-query Countdown)}
:ui/new-countdown-label]}
(dom/div
(dom/h3 "Counters")
(dom/button {:onClick #(prim/transact! this [`(create-countdown ~{::counter-id (prim/tempid)
::counter-label "New"})])}
"Add counter")
(dom/div {:style {:display "flex" :alignItems "center" :justifyContent "space-between"}}
(mapv ui-countdown all-counters))))
(defn initialize
"To be used in :started-callback to pre-load things."
[app])
9.3. Augmenting the Ring Response
The Ring stack is supplied for you in the server code and most responses are simple EDN with the HTTP details taken care of for you; however, there are times when you need to modify something about the low-level response itself (such as adding a cookie).
If you’re using the Fulcro server tools (handle API request or the easy server) then you can add a fully general response transform to your EDN response as follows:
(fulcro.server/augment-response your-EDN-response
(fn [ring-response]
...
modified-ring-response))
For example, if you were using Ring and Ring Session, you could cause a session cookie to be generated, and user information to be stored in a server session store simply by returning this from a query on user:
(server/augment-response user (fn [resp] (assoc-in resp [:session :uid] real-uid)))
9.4. Full-Stack Mutations
You’ve already seen how to define local mutations on the client. In this chapter we’re going to go into the details of making mutations work against the server.
9.5. Remote Mutations
Mutations are already plain data, so Fulcro can pass them over the network as-is when the client invokes them. All you need to do to indicate that a given mutation should affect a given remote is add that remote to the mutation:
(defmutation add-friend [params]
(action ...) ; optimistic update of client state
(remote [env] true)) ; send the mutation to the remote known as :remote
; or using the multimethod directly:
(defmethod m/mutate `add-friend [env k params]
{:action (fn [] ...)
:remote true})
Now you can see why choosing the right real mutations and amount of composition on the client
can give you optimal server interaction: Anything that you run in transact!
itself can stand
alone as a remote mutation call in the transaction on the wire.
9.5.1. Optimistic by Default
The action
portion of a mutation is run immediately on the client. When there is also a server interaction
then the client-side operation is known as an optimistic update because by default we assume that the server
will succeed in replicating the action. This gives the user immediate feedback and the ability to proceed quickly
even in the presence of a slow network. We’ll discuss more on error handling shortly.
9.5.2. Mutations Revisited
A multi-method in Fulcro client (which is manipulated with defmutation
) can indicate that a given mutation
should be sent to any number of remotes. The default remote is named :remote
, but you can define new ones or even
rename the default one.
The technical structure of this looks like:
(require [fulcro.client.mutations :refer [mutate]])
(defmethod mutate 'some/mutation [env k params]
;; sends this mutation with the same `env`, `k`, and `params` arguments to the server
{:remote true
:action (fn[] ... )})
or preferably using the defmutation
macro:
(defmutation do-thing [params]
(action [env] ...)
(remote [env] true))
Basically, you use the name of the remote as an indicator of which remote you want to replicate the mutation to. From
there you either return true
(which means send the mutation as-is), or you may return an expression AST that represents
the mutation you’d like to send instead. The fulcro.client.primitives
namespace includes ast→query
and query-ast
for arbitrary generation of ASTs, but the original AST of the mutation is also available in the mutation’s environment.
Therefore, you can alter a mutation simply by altering and returning the ast
given in env
:
(defmutation do-thing [params]
(action [env] ...)
; send (do-thing {:x 1}) even if params are different than that on the client
(remote [{:keys [ast]}] (assoc ast :params {:x 1})) ; change the param list for the remote
; or using the with-params helper
(defmutation do-thing [params]
(action [env] ...)
; send (do-thing {:x 1}) even if params are different than that on the client
(remote [{:keys [ast]}] (m/with-params ast {:x 1})) ; change the param list for the remote
or even change which mutation the server sees:
(defmutation some-mutation [ params]
;; Changes the mutation from the incoming client-side some-mutation to server-mutation
(remote [{:keys [ast] :as env}] (assoc ast :key 'server-mutation :dispatch-key 'server-mutation)))
Warning
|
The state is available in remote , but the action will run first. This means that you should not
expect the "old" values in state when computing anything for the remote because the optimistic
update of the action will have already been applied! If you need to rely on data as it existed at the time of
transact! then you must pass it as a parameter to the mutation so that the original data is closed over for the
duration of the mutation processing.
|
9.5.3. Writing The Server Mutations
Server-side mutations in Fulcro are written the same way as on the client: A mutation returns a map with a key :action
and a function of no variables as a value. The mutation then does whatever server-side operation is indicated. The env
parameter on the server can contain anything you like (for example database access interfaces). You’ll see how
to configure that when you study how to build a server.
The recommended approach to writing a server mutation is to use the pre-written server-side parser and multimethods, which allow you
to mimic the same code structure of the client (there is a defmutation
in fulcro.server
for this).
If you’re using this approach (which is the default in the lein template and easy server), then here are the client-side and server-side implementations of the same mutation:
;; client
;; src/my_app/mutations.cljs
(ns my-app.mutations
(:require [fulcro.client.mutations :refer [defmutation]]))
(defmutation do-something [{:keys [p]}]
(action [{:keys [state]}]
(swap! state assoc :value p))
(remote [env] true))
;; server
;; src/my_app/mutations.clj
(ns my-app.mutations
(:require [fulcro.server :refer [defmutation]]))
(defmutation do-something [{:keys [p]}]
(action [{:keys [some-server-database]}]
... server code to make change ...))
or even a CLJC file:
(ns my-app.mutations
(:require
#?(:cljs [fulcro.client.mutations :refer [defmutation]]
:clj [fulcro.server :as server])))
#?(:cljs
(defmutation do-something [{:keys [p]}]
(action [{:keys [state]}]
(swap! state assoc :value p))
(remote [env] true))
:clj
(server/defmutation do-something [{:keys [p]}]
(action [{:keys [some-server-database]}]
... server code to make change ...)))
It is recommended that you use the same namespace on the client and server for mutations so it is easy to find them, but the macro allows you to namespace the symbol if you choose to use a different namespace on the server:
(ns my-app.server
(:require [fulcro.server :refer [defmutation]]))
(defmutation my-app.mutations/do-something [{:keys [p]}]
(action [{:keys [some-server-database]}]
... server code to make change ...))
The ideal structure for many people is to use a CLJC file, where they can co-locate the mutations in the same
source file. The only trick is that you have to make sure you use the correct defmutation
!
(ns my-app.api
(:require
#?(:clj [fulcro.server :as server]
:cljs [fulcro.client.mutations :refer [defmutation]])))
#?(:clj (server/defmutation do-thing [params]
(action [server-env] ...))
:cljs (defmutation do-thing [params]
(remote [env] true)))
Note
|
The server namespace includes support for defining a server mutation in the browser. This allows you to simulate a server in the browser instead of having to have a real server. |
Please see Building A Server for more information about setting up a server with injected components in the mutation environment.
The Server Multimethods for Mutations
The defmutation
macro on the server simply hits a multimethod. You can use defmethod
on fulcro.server/server-mutate
to define your mutations. The advantage of this is that you might want
to write your own wrappers, macros, or code around the low-level implementation.
In general, we recommend using defmutation
because it is better supported by IDEs (for navigation, docstrings, etc)
and eliminates some classes of syntactic error.
9.5.4. New item creation – Temporary IDs
Fulcro has a built in function prim/tempid
that will generate a unique temporary ID. This allows the normalization
and denormalization of the client side database to continue working while the server processes the new data and returns
the permanent identifier(s).
The idea is that these temporary IDs can be safely placed in your client database (and network queues), and will be automatically rewritten to their real ID when the server has managed to create the real persistent entity. Of course, since you have optimistic updates on the client it is important that things go in the correct sequence, and that queued operations for the server don’t get confused about what ID is correct!
Warning
|
Because mutation code can be called multiple times (at least once + once per each remote),
you should take care to not call fulcro.client.primitives/tempid inside your mutation.
Instead call it from your UI code that builds the mutation params.
|
Fulcro’s implementation works as follows:
-
Mutations always run in the order specified in the call to
transact!
-
Transmission of separate calls to
transact!
run in the order they were called. -
If remote mutations are separated in time, then they go through a sequential networking queue, and are processed in order.
-
As mutations complete on the server, they return tempid remappings. Those are applied to the application state and network queue before the next network operation (load or mutation) is sent.
This set of rules helps ensure that you can reason about your program, even in the presence of optimistic updates that could theoretically be somewhat ahead of the server.
For example, you could create an item, edit it, then delete it. The UI responds immediately, but the initial create might still be running on the server. This means the server has not even given it a real ID before you’re queuing up a request to delete it! With the above rules, it will just work! The network queue will have two backlogged operations (the edit and the delete), each with the same tempid that you currently know. When the create finally returns it will automatically rewrite all of the tempids in state and the network queues, then send the next operation. Thus, the edit will apply to the current server entity, as will the delete.
All the server code has to do is return a map with the special key :fulcro.client.primitives/tempids
(or the legacy :tempids
) whose value is a map of tempid→realid
whenever it sees an ID during persistence operations.
Here are the client-side and server-side implementations of the same mutation that create a new item:
;; client
;; src/my_app/mutations.cljs
(ns my-app.mutations
(:require [fulcro.client.mutations :refer [defmutation]]))
(defmutation new-item [{:keys [tempid text]}]
(action [{:keys [state]}]
(swap! state assoc-in [:item/by-id tempid] {:db/id tempid :item/text text}))
(remote [env] true))
;; server
;; src/my_app/mutations.clj
(ns my-app.mutations
(:require [fulcro.client.primitives :as prim]
[fulcro.server :refer [defmutation]]))
(defmutation new-item [{:keys [tempid text]}]
(action [{:keys [state]}]
(let [database-tempid (make-database-tempid)
database-id (add-item-to-database database {:db/id database-tempid :item/text text})]
{::prim/tempids {tempid database-id}})))
Other mutation return values are covered in Mutation Return Values.
9.5.5. Remote Reads After a Mutation
In earlier sections you learned that you can list properties with your mutation to indicate re-renders. These follow-on read keywords are always local re-render reads, and nothing more:
(prim/transact! this '[(app/f) :thing])
; Does mutation, and re-renders anything that has :thing in a query
Fulcro will automatically queue remote reads after writes when they are submitted in the same thread interaction:
(prim/transact! this `[(f)])
(df/load this :thing Thing)
(prim/transact! this `[(g)])
will result in two network interactions. The first will run [(f) (g)]
, and the second will be a load of :thing
. This
is a defined and official behavior.
Thus, one way you can implement a sequence of mutation followed by learning a result is to run a mutation and a load.
9.5.6. Pessimistic Transactions
There are scenarios where the above behavior is not what you want. In particular are cases like form submission where you might want to wait until the server completes, so that the user can be kept in the form until you’ve confirmed the server isn’t down or something.
Fulcro 2.0+ has support for pessimistic transactions that enable exactly this sort of behavior:
(prim/ptransact! this `[(a) (b)])
Will run `a’s action, `a’s remote, then `b’s action and `b’s remote. This can be combined with analysis of mutation return values to allow you to follow a remote operation with a UI one:
; assume submit-my-form blocks the UI, and leave-form-if-ok checks app state and moves on.
(prim/ptransact! this `[(submit-my-form) (leave-form-if-ok)])
Warning
|
Use caution when using mutations with conditional remote behavior.
ptransact! detects which mutations are remote by pre-running them (they are side-effect free)
against the app state as it is at the beginning of the transaction. If you have a mutation in the middle
that relies on the state modifications of a prior mutation in the same transaction in order to decide if
it is remote then it will be mis-detected.
|
9.5.7. Using Loads as Mutations
There is technically nothing wrong with issuing a load that has side-effects on the server (though one could argue that this is a bit sketchy from a design perspective). For example, one way to implement login is to issue a load with the user’s credentials:
(df/load :current-user User {:params {:username u :password p}})
The server query response can validate the credentials, set a cookie, and return the user info all at once! Your UI can
simply base rendering on the values in :current-user
. If they’re valid, you’re logged in.
If you remember from the General Operations section, you can modify the low-level Ring response by associating a lambda with your return value. If you were using Ring Session, then this might be how the query would be implemented on the server:
(ns my-api
(:require [fulcro.server :as server :refer [defmutation defquery-root]]))
(def bad-user {:db/id 0})
(defquery-root :current-user
(value [env params]
(if-let [{:keys [db/id] :as user} (authenticate params)] ; look up the user, return nil if bad
(server/augment-response user (fn [resp] (assoc-in resp [:session :uid] id)))
bad-user)))
9.5.8. Running Mutations in the Context of an Entity
If you submit a transaction and include an ident:
(transact! reconciler [:person/by-id 4] `[(f)])
then the transaction will run as-if it were executed in the context of any live component on the screen that currently has
that ident. This will make the ident available in the mutation’s environment as :ref
, and will focus refresh at that
component sub-tree(s). This can be useful when you have out-of-band data that causes you to want to run a
transaction outside of the UI using the reconciler.
9.5.9. Mutations that Trigger one or more Loads
Mutations generally need not expose their full-stack nature to the UI. For
example a next-page
mutation might trigger a load for the next page of
data or simply swap in some already cached data. The UI need not be
aware of the logic of this distinction (though typically the UI will
want to include loading markers, so it is common for there to be some
kind of knowledge about lazy loading).
Instead of coding complex "do I need to load that?" logic in the UI (where it most certainly does not belong) one should instead write mutations that abstract it into a nice concept.
Fulcro handles loads by placing entries in a load queue in the application database. Whenever a remote operation is triggered, the networking layer will check this queue and process it.
The fulcro.client.data-fetch/load
function simply runs a transact!
that does both (adds the load to the queue and triggers remote processing).
If you’d like to compose one or more loads into a mutation there are helper
functions that will help you do just that: df/load-action
and df/remote-load
.
The basic pattern is:
(defmutation next-page [params]
(action [{:keys [state] :as env}]
(swap! state ...) ; local optimistic db updates
(df/load-action env :prop Component {:remote :remote}) ; same basic args as `load`, except state atom instead of `this`
(df/load-action env :other Other) {:remote :other-remote}) ; as many as you need...
(remote [env]
(df/remote-load env))) ; notifies back-end that there is some loading to do
Important
|
The remote-load call need only be done for any one of the remotes you’re talking to. It merely tells the
back-end code to process remote requests (which will hit all remote queues). Thus, the parameters to the load-action
calls are where you actually specify which remote a given load should actually talk to.
|
9.6. Network Activity Indicators
Rendering is completely up to you, but Fulcro handles the networking (technically this is pluggable, but Fulcro still initiates the interactions). That means that you’re going to need some help when it comes to showing the user that something is happening on the network.
The first and easiest thing to use is a global activity marker that is automatically maintained at the root node of your client database.
9.6.1. Global loading activity marker
Fulcro will automatically maintain a global network activity marker at the top level of the app state under the
keyword :ui/loading-data
. This key will have a true
value when there are network loads awaiting a response from
the server, and will have a false
value when there are no network requests in process.
You can access this marker from any component that composes to root by including a link in your component’s query:
(defsc Item ... )
(def ui-item (prim/factory Item {:keyfn :id}))
(defsc List [this {:keys [title items ui/loading-data]}]
{:query [:id :title {:items (prim/get-query Item)} [:ui/loading-data '_]]}
...
(if (and loading-data (empty? items))
(dom/div "Loading...")
(dom/div
(dom/h1 title)
(map ui-item items))))
Because the global loading marker is at the top level of the application state, do not use the keyword as a follow-on read to mutations because it may unnecessarily trigger a re-render of the entire application.
In Fulcro 2.6.10+ there is a top-level key called :fulcro.client.network/status that holds a map from remotes to their
current general status (:inactive
or :active
). These are correct for all Fulcro remotes you’ve added to the
application, and can be used (via a link query) to watch for global activity. Since these are not tied to specific
requests, they have only minor advantages over the older :ui/loading-data
marker: They are correct for loads
and mutations, and are split out by remote.
9.6.2. Mutation Activity
Mutations can be passed off silently to the server. You may choose to block the UI if you have reason to believe there will be a problem, but there is usually no other reason to prevent the user from just continuing to use your application while the server processes the mutation. See Pessimistic Transactions for a method of controlling UI around the network activity of remote mutations.
Fulcro Incubator also has "pessimistic mutations", an extention of the mutation system that allows for more pessimistic general operation, and is easier to use.
9.6.3. Tracking Specific Loads
Loads are a different story. It is very often the case that you might have a number of loads running to populate different parts of your UI all at once. In this case it is quite useful to have some kind of load-specific marker that you can use to show that activity.
9.6.4. Normalized Load Markers
Load markers are normalized into a well-known table via an ID that you choose. You can use just about any value (except a boolean) for the id. This has the following behavior:
-
Load markers are placed in a top-level table (the var fulcro.client.data-fetch/marker-table holds the table name), using your marker value as their ID.
-
You can explicitly query for them using an ident join.
Warning
|
Do not use a boolean for a marker’s ID as this causes a legacy behavior that is undesirable
(but supported for backwards compatibility). The :marker false option on loads completely disables load markers for
a load, and is the default.
|
Working with Normalized Load Markers
The steps are rather simple: Include the :marker
parameter with load, and issue a query for the load marker on
the marker table. The table name for markers is stored in the data-fetch namespace in the var df/marker-table
.
(defsc SomeComponent [this props]
{:query [:data :prop2 :other [df/marker-table :marker-id]]} ; an ident in queries pulls in an entire entity
(let [marker (get props [df/marker-table :marker-id])]
...)))
...
(df/load this :key Item {:marker :marker-id})
The data fetch load marker will be missing if no loading is in progress. You can use the following functions to detect what state the load is in:
-
(df/ready? m)
- Returns true if the item is queued, but not yet active on the network -
(df/loading? m)
- Returns true if the item is active on the network
The marker will disappear from the table when network activity completes.
The rendering is up to you, but that is really all there is to it.
Marker IDs for Items That Have Many Instances
In this case you will need to generate a marker ID based on the entity ID, and then use a link query to pull the entire load marker table to see what is loading.
For example, you might define the marker IDs as (keyword "person-load-marker" (str person-id))
. Your person
component could then find its load marker like this:
(defn person-markerid [id] (keyword "person-load-marker" (str id)))
(defsc Person [this {:keys [db/id person/name] :as props}]
{:query [:db/id :person/name [df/marker-table '_]]
:ident [:person/by-id :db/id]}
(let [marker-id (person-markerid id)
all-markers (get props df/marker-table)
marker (get all-markers marker-id)]
...))
the load might look something like this:
(df/load this [:person/by-id 42] Person {:marker (person-markerid 42)})
The example below uses these techniques to name load markers.
Note
|
Open up the DB view and turn your server’s latency way up so you can watch the marker state. |
(ns book.demos.loading-indicators
(:require
[fulcro.client.dom :as dom]
[fulcro.client.data-fetch :as df]
[fulcro.logging :as log]
[fulcro.client :as fc]
[fulcro.server :as server]
[fulcro.client.primitives :as prim :refer [defsc]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(server/defquery-entity :lazy-load/ui
(value [env id params]
(case id
:panel {:child {:db/id 5 :child/label "Child"}}
:child {:items [{:db/id 1 :item/label "A"} {:db/id 2 :item/label "B"}]}
nil)))
(server/defquery-entity :lazy-load.items/by-id
(value [env id params]
(log/info "Item query for " id)
{:db/id id :item/label (str "Refreshed Label " (rand-int 100))}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def initial-state {:ui/react-key "abc"
:panel {}})
(defonce app (atom (fc/make-fulcro-client {:initial-state initial-state})))
(declare Item)
(defsc Item [this {:keys [db/id item/label] :as props}]
; query for the entire load marker table. use the lambda form of query for link queries
{:query (fn [] [:db/id :item/label [df/marker-table '_]])
:ident (fn [] [:lazy-load.items/by-id id])}
(let [marker-id (keyword "item-marker" (str id))
marker (get-in props [df/marker-table marker-id])]
(dom/div label
; If an item is rendered, and the fetch state is present, you can use helper functions from df namespace
; to provide augmented rendering.
(if (df/loading? marker)
(dom/span " (reloading...)")
; the `refresh!` function is a helper that can send an ident-based join query for a component.
; it is equivalent to `(load reconciler [:lazy-load.items/by-id id] Item)`, but finds the params
; using the component itself.
(dom/button {:onClick #(df/refresh! this {:marker marker-id})} "Refresh")))))
(def ui-item (prim/factory Item {:keyfn :db/id}))
(defsc Child [this {:keys [child/label items] :as props}]
{:query [:child/label {:items (prim/get-query Item)}]
:ident (fn [] [:lazy-load/ui :child])}
(let [render-list (fn [items] (map ui-item items))]
(dom/div
(dom/p "Child Label: " label)
(if (seq items)
(map ui-item items)
(dom/button {:onClick #(df/load-field this :items :marker :child-marker)} "Load Items")))))
(def ui-child (prim/factory Child {:keyfn :child/label}))
(defsc Panel [this {:keys [ui/loading-data child] :as props}]
{:initial-state (fn [params] {:child nil})
:query (fn [] [[:ui/loading-data '_] [df/marker-table '_] {:child (prim/get-query Child)}]) ; link querys require lambda
:ident (fn [] [:lazy-load/ui :panel])}
(let [markers (get props df/marker-table)
marker (get markers :child-marker)]
(dom/div
(dom/div {:style {:float "right" :display (if loading-data "block" "none")}} "GLOBAL LOADING")
(dom/div "This is the Panel")
(if marker
(dom/h4 "Loading child...")
(if child
(ui-child child)
(dom/button {:onClick #(df/load-field this :child :marker :child-marker)} "Load Child"))))))
(def ui-panel (prim/factory Panel))
; Note: Kinda hard to do idents/lazy loading right on root...so generally just have root render a div
; and then render a child that has the rest.
(defsc Root [this {:keys [panel] :as props}]
{:initial-state (fn [params] {:panel (prim/get-initial-state Panel nil)})
:query [{:panel (prim/get-query Panel)}]}
(dom/div (ui-panel panel)))
Note
|
You can also just use something like a component’s ident for the marker ID. This sounds confusing at first, but
just think of the marker ID as any opaque value that supports comparison under = .
|
9.7. Server Mutation Return Values
The server mutation is always allowed to return a value. Normally the only value that makes sense is the temporary ID remapping as previoiusly discussed in the main section of full-stack mutations. It is automatically processed by the client and causes the tempid to be rewritten everywhere in your state and network backlog:
; server-side
(defmutation new-thing [params]
(action [env]
...
{::prim/tempids {old-id new-id}}))
In some cases you’d like to return other details. However, remember that any data merge needs a tree of data and a query. With a mutation there is no query! As such, return values from mutations are ignored by default because there is no way to understand how to merge the result into your database. Remember we’re trying to eliminate the vast majority of callback hell and keep asynchrony out of the UI. The processing pipeline is always: update the database state, re-render the UI.
If you want to make use of the returned values from the server then you need to add something to remedy the lack of a query.
9.7.1. Using Mutation Joins
The solution might be obvious to you: include the query with the mutation! This is called a mutation join. The explicit syntax for a mutation join looks like this:
`[{(f) [:x]}]
but you never write them this way because a manual query doesn’t have ident information and cannot aid normalization. Instead, you write them just like you do when grabbing queries for anything else:
`[{(f) ~(prim/get-query Item)}]
Running a mutation with this notation allows you to return a value from the server’s mutation that exactly matches the graph
of the item, and it will be automatically normalized and merged into your database. So, if the Item
query ended up being
[:db/id :item/value]
then the server mutation could just return a simple map like so:
; server-side
(defmutation f [params]
(action [env]
{:db/id 1 :item/value 42})) ; ok to return one (a map) OR many (as a vector of maps)
Note
|
At the time of this writing the query must come from a UI component that has an ident. Thus, mutations joins essentially normalize things into a specific table in your database (determined by the ID(s) of the return value and the ident on the query’s component). Newer versions may relax this restriction. |
9.7.2. Mutation Joins: Simpler Notation
Writing transact!
using mutation joins is a bit visually noisy. It turns out there is a better way.
If you remember: the remote section of client mutations can return a boolean or an AST. Fulcro comes with helper functions that can
rewrite the AST of the mutation to modify the parameters or convert it to a mutation join! This can simplify how the
mutations look in the UI.
Here’s the difference. With the manual syntactic technique we just described your UI and client mutation would look something like this:
; in the UI
(transact! this `[{(f) ~(prim/get-query Item)}])
; in your mutation definition (client-side)
(defmutation f [params]
(action [env] ...)
(remote [env] true))
However, using the helpers you can instead write it like this:
(ns api
(:require [fulcro.client.mutations :refer [returning]]))
; in the UI
(transact! this `[(f)])
; in your mutation definition (client-side)
(defmutation f [params]
(action [env] ...)
(remote [{:keys [ast state]}] (returning ast state Item))
This makes the mutation join an artifact of the network interaction, and less for you to manually code (and read) in the UI.
The server-side code is the same for both: just return a proper graph value!
9.7.3. Targeting Return Values From Mutation Joins
If you use the AST with mutation joins, then Fulcro gives you an additional bonus: A helper you can use with your mutation to indicate that the given mutation return value should be further integrated into your app state. By default, you’re just returning an entity. The data gets normalized, but there is no further linkage into your app state.
You will sometimes want to pepper idents around your app state as a result of the return. You can add this kind of targeting through the AST in the remote (not available at the UI layer):
(defmutation f [params]
(action [env] ...)
(remote [{:keys [ast state]}]
(-> ast
(m/returning state Item)
(m/with-target [:path :to :field])))
Special targets are also supported:
(defmutation f [params]
(action [env] ...)
(remote [{:keys [ast state]}]
(-> ast
(m/returning state Item) ; Returns something of type Item, will merge/normalize
(m/with-target ; Place the ident pointing to the loaded item in app state at additional locations
(df/multiple-targets
(df/append-to [:table 3 :field])
(df/prepend-to [:table-2 4 :field]))))))
Demo of Mutation Joins
The demo below cover the basics of using mutation joins. It demonstrates:
-
Targeting Raw Values
If you don’t specify a component with
returning
, then your returned data can be targeted, but of course it won’t normalize. The error triggering mutation does this:(defmutation trigger-error "This mutation causes an unstructured error (just a map), but targets that value to the field `:error-message` on the component that invokes it." [_] (remote [{:keys [ast ref]}] (m/with-target ast (conj ref :error-message))))
and the server simply returns a raw value (map is recommended)
(server/defmutation trigger-error [_] (action [env] {:error "something bad"}))
-
Targeting Graph Results
In the demo The bulk of the work is done in the
create-entity
mutation. which is targeting to-many so we can demo more features.(defmutation create-entity "This mutation simply creates a new entity, but targets it to a specific location (in this case the `:child` field of the invoking component)." [{:keys [where?] :as params}] (remote [{:keys [ast ref state]}] (let [path-to-target (conj ref :children) ; replacement cannot succeed if there is nothing present...turn those into appends no-items? (empty? (get-in @state path-to-target)) where? (if (and no-items? (= :replace-first where?)) :append where?)] (cond-> (-> ast ; always set what kind of thing is coming back (m/returning state Entity) ; strip the where?...it is for local use only (not server) (m/with-params (dissoc params :where?))) ; Add the targeting...based on where? (= :append where?) (m/with-target (df/append-to path-to-target)) ; where to put it (= :prepend where?) (m/with-target (df/prepend-to path-to-target)) (= :replace-first where?) (m/with-target (df/replace-at (conj path-to-target 0)))))))
The server mutation just returns the entity (mixed with tempid remappings, if you need them).
(server/defmutation create-entity [{:keys [db/id]}] (action [env] (let [real-id (swap! ids inc)] {:db/id real-id :entity/label (str "Entity " real-id) :tempids {id real-id}})))
(ns book.demos.server-targeting-return-values-into-app-state
(:require
[fulcro.client.dom :as dom]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.server :as server]
[fulcro.client.data-fetch :as df]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ids (atom 1))
(server/defmutation trigger-error [_]
(action [env]
{:error "something bad"}))
(server/defmutation create-entity [{:keys [db/id]}]
(action [env]
(let [real-id (swap! ids inc)]
{:db/id real-id
:entity/label (str "Entity " real-id)
:tempids {id real-id}})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare Item Entity)
(defmutation trigger-error
"This mutation causes an unstructured error (just a map), but targets that value
to the field `:error-message` on the component that invokes it."
[_]
(remote [{:keys [ast ref]}]
(m/with-target ast (conj ref :error-message))))
(defmutation create-entity
"This mutation simply creates a new entity, but targets it to a specific location
(in this case the `:child` field of the invoking component)."
[{:keys [where?] :as params}]
(remote [{:keys [ast ref state]}]
(let [path-to-target (conj ref :children)
; replacement cannot succeed if there is nothing present...turn those into appends
no-items? (empty? (get-in @state path-to-target))
where? (if (and no-items? (= :replace-first where?))
:append
where?)]
(cond-> (-> ast
; always set what kind of thing is coming back
(m/returning state Entity)
; strip the where?...it is for local use only (not server)
(m/with-params (dissoc params :where?)))
; Add the targeting...based on where?
(= :append where?) (m/with-target (df/append-to path-to-target)) ; where to put it
(= :prepend where?) (m/with-target (df/prepend-to path-to-target))
(= :replace-first where?) (m/with-target (df/replace-at (conj path-to-target 0)))))))
(defsc Entity [this {:keys [entity/label]}]
{:ident [:entity/by-id :db/id]
:query [:db/id :entity/label]}
(dom/div label))
(def ui-entity (prim/factory Entity {:keyfn :db/id}))
(defsc Item [this {:keys [db/id error-message children]}]
{:query [:db/id :error-message {:children (prim/get-query Entity)}]
:initial-state {:db/id :param/id :children []}
:ident [:item/by-id :db/id]}
(dom/div {:style {:float "left"
:width "200px"
:margin "5px"
:border "1px solid black"}}
(dom/h4 (str "Item " id))
(when error-message
(dom/div "The generated error was: " (pr-str error-message)))
(dom/button {:onClick (fn [evt] (prim/transact! this `[(trigger-error {})]))} "Trigger Error")
(dom/h6 "Children")
(map ui-entity children)
(dom/button {:onClick (fn [evt] (prim/transact! this `[(create-entity {:where? :prepend :db/id ~(prim/tempid)})]))} "Prepend one!")
(dom/button {:onClick (fn [evt] (prim/transact! this `[(create-entity {:where? :append :db/id ~(prim/tempid)})]))} "Append one!")
(dom/button {:onClick (fn [evt] (prim/transact! this `[(create-entity {:where? :replace-first :db/id ~(prim/tempid)})]))} "Replace first one!")))
(def ui-item (prim/factory Item {:keyfn :db/id}))
(defsc Root [this {:keys [root/items]}]
{:query [{:root/items (prim/get-query Item)}]
:initial-state {:root/items [{:id 1} {:id 2} {:id 3}]}}
(dom/div
(mapv ui-item items)
(dom/br {:style {:clear "both"}})))
9.7.4. Augmenting the Merge
There is also a sledge-hammer approach to return values: plug into Fulcro’s merge routines. This is an advanced technique and is not recommended for most applications.
Fulcro gives a hook for a mutation-merge
function that you can install when you’re creating the application. If you
use a multi-method, then it will make it easier to co-locate your return value logic near the client-local mutation itself:
(defmulti return-merge (fn [state mutation-sym return-value] mutation-sym))
(defmethod return-merge :default [s m r] s)
(make-fulcro-client {:mutation-merge return-merge ...})
Now you should be able to write your return merging logic next to the mutation that it goes with. For example:
(defmethod m/mutate 'some-mutation [env k p] {:remote true })
(defmethod app/return-merge 'some-mutation [state k returnval] ...new-state...)
Note that the API is a bit different between the two: mutations get the app state atom in an environment, and you
swap!
on that atom to change state. The return merge function is in an already running swap!
during the state merge
of the networking layer. So, it is a function that takes the application state as a map and must
return a new state as a map.
This technique is fully general in terms of handling arbitrary return values, but is limited in that your only recourse is to merge the data into you app state. Of course, since your rendering is a pure function of app state this means you can, at the very least, visualize the result.
This works, but is not the recommended approach because it is very easy to make mistakes that affect your entire application.
Note
|
Mutation merge happens after server return values have been merged; however, it does happen before tempid remapping. Just work with the tempids, and they will be rewritten once your merge is complete. |
Live Example of a Mutation Merge
In the example below the displayed volume is coming from the server’s mutation return value. Use the server latency to convince yourself of this. Notice if you click too rapidly then the value doesn’t increase any faster than the server can respond (since it computes the new volume based on what the client sends).
The merge function is most easily dealt with as a multimethod so you can dispatch on the mutation symbol:
(defmulti merge-return-value (fn [state sym return-value] sym))
We’re going to return a map with :new-volume
in it from the server, so our merge can look like this:
(defmethod merge-return-value `crank-it-up
[state _ {:keys [new-volume]}]
(assoc-in state [:child/by-id 0 :volume] new-volume))
Our mutation asks a remote server to increase the volume. The client and server mutations are:
;; client-side
(m/defmutation crank-it-up [params]
(remote [env] true))
(server/defmutation crank-it-up [{:keys [value]}]
(action [env]
{:new-volume (inc value)}))
The remainder of the setup is just giving the merge handler function to the application at startup:
(fc/make-fulcro-client {:mutation-merge merge-return-value})
(ns book.demos.server-return-values-manually-merging
(:require
[fulcro.client.dom :as dom]
[fulcro.server :as server]
[fulcro.client.mutations :as m]
[fulcro.client.primitives :as prim :refer [defsc]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(server/defmutation crank-it-up [{:keys [value]}]
(action [env]
{:new-volume (inc value)}))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmulti merge-return-value (fn [state sym return-value] sym))
; Do all of the work on the server.
(m/defmutation crank-it-up [params]
(remote [env] true))
(defmethod merge-return-value `crank-it-up
[state _ {:keys [new-volume]}]
(assoc-in state [:child/by-id 0 :volume] new-volume))
(defsc Child [this {:keys [id volume]}]
{:initial-state (fn [params] {:id 0 :volume 5})
:query [:id :volume]
:ident [:child/by-id :id]}
(dom/div
(dom/p "Current volume: " volume)
(dom/button {:onClick #(prim/transact! this `[(crank-it-up ~{:value volume})])} "+")))
(def ui-child (prim/factory Child))
(defsc Root [this {:keys [child]}]
{:initial-state (fn [params] {:child (prim/get-initial-state Child {})})
:query [{:child (prim/get-query Child)}]}
(dom/div (ui-child child)))
9.8. Loads From Within Mutations
It is often the case that a load results from user interaction with the UI. But it is also the case that the load isn’t everything you want to do, or that you’d like to hide the load logic or base it on current state that the triggering component does not know.
9.8.1. Load Actions in Mutations
In reality load
and load-field
call prim/transact!
under the hood, targeting fulcro’s built-in fulcro/load
mutation, which is responsible for sending your request to the server.
There are similar functions: load-action
and load-field-action
that do not call prim/transact!
, but instead just push a load request into the load queue and can be used to
inside of one of your own client-side mutations.
Let’s look at an example of a standard load. Say you want to load a list of people from the server:
(require [fulcro.client.data-fetch :as df])
(defsc Person [this props]
{:query [:id :name]}
... )
(def ui-person (prim/factory Person))
(defsc PeopleList [this {:keys [people]}]
{:query [:db/id :list-title {:people (prim/get-query Person}]
:ident [:people-list/by-id :db/id]}
(dom/div
(if (seq people)
(dom/button {:onClick #(df/load-field this :people)} "Load People")
(map ui-person people))))
Since we are in the UI and not inside of a mutation’s action thunk, we can use load-field
to initialize the
call to prim/transact!
.
The action-suffixed load functions are useful when performing an action in the user interface that must both modify the client-side database and load data from the server.
Note
|
You must use the result of the
fulcro.client.data-fetch/remote-load funtion as the value of the remote in the mutation. The
action calls of load-action place the request on a queue. The remote-load returns the correct indicator to
Fulcro so that it knows you queued a load. If you forget it, then your load won’t be processed until the next
operation causes remote interactions.
|
(require [fulcro.client.data-fetch :as df]
[fulcro.client.mutations :refer [mutate]]
[app.ui :as ui])
(defmutation change-view [{:keys [new-view]}]
(action [{:keys [state] :as env}]
(let [new-view-comp (cond
(= new-view :main) ui/Main
(= new-view :settings) ui/Settings]
(df/load-action env new-view new-view-comp) ;; Add the load request to the queue
(swap! state update :app/current-view new-view))}))
(remote [env] (df/remote-load env))) ;; Tell Fulcro you did something that requires remote
This snippet defines a mutation that modifies the app state to display the view passed in via the mutation parameters and loads the data for that view. A few important points:
-
If an action thunk calls one or more
action
-suffixed load functions (which do nothing but queue the load request) then it MUST also callremote-load
on the remote side. -
The
remote-load
function changes the mutation’s dispatch key tofulcro/load
which in turn triggers the networking layer that one or more loads are ready. IMPORTANT: Remote loading cannot be mixed with a mutation that also needs to be sent remotely. I.e. one could not sendchange-view
to the server in this example. -
If you find yourself wanting to put a call to any
load-*
in a React Lifecycle method try reworking the code to use your own mutations (which can check if a load is really needed) and the use the action-suffixed loads instead. Lifecycle methods are often misunderstood, leading to incorrect behaviors like triggering loads over and over again.
9.8.2. Using the fulcro/load
Mutation Directly (NOT recommended)
Fulcro has a built-in mutation fulcro/load
(also aliased as fulcro.client.data-fetch/load
).
The mutation can be used from application startup or anywhere you’d run a mutation (transact!
). This covers almost
all of the possible remote data integration needs!
The helper functions described above simply trigger this built-in Fulcro mutation
(the *-action
variants do so by modifying the remote mutation AST via the remote-load
helper function).
You are allowed to use this mutation directly in a call to transact!
, but you should never need to.
The arguments to this mutation include most of the options that load
can take, but you do
need to specify query
. For most direct use-cases you’ll probably skip using the load-field
specific parameters
described in the docstring (:field
and :ident
). You can read the source of load-field
if you’d like to simulate
it by hand.
For example:
(load reconciler :prop nil)
is a simple helper that is ultimately identical to:
(prim/transact! reconciler '[(fulcro/load {:query [:prop]}) :ui/loading-data])
(the follow-on read is to ensure load markers update).
9.9. Incremental Loading
It is very common for your UI query to have a lot more in it than you want to load at any given time. In some cases, even a specific entity asks for more than you’d like to load. A good example of this is a component that allows comments. Perhaps you’d like the initial load of the component to not include the comments at all, then later load the comments when the user, for example, opens (or scrolls to) that part of the UI.
Fulcro makes this quite easy. There are three basic steps:
-
Put the full query on the UI
-
When you use that UI query with load, prune out the parts you don’t want.
-
Later, ask for the part you do want.
Step 2 sounds like it will be hard, but it isn’t:
9.9.1. Pruning the Query
Sometimes your UI graph asks for things that you’d like to load incrementally. Let’s say you were loading a blog post that has comments. Perhaps you’d like to load the comments later:
(df/load app :server/blog Blog {:params {:id 1 }
:target [:screens/by-name :blog :current-page]
:without #{:blog/comments}})
The :without
parameter can be used to elide portions of the query (it works recursively). The query sent to the
server will not ask for :blog/comments
. Of course, your server has to parse and honor the exact details
of the query for this to work (if the server decides it’s going to returns the comments, you get them…but this is why
we disliked REST, right?)
(server/defquery-root :server/blog
(value [{:keys [query]} {:keys [id]}] ; query will be the query of Blog, without the :comments
; use a parser on query to get the proper blog result. See Server Interactions - Query Parsing
(get-blog id query)))
9.9.2. Filling in the Subgraph
Later, say when the user scrolls to the bottom of the screen or clicks on "show comments" we can load the rest
from of this previously partially-loaded graph within the Blog itself using load-field
, which does the opposite
of :without
on the query:
(defsc Blog [this props]
{:ident [:blog/by-id :db/id]
:query [:db/id :blog/title {:blog/content (prim/get-query BlogContent)} {:blog/comments (prim/get-query BlogComment)}]}
(dom/div
...
(dom/button {:onClick #(load-field this :blog/comments)} "Show Comments")
...)))
The load-field
function prunes everything from the query except for the branch
joined through the given key. It also generates an entity rooted query based on the calling component’s ident:
[{[:table ID] subquery}]
where the [:table ID]
are the ident of the invoking component, and subquery is (prim/get-query invoking-component)
, but
focused down to the one field. In the example above, this would end up something like this:
[{[:blog/by-id 1] [{:blog/comments [:db/id :comment/author :comment/body]}]}]
This kind of query can be handled on the server with defquery-entity
(which is triggered on these kinds of ident joins):
(server/defquery-entity :blog/by-id
(value [{:keys [query]} id params]
(get-blog id query))) ; SAME HANDLER WORKS!!!
Load Focus
Another way to load a subgraph part is to use the :focus
setting on the load
, :focus
allow you to define a subquery to be loaded from the component query, to start simple here
is how we can write the same previous example using :focus
:
(defsc Blog [this props]
{:ident [:blog/by-id :db/id]
:query [:db/id :blog/title {:blog/content (prim/get-query BlogContent)} {:blog/comments (prim/get-query BlogComment)}]}
(dom/div
...
(dom/button {:onClick #(load this (prim/get-ident this) Blog {:focus [:blog/comments]})}
"Show Comments")
...)))
Although the interface requires more code, it’s more flexible, let’s say for instance you only want to load the comment id and author, you can write as:
(load this (get-ident this) Blog {:focus [{:blog/comments [:db/id :comment/author]}]})
As you might notice, in the first :focus
example when we point to a join, the whole
join sub-query will be pulled, but you can get more precise by expressing more of the
sub-query.
Load focus on unions
A special case that worth mention about focus sub-query is how it handles unions. Other
than on unions, :focus
will only use the attributes mentioned on the sub-query, but
on unions, if you don’t express some union branch it will be pulled as is, for example, let’s
say you have this given query:
[{:feed/item {:message [:message/text :message/timestamp]
:activity [:activity/source-id :activity/url]}}]
If you :focus
on this:
[{:feed/item {:message [:message/text]}}]
Then you get out the query:
[{:feed/item {:message [:message/text]
:activity [:activity/source-id :activity/url]}}]
This makes sure you will keep all union branches.
9.10. Full-Stack Error Handling
The first thing I want to challenge you to think about is this: why do errors happen, and what can we do about them?
In the early days of web apps, our UI was completely dumb: the server did all of the logic. The answer to these questions were clear, because it wasn’t even a distributed app: it was a remote display of an app running on a remote machine. In other words, the context of the error handling was available at the same time as our request to do the operation.
So we often block the UI so that the user cannot get ahead of things (like submit a form and move on before the server has confirmed the submission). Over the years we’ve gotten a little more clever with our error handling, but largely our users (and our ability to reason about our programs) has kept us firmly rooted to the block-until-we-know method of error handling because it is actually less like a distributed system. Unfortunately, such UI interactions are doomed to feel sluggish in congested or bandwidth-limited environments.
More and more code is moving to the client machine. In the world of single-page apps we want things to "make sense" and we also want them to be snappy. Unfortunately, we still also have security concerns at the server, so we get confused by the following fact: the server has to be able to validate a request for security reasons. There is no getting around this. You cannot trust a client.
However, I think many of us take this too far: security concerns are often a lot easier to enforce than the full client-level interaction with these concerns. For example, we can say on a server that a field must be a number. This is one line of code that can be done with an assertion.
The UI logic for this is much larger: we have to tell the user what we expected, why we expected it, constrain the UI to keep them from typing letters, etc. In other words, almost all of the real logic is already on the client, and unless there is a bug, our UI won’t cause a server error because it is pre-checking everything before sending it out.
So, in a modern UI, here are the scenarios for errors from the server:
-
You have a bug. Is there anything you can really do? No, because it is a bug. If you could predict it going wrong, you would have already fixed it. Testing and user bug reports are your only recourse.
-
There is a security violation. There is nothing for your UI to do, because your UI didn’t do it! This is an attack. Throw an exception on the server, and never expect it in the UI. If you get it, it is a bug. See (1).
-
There is a user perspective outage (LAN/WiFi/phone). These are possibly recoverable. You can block the UI, and allow the user to continue once networking is re-established.
-
There is an infrastructure outage. You’re screwed. Things are just down. If you’re lucky, it is networking and your user is seeing it as (3) and is just blocked. If you’re not lucky, your database crashed and you have no idea if your data is even consistent.
So, I would assert that the only full-stack error handling worth doing in any detail is for case (3). If communications are down, the client can retry. But in a distributed system this can be a little nuanced. Did that mutation partially complete?
If your application can assume reasonably reliable networking and you write your server operations to be atomic then your error handling can be a relatively small amount of code. Unrecoverable problems will be rare and at worst you throw up a dialog that says you’ve had an error and the user hits reload on their browser. If this happens to users once or twice a year, it isn’t going to hurt you.
But of course there is more to the story, and the devil is in the details.
9.10.1. Programming with Pure Optimism
The general philosophy of a Fulcro application is that optimistic updates are not even triggered on the client unless they expect to succeed on the server. In other words, you write the application in such a way that operations cannot be triggered from the UI unless you’re certain that a functioning server will execute them. A server should not throw an exception and trigger a need for error handling unless there is a real, non-recoverable situation.
If this is true, then a functioning server does need to do sanity checking for security reasons, but in general you don’t need to give friendly errors when those checks fail: you should assume they are attempted hacks. Other serious problems are similar: there is usually nothing you can do but throw an exception and let the user contact support. Exceptions to this rule certainly exist, but they are few and far between.
There are some cases where the server has to be involved in a validation interaction non-optimistically. Login is a great example of this. However, invalid credentials on login need not be treated as an error! Instead they can be treated as a response to a question. "Can I log in with these credentials?". Yes or no. This is a query and response, not an error handling interaction. Thus, something like login can be handled with a query (to get the answer) and post-mutation (to update the screen with a message or change the UI route).
This philosophy eases the overhead in general application programming. You need not write a bunch of code in the UI that gives a nice friendly message for every kind of error that can possibly occur (nor does anyone really do that anywhere anyhow). If an error occurs, you can pretty much assume it is either a bug or a real outage. In both cases, there isn’t a lot you can do that will work "well" for the user. If it is a bug, then you really have no chance of predicting what will fix it, otherwise you would have already fixed the bug. If it’s an outage you might be able to do retries, but in many cases you have no way of knowing what has gone wrong.
So, one approach is to treat most error conditions as a rare problem that needs fairly radical recovery. One such method is to use a global error handler that is configured during the setup of your client application (you have to explicitly configure networking). This function could update application state to show some kind of top-level modal dialog that describes the problem, possibly allows the user to submit application history (for support viewer) to your servers, and then re-initializes the application in some way.
You can, of course, get pretty creative with the re-initialization. For example, say you write screens so that they will refresh their persistent data whenever it is older than some amount of time, and write it so all entities have a timestamp. You could walk the state and "expire" all of the timestamps, and then close the dialog. Your retry could be set up to check for the expiration, which in turn would trigger loads. If the server is really having problems then the worst case is that the dialog pops back up telling them there is still a problem.
9.10.2. Being a Bit More Pessimistic — Flaky Network Operation
If your users are likely using your software from a phone on a subway then you have a completely different issue.
Fortunately, Fulcro actually makes handling this case relatively easy as well. Here is what you can do:
-
Write a custom networking implementation for the client that detects the kind of error, and retries recoverable ones until they succeed. Possibly with exponential backoff. (If an infinite loop happens, the user will eventually hit reload.)
-
Make your server mutations idempotent so that a client can safely re-apply a transaction without causing data corruption.
The default fulcro networking does not do retries because it isn’t safe without the idempotent guarantee.
The optimistic updates of Fulcro and the in-order server execution means that "offline" operation is actually quite tractable. If programmed this way, your error handling becomes isolated almost entirely to the networking layer. Of course, if the user navigates to a screen that needs server data, they will just have to wait. Writing UI code that possibly has lifecycle timers to show progress updates will improve the overall feel, but the correctness will be there with a fairly small number of additions.
However, even with these fancy tricks that make our applications better, there are times when we’d just like to block until something is complete.
9.10.3. Detecting Errors From the Server
The built-in remote and networking in Fulcro has a few hooks for dealing with errors:
-
A global network error handler for dealing with actual network errors (i.e. you can’t talk to the server).
-
A global root node value (at
:fulcro/server-error
) you can query that holds the last network error. (You manually clear this if you wish to use it to track errors over time.) -
The ability to run a mutation if a load fails with an application-level error (load option
:fallback
). See fallbacks. -
Mutation fallbacks for responding to full-stack application-level mutation errors.
Global Error Handler
This is only available if you use the built-in remote with the Fulcro client. If you write your own networking then you can handle errors at the network layer any way you want. To install a network error handler on the default remote support simply write a function like this:
;; this function is called on *every* network error, regardless of cause
(defn error-handler "To be used as network-error-callback"
[state status-code error]
(log/warn "Global callback:" error " with status code: " status-code))
and install it:
(fc/make-fulcro-client {:network-error-callback error-handler})
Server Error Demo
The live example below does various things to demonstrate various ways of reacting to errors. There is a load that fails and uses a fallback to log a message.
The next button tries a mutation that fails (by throwing on the server in a way that propagates the error back to the client). The final one tries a read that will fail, but does nothing with the error, though you’ll still see that the global indicator updates.
(ns book.demos.server-error-handling
(:require
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[fulcro.logging :as log]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.server :as server]
[fulcro.client.dom :as dom]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SERVER:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(server/defmutation error-mutation [params]
;; Throw a mutation error for the client to handle
(action [env] (throw (ex-info "Server error" {:type :fulcro.client.primitives/abort :status 401 :body "Unauthorized User"}))))
(server/defquery-entity :error.child/by-id
(value [env id params]
(throw (ex-info "other read error" {:status 403 :body "Not allowed."}))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CLIENT:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Just send the mutation to the server, which will return an error
(defmutation error-mutation [params]
(remote [env] true))
;; an :error key is injected into the fallback mutation's params argument
(defmutation disable-button [{:keys [error ::prim/ref] :as params}]
(action [{:keys [state]}]
(log/warn "Mutation specific fallback -- disabling button due to error from mutation invoked at " ref)
(swap! state assoc-in [:error.child/by-id :singleton :ui/button-disabled] true)))
(defmutation log-read-error [{:keys [error]}]
(action [env] (log/warn "Read specific fallback: " error)))
(defsc Child [this {:keys [fulcro/server-error ui/button-disabled]}]
;; you can query for the server-error using a link from any component that composes to root
{:initial-state (fn [p] {})
:query (fn [] [[:fulcro/server-error '_] :ui/button-disabled :fulcro/read-error])
:ident (fn [] [:error.child/by-id :singleton])} ; lambda so we get a *literal* ident
(dom/div
;; declare a tx/fallback in the same transact call as the mutation
;; if the mutation fails, the fallback will be called
(dom/button {:onClick #(df/load this :data nil {:fallback `log-read-error})}
"Click me to try a read with a fallback (logs to console)")
(dom/button {:onClick #(prim/transact! this `[(error-mutation {}) (df/fallback {:action disable-button})])
:disabled button-disabled}
"Click me for error (disables on error)!")
(dom/button {:onClick #(df/load-field this :fulcro/read-error)}
"Click me for other error!")
(dom/div "Server error (root level): " (str server-error))))
(def ui-child (prim/factory Child))
(defsc Root [this {:keys [child]}]
{:initial-state (fn [params] {:child (prim/get-initial-state Child {})})
:query [{:child (prim/get-query Child)}]}
(dom/div (ui-child child)))
9.10.4. UI Blocking
Fulcro defaults to optimistic updates, which in turn encourages you to write a UI that is very responsive. However, as soon as you start writing remote mutations you start worrying about the fact that your user submitted some data but you let them go off and do other things (like leave the screen they’re on) before the server has responded. In effect, we’ve told the user "success", but we know we’re kind of lying to them.
Another way of looking at it is: we’re letting them leave the visual context of the information, but we know that if a server error happens then we need to inform them about that error. We’d like to be sure they understand the error by still seeing that context when it arrives.
This is a rather complicated way of saying something like "if their email change didn’t work, then we’d like to show the error next to the email input box".
There is nothing in Fulcro that prevents you from writing a blocking UI. You just have to remember that the UI is a pure rendering of application state: meaning that if you want to block the UI, then you need a way to put a "block the ui" marker in state (that renders in a way that prevents navigation), and remove that marker when the operation is complete.
Fulcro has a number of ways that you can accomplish this, but we’ll cover the simplest and most obvious.
Blocking on Remote Mutations
This technique uses the following pattern:
-
We use the
prim/ptransact!
to submit a transaction, which will run each mutation in pessimistic mode (each element runs only after the prior element has completed a round-trip to the server). -
The first call in the tx will block the UI, and do the remote operation. We’ll also leverage mutation return values so the server can indicate success to us.
-
Once the first call finishes, the second call in the tx can choose to unblock the UI, or handle any problem it sees. The mutation return value is merged (and visible) in app state.
Unlike normal mode, pessimistic transactions expect that you might have to nest another one within a mutation in order to retry a
prior call. This is a supported use, and you will find the reconciler in the mutation’s env
parameter to facilitate it as
shown in the example below.
To show how this all works we’ll use an in-browser server emulation and show you a working example.
First, we need something to block our UI (which in the card measures 400x100 px). It is a simple div with some style that will overlay the main UI and prevent further interactions while also showing some kind of feedback message. The CSS sucks, but let’s ignore that for now.
We define it, along with some helper functions that can manipulate its state. It does not have an ident, and we
plan to just place it in root at :overlay
:
(defn set-overlay-visible* [state tf] (assoc-in state [:overlay :ui/active?] tf))
(defn set-overlay-message* [state message] (assoc-in state [:overlay :ui/message] message))
(defsc BlockingOverlay [this {:keys [ui/active? ui/message]}]
{:query [:ui/active? :ui/message]
:initial-state {:ui/active? false :ui/message "Please wait..."}}
(dom/div (clj->js {:style {:position :absolute
:display (if active? "block" "none")
:zIndex 65000
:width "400px"
:height "100px"
:backgroundColor "rgba(0,0,0,0.5)"}})
(dom/div (clj->js {:style {:position :relative
:top "40px"
:color "white"
:textAlign "center"}}) message)))
The main UI is just a simple one-field form and submission button. Note, however, that it submits the form
with ptransact!
, which will force each call to complete before the next one can start. Thus the second call can
check the result and run whatever in response to it.
(defsc Root [this {:keys [ui/name overlay]}]
{:query [:ui/name {:overlay (prim/get-query BlockingOverlay)}]
:initial-state {:overlay {} :ui/name "Alicia"}}
(dom/div {:style (clj->js {:width "400px" :height "100px"})}
(ui-overlay overlay)
(dom/p "Name: " (dom/input {:value name}))
(dom/button {:onClick #(prim/ptransact! this `[(submit-form) (retry-or-hide-overlay)])}
"Submit")))
Now a bit of information about our "server". It has the following definition of the remote mutation:
(server/defmutation submit-form [params]
(action [env]
(if (> 0.5 (rand))
{:message "Everything went swell!"
:result 0}
{:message "There was an error!"
:result 1})))
As you can see it’s just a stub that randomly responds with success or error. The client mutation looks like this:
(defmutation submit-form [params]
(action [{:keys [state]}] (swap! state set-overlay-visible* true))
(remote [{:keys [state ast] :as env}]
(m/returning ast state MutationStatus)))
It just shows the overlay, and goes remote. Notice the remote part is using returning
from the mutations namespace
to indicate a merge of the result value of the mutation. For that we’ve defined a singleton component (for its query only):
(defsc MutationStatus [this props]
{:ident (fn [] [:remote-mutation :status])
:query [:message :result]})
This means that when this remote mutation is done, we should see a the return value of the server mutation
at [:remote-mutation :status]
(which is the constant ident of MutationStatus
).
Now for our second client mutation. First, we need a function that can look in state and see if the mutation status looks ok:
(defn submit-ok? [env] (= 0 (some-> env :state deref :remote-mutation :status :result)))
Then we can leverage that to make something that reads well:
(defmutation retry-or-hide-overlay [params]
(action [{:keys [reconciler state] :as env}]
(if (submit-ok? env)
(swap! state (fn [s]
(-> s
(set-overlay-message* "Please wait...") ; reset the overlay message for the next appearance
(set-overlay-visible* false))))
(do
(swap! state set-overlay-message* (str (-> state deref :remote-mutation :status :message) " (Retrying...)"))
(prim/ptransact! reconciler `[(submit-form {}) (retry-or-hide-overlay {})])))))
It’s the real work-horse. The optimistic side can assume the result is updated, so it looks for the result code via
submit-ok?
. If things are OK, then it resets the overlay message and hides it.
If the submission had an error, then it
-
Adds "retrying" to the server message and puts that on the overlay
-
Does a new call to
ptransact!
.
You can try out the finished product in the example below. Try it a few times so you can see the error-handling in action.
(ns book.server.ui-blocking-example
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.mutations :as m]
[fulcro.server :as server]
[fulcro.client.mutations :as m :refer [defmutation]]
[book.macros :refer [defexample]]))
;; SERVER
(server/defmutation submit-form [params]
(action [env]
(if (> 0.5 (rand))
{:message "Everything went swell!"
:result 0}
{:message "There was an error!"
:result 1})))
;; CLIENT
(defsc BlockingOverlay [this {:keys [ui/active? ui/message]}]
{:query [:ui/active? :ui/message]
:initial-state {:ui/active? false :ui/message "Please wait..."}}
(dom/div (clj->js {:style {:position :absolute
:display (if active? "block" "none")
:zIndex 65000
:width "400px"
:height "100px"
:backgroundColor "rgba(0,0,0,0.5)"}})
(dom/div (clj->js {:style {:position :relative
:top "40px"
:color "white"
:textAlign "center"}}) message)))
(def ui-overlay (prim/factory BlockingOverlay))
(defn set-overlay-visible* [state tf] (assoc-in state [:overlay :ui/active?] tf))
(defn set-overlay-message* [state message] (assoc-in state [:overlay :ui/message] message))
(defsc MutationStatus [this props]
{:ident (fn [] [:remote-mutation :status])
:query [:message :result]})
(defmutation submit-form [params]
(action [{:keys [state]}] (swap! state set-overlay-visible* true))
(remote [{:keys [state ast] :as env}]
(m/returning ast state MutationStatus)))
(defn submit-ok? [env] (= 0 (some-> env :state deref :remote-mutation :status :result)))
(defmutation retry-or-hide-overlay [params]
(action [{:keys [reconciler state] :as env}]
(if (submit-ok? env)
(swap! state (fn [s]
(-> s
(set-overlay-message* "Please wait...") ; reset the overlay message for the next appearance
(set-overlay-visible* false))))
(do
(swap! state set-overlay-message* (str (-> state deref :remote-mutation :status :message) " (Retrying...)"))
(prim/ptransact! reconciler `[(submit-form {}) (retry-or-hide-overlay {})]))))
(refresh [env] [:overlay])) ; we need this because the mutation runs outside of the context of a component
(defsc Root [this {:keys [ui/name overlay]}]
{:query [:ui/name {:overlay (prim/get-query BlockingOverlay)}]
:initial-state {:overlay {} :ui/name "Alicia"}}
(dom/div {:style {:width "400px" :height "100px"}}
(ui-overlay overlay)
(dom/p "Name: " (dom/input {:value name}))
(dom/button {:onClick #(prim/ptransact! this `[(submit-form {}) (retry-or-hide-overlay {})])}
"Submit")))
9.10.5. Fallbacks
If you’re running a mutation is likely to trigger server errors then you can explicitly encode a fallback behavior with the mutation. Fallbacks are triggered the mutation on the server throws an error that is detectable, or if there is a network error.
The requirement for a server mutation to trigger fallbacks is for it to throw an ex-info
exception and
include {:type :fulcro.client.primitives/abort}
in the data. Otherwise the server-side parser will swallow
the exception and continue with the transaction.
Defining a fallback for a transaction is done by including a special mutation in the transaction that names the mutation to invoke on error:
(prim/transact! this `[(some/mutation) (fulcro.client.data-fetch/fallback {:action handle-failure})])
(require [fulcro.client.mutations :refer [mutate]]
[fulcro.client.primitives :as prim)
(defmutation handle-failure [{:keys [error ::prim/ref] :as params}]
;; fallback mutations are designed to recover the client-side app state from server failures
;; THEY DO NOT CHECK FOR REMOTE. You cannot chain a remote interaction in a fallback.
(action [{:keys [state]}]
(swap! state undo-stuff error)))
Assuming that some-mutation
is remote, then if the server throws a hard error (e.g. status code not 200)
then the fallback action’s mutation symbol (a dispatch key for mutate) is invoked on the
client with params that include an :error
key that includes the details of the server exception (error type, message,
and ex-info’s data). Be sure to only include serializable data in the server exception!
If triggered due to a mutation fallback (not load), then the fallback will also receive the ident of the component
that invoked the original transaction in parameters under the key fulcro.client.primitives/ref
.
You can have any number of fallbacks in a tx, and they will run in order if the transaction fails.
Note
|
It is not recommended that you rely on fallbacks for very much. They are provided for cases where you’d like to code instance-targeted recovery, but we believe this to be a rarely useful feature. You’re much better off preventing errors by coding your UI to validate, authorize, and error check things on the client before sending them to the server. The server should still verify sanity for security reasons, but optimistic systems like Fulcro put more burden on the client code in order to provide a better experience under normal operation. See the earlier discussion about error handling. |
If you do use fallback then you probably also need to clear the network queue so that additional queued operations don’t continue to fail.
9.10.6. Clearing Network Queue
If the server sends back a failure it may be desirable to clear any pending network requests from the client network queue. For example, if you’re adding an item to a list and get a server error you might have a mutation waiting in your network queue that was some kind of modification to that (now failed) item. Continuing the network processing might just cause more errors.
The FulcroApplication protocol (implemented by your client app) includes the protocol method
clear-pending-remote-requests!
which will drain all pending network requests.
(fulcro.client/clear-pending-remote-requests! my-app)
A common recovery strategy from errors could be to clean the network queue and run a mutation that resets your application to a known state, possibly loading sane state from the server.
9.10.7. Pessimistic Transaction Fallbacks
The fallback mechanism described for error handling works in ptransact!
. Fallbacks are clustered to the remote they follow
up until the next remote mutation (with one exception: fallbacks at the beginning of the entire tx are clustered to the first remote mutation):
(ptransact! this `[(L) (df/fallback {...}) (L) (R) (df/fallback {...}) (L2) (R2) (L3) (df/fallback {...}) ])
will associate the first two fallbacks with remote call R
, and the last one with R2
.
10. Building a Server
In the Getting Started chapter you saw a little on how to build and use Fulcro’s easy server. That server is acually flexible enough for many production needs, but Fulcro also comes with code to help you very quickly get a custom server for your application up and running. In this chapter we’ll give you more detail on these two main approaches to the server-side of Fulcro:
-
How to use the pre-built bits to manually build your own server.
-
More details on the easy server.
10.1. Rolling Your Own Server
If you’re integrating with an existing server then you probably just want to know how to get things working without having to use a Component library, and all of the other stuff that comes along with it.
It turns out that the server API handling is relatively light. Most of the work goes into getting things set up for easy server restart (e.g. making components stop/start) and getting those components into your parsing environment.
If you have an existing server then you’ve mostly figured out all of that stuff already and just want to plug a Fulcro API handler into it.
Here are the basic requirements:
-
Make sure your Ring stack has transit-response and transit-params. You can see a sample Ring stack in [Fulcro’s source](https://github.com/fulcrologic/fulcro/blob/1.2.0/src/main/fulcro/easy_server.clj#L94)
-
Check to see if the incoming request is for "/api". If so:
-
Call [
handle-api-request
](https://github.com/fulcrologic/fulcro/blob/1.2.0/src/main/fulcro/server.clj#L354). Pass a parser to it (recommend usingfulcro.server/fulcro-parser
), an environment, and the EDN of the request. It will give back a Ring response.
You’re responsible for creating the parser environment. I’d recommend using
the fulcro.server/fulcro-parser
because it is already hooked to the server multimethods
like defquery-root
and defmutation
. Those won’t work unless you use it, but any parser
that can deal with the query/mutation syntax is technically legal.
Here’s a complete little server with full defaults/dev/prod configuration support (see configuration),
and hot code reload using mount
instead of component:
(ns demo.server
(:require
[fulcro.server :as server]
[immutant.web :as web]
[mount.core :refer [defstate]]
[ring.middleware.content-type :refer [wrap-content-type]]
[ring.middleware.gzip :refer [wrap-gzip]]
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.multipart-params :refer [wrap-multipart-params]]
[ring.middleware.not-modified :refer [wrap-not-modified]]
[ring.middleware.resource :refer [wrap-resource]]
[ring.util.response :refer [response file-response resource-response]]))
;; You'll need defaults.edn and dev.edn in resources/config with at least {} as content.
(defstate config :start (server/load-config {:config-path "config/dev.edn"}))
(def ^:private not-found-handler
(fn [req]
{:status 404
:headers {"Content-Type" "text/plain"}
:body "NOPE"}))
(defstate server-parser :start (server/fulcro-parser))
(defn wrap-api [handler uri]
(fn [request]
(if (= uri (:uri request))
(server/handle-api-request
server-parser
;; this map is `env`. Put other defstate things in this map and they'll be in the mutations/query env on server.
{:config config}
(:transit-params request))
(handler request))))
(defstate middleware
:start
(-> not-found-handler
(wrap-api "/api")
server/wrap-transit-params
server/wrap-transit-response
(wrap-resource "public")
wrap-content-type
wrap-not-modified
wrap-gzip))
(defstate http-server
:start
(web/run middleware {:host "0.0.0.0"
:port (get config :port 3000)}) ; defaults to 3000, but can also come from config file
:stop
(web/stop http-server))
and the user namespace (typically in src/dev
):
(ns user
(:require
[clojure.tools.namespace.repl :as tools-ns :refer [set-refresh-dirs]]
demo.server
[mount.core :as mount]))
(set-refresh-dirs "src/dev" "src/main")
(defn go [] (mount/start))
(defn restart []
(mount/stop)
(tools-ns/refresh :after 'user/go))
In a REPL, you could start this one up and restart it with:
$ clj -A:dev
user=> (start)
user=> (restart)
...hot code reload/restart...
If you used the lein template to setup your project, see it’s README doc for instructions about how to run the dev environment.
10.1.1. Configuration
Since you’ll need to configure your web server, it might be useful to note that the configuration used
by the easy server is a component you can inject into your own server. A number of add-on components for Fulcro
assume a :config
keyed component will be in your system, so if you choose to use such components you can
create a config component via fulcro.server/new-config
.
It supports pulling in values from the system environment, overriding configs with a JVM option, and more. See
the section in easy server about configuration, and the docstrings on new-config
for more details.
10.2. The "Easy" Server
The pre-built easy server component for Fulcro uses Stuart Sierra’s Component library. The server has no global state except for a debugging atom that holds the entire system, and can therefore be easily restarted with a code refresh to avoid costly restarts of the JVM.
You should have a firm understanding of [Stuart’s component library](https://github.com/stuartsierra/component), since we won’t be covering that in detail here.
10.2.1. Constructing a base server
The base server is trivial to create:
(ns app.system
(:require
[fulcro.easy-server :as server]
[app.api :as api]
[fulcro.server :as prim]))
(defn make-system []
(server/make-fulcro-server
; where you want to store your override config file
:config-path "/usr/local/etc/app.edn"
; The keyword names of any components you want auto-injected into the query/mutation processing parser env (e.g. databases)
:parser-injections #{}
; Additional components you want added to the server
:components {}))
10.2.2. Configuring the server [ServerConfig]
Server configuration requires two EDN files:
-
resources/config/defaults.edn
: This file should contain a single EDN map that contains defaults for everything that the application wants to configure. -
/abs/path/of/choice
: This file can be named what you want (you supply the name when making the server). It can also contain an empty map, but is meant to be machine-local overrides of the configuration in the defaults. This file is required. We chose to do this because it keeps people from starting the app in an unconfigured production environment. NOTE: If the path is relative it is looked for via CLASSPATH (resource). If it is absolute, the real filesystem is searched.
(defn make-system []
(server/make-fulcro-server
:config-path "/usr/local/etc/app.edn"
...
The only parameter that the default server looks for is the network port to listen on:
{ :port 3000 }
The configuration component has a number of built-in features:
-
You can override the configuration to use with a JVM option:
-Dconfig=filename
-
The
defaults.edn
is the target of configuration merge. Your config EDN file must be a map, and anything in it will override what is in defaults. The merge is a deep merge. -
Values can take the form
:env/VAR
, which will use the string value of that environment variable as the value -
Values can take the form
:env.edn/VAR
, which will useread-string
to interpret the environment variable as the value -
Relative paths for the config file can be used, and will search the CLASSPATH resources. This allows you to package your config with you jar.
If you choose not to use components (see mount), then you can use load-config
directly:
(defstate config :start (server/load-config {:config-path "config/dev.edn"}))
and config
(after start) will simply contain the EDN of the defaults/config file merge.
10.2.3. Pre-installed components
When you start a Fulcro server it comes pre-supplied with injectable components upon which your component can depend and/or inject into the server-side parsing environment.
The most important of these, of course, is the configuration itself. The available components are known by the following keys:
-
:config
: The configuration component. The actual EDN value is in the:value
field of the component. -
:handler
: The component that handles web traffic. You can inject your own Ring handlers into two different places in the request/response processing: before or after the API handler. -
:server
: The actual web server.
The component library, of course, figures out the dependency order and ensures things are initialized and available where necessary.
10.2.4. Making components available in the processing environment
Any components in the server can be injected into the processing pipeline so they are
available when writing your mutations and query procesing. Making them available is as simple
as putting their component keyword into the :parser-injections
set when building the server:
(defn make-system []
(server/make-fulcro-server
:parser-injections #{:config}
...))
10.2.5. Adding to the Ring Stack
The easy server has a hook in front of the API processing (pre-hook), and one at the end of the ring stack
after API processing and just before the not-found handler (post-hook). You can have a component join into
the stack by making it depend on :handler
. Here is an example:
(defrecord Authentication [handler]
c/Lifecycle
(start [this]
(log/info "Hooking into pre-processing to add user info")
(let [old-pre-hook (fulcro.easy-server/get-pre-hook handler)
new-hook (fn [ring-handler] (fn [req] ((old-pre-hook ring-handler) (new-behavior req)))]
(fulcro.easy-server/set-pre-hook! handler new-hook))
this)
(stop [this] this))
...
(def server (fulcro.easy-server/make-fulcro-server
:components { :auth (component/using (map->Authentication {}) [:handler]))}))
The same pattern is used for fallback hooks.
The nesting of these functions can be a bit confusing, especially since none of the types are explicit. Here’s a description of each:
- middleware
-
A function that takes a "default handler" and returns a function of request → response (which is where you can choose to call the default, or do your own thing).
ring-handler
-
A function from Ring request maps to responses
(fn [req] resp)
old-pre-hook
-
The prior value of the hook. It is middleware.
So in the example above you have complete control over what happens in your new hook. Here are some examples:
Ignore the request altogether, and short-circuit everything with a literal response:
(let [...
new-hook (fn [ring-handler] (fn [req] (ring.util.response "Hello world")]
...
Ignore the old hook (this is like uninstalling all hooks):
(let [...
new-hook (fn [ring-handler] (fn [req] (ring-handler req))]
...
Serve index.html
from your public
resources folder if the uri of the request ends in html
(useful for HTML5 routing):
(let [...
new-hook (fn [ring-handler]
(fn [req]
(if (re-find #".*html$" uri)
(ring.middleware.resource/resource-request (assoc req :uri "index.html") "public")
((old-pre-hook ring-handler) req)))]
...
Note
|
Remember that if you serve something like index.html for alternate paths you should use absolute paths for the
resources (e.g. scripts) in that file. If the requested resource wasn’t an html file, then you’ll also need to set the
content type on the response with something like (→ (ring.middleware.resource/resource-request …) (ring.util.response/content-type "text/html")) .
|
10.2.6. Handling Filesystem Resources
The easy server will serve any files that are placed in resources/public
. The easy server does pre-map
URI "/" to "/index.html".
10.2.7. Modifying the /api
route
The easy server (and client) default to using /api
as the URI on which to handle traffic. If you are proxying multiple
Fulcro applications to a single server, you may want to place them under different URI paths (e.g. /app-1/api
and /app-2/api
.
; server API at "/app-1/api":
(def server (fulcro.easy-server/make-fulcro-server :app-name "app-1" ...))
The :app-name
option of the easy server will add such a prefix to the API route. If you do that, then the client
will also need to have manual configuration of networking to ensure that it tries to contact the correct URI for
API calls.
(def client (fc/make-fulcro-client {:networking {:remote (fulcro.client.network/fulcro-http-remote {:url "/app1/api"})})})
10.2.8. Adding Non-API Routes
The easy server also supplies a way to add in URI handlers via BIDI:
(defn page-handler [env bidi-match]
(ring/response ...))
; define routes (see BIDI documentation)
(def my-routes ["/" {"page.html" :page}])
(def server (fulcro.easy-server/make-fulcro-server
:extra-routes { :routes my-routes
:handlers {:page page-handler}}))
The extra routes are processed right after the pre-hook, but just before the resource (filesystem) serving. Thus, you can respond to any URI that isn’t already handled by your pre-hook.
11. Fulcro and GraphQL
GraphQL is rapidly becoming the standard for writing graph-based servers. As a result there are many existing ways to deploy such an API, and many businesses have decided that their back-end will simply use the standard. There are BaaS (back-end as a service) providers that also let you quickly define and deploy a GraphQL API.
All of this makes GraphQL an attractive server-side solution for Fulcro.
Luckily, Pathom has all of the tools necessary to make this integration simple and easy!
You can see some of it in action in one of the Fulcro video series, and read about configuring it in the Pathom Documentation (link goes to a pre-release version at the time of this writing).
The simplest integration is to simply use Pathom’s GraphQL remote:
(ns app
(:require
...
[com.wsscode.pathom.fulcro.network :as pfn]))
(def client (fulcro/make-fulcro-client
{:networking {:remote (pfn/graphql-network
{::pfn/url (str "https://api.github.com/graphql?access_token=" token)})}})
The more powerful and extensible method allows you to combine GraphQL access with Pathom Connect. This gives you very powerful features, including many useful ones that GraphQL doesn’t support natively!
12. Dynamic Queries
Fulcro supports dynamic queries: the ability the change the query of a component at runtime. This feature is fully serializable (works with the support viewer and other time-travel features), and is necessary code splitting where you may need to compose in a query of an as-yet unloaded component tree of your application.
Warning
|
This feature is not widely used yet, and should be considered of ALPHA quality. |
12.1. Query IDs
For dynamic queries to work right they have to be stored in your application database and every aspect of them must be serializable. Additionally, the UI must be able to look them up at the component level in order to do optimal refresh. The solution to this is query IDs. A query ID is a simple combination of the component’s fully-qualified class name combined with a user-defined qualifier (which defaults to the empty string).
Since this qualifier is needed both in the code that obtains queries (get-query
) and in the UI rendering (the factory
that draws that component), it is easiest to locate the qualifier in the UI factory itself. This allows you
to have instances of a class that can have different queries:
(defsc Thing ...)
(def ui-thing (prim/factory Thing)) ; query ID is based solely on the class itself (with no qualifier)
(def ui-thing-1 (prim/factory Thing {:qualifier :a})) ; query ID is derived from Thing plus the qualifier :a
(defsc Parent [this props]
{:query (fn [] [{:child (prim/get-query ui-thing-1)}])}
...)
In the above example one can now set the query for Thing
, or “Thing` with qualifier `:a”.
The following live demo shows dynamic queries in action:
(ns book.queries.dynamic-queries
(:require
[fulcro.client.dom :as dom]
[goog.object]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :as m]))
(declare ui-leaf)
; This component allows you to toggle the query between [:x] and [:y]
(defsc Leaf [this {:keys [x y]}]
{:initial-state (fn [params] {:x 1 :y 42})
:query (fn [] [:x]) ; avoid error checking so we can destructure both :x and :y in props
:ident (fn [] [:LEAF :ID])} ; there is only one leaf in app state
(dom/div
(dom/button {:onClick (fn [] (prim/set-query! this ui-leaf {:query [:x]}))} "Set query to :x")
(dom/button {:onClick (fn [] (prim/set-query! this ui-leaf {:query [:y]}))} "Set query to :y")
; If the query is [:x] then x will be defined, otherwise it will not.
(dom/button {:onClick (fn [e] (if x
(m/set-value! this :x (inc x))
(m/set-value! this :y (inc y))))}
(str "Count: " (or x y))) ; only one will be defined at a time
" Leaf"))
(def ui-leaf (prim/factory Leaf {:qualifier :x}))
(defsc Root [this {:keys [root/leaf] :as props}]
{:initial-state (fn [p] {:root/leaf (prim/get-initial-state Leaf {})})
:query (fn [] [{:root/leaf (prim/get-query ui-leaf)}])}
(dom/div (ui-leaf leaf)))
12.2. Setting a Query
The whole point of a dynamic query is being able to set the query to something new on a component. This has one
critical component that you must be very careful with: normalization. A component’s query is augmented with metadata
the marks which component is used to normalize (via idents) the entity found for the given portion of the query that
it handles. Therefore, you must still use get-query
to pull the query for portions of your subquery that you wish
to include when setting a new query. If done incorrectly, then normalization when using these components will not
work.
The top-level metadata will be added for you, so this is fine:
(defmutation change-a-query [_]
(action [{:keys [state]}]
(swap! state prim/set-query* B {:query [:MODIFIED]}))
but as soon as there’s a join you must ensure the subqueries have the correct metadata. This means that this is probably
incorrect (because [:x]
probably goes with some other component):
(defmutation change-a-query [_]
(action [{:keys [state]}]
(swap! state prim/set-query* B {:query [:MODIFIED {:subquery [:x]}]}))
instead, you should use get-query
with a state parameter to pull the query for a given component. So, say you
wanted to change the query for this:
(defsc X [_ _]
{:query [:id :name]
:ident [:x/by-id :id]})
(defsc A [_ _]
{:query [:id {:x (prim/get-query X)}]
:ident [:a/by-id :id]})
and you want to change both component’s queries. The correct form for the resulting mutation is:
(defmutation change-a-query [_]
(action [{:keys [state]}]
(swap! state (fn [s]
(as-> s state-map
(prim/set-query* state-map X {:query [:id :address]})
(prim/set-query* state-map A {:query [:id :new-prop {:x (prim/get-query X state-map}]}))))))
13. Query Parsing
Note
|
This chapter is here to help you understand how the internals of query parsing work. It used to be more necessary to interact at this level, but now libraries exist that make this much easier. It is highly recommended that you use the "Connect" feature of pathom to process queries. |
In the Loading and Incremental Loading sections we showed you the central entry
points for responding to server queries:
defquery-root
and defquery-entity
. These are fine for simple examples and for getting into your
processing; however, to be truly data-driven you need to change how the server responds based
on what the client actually asked for (in detail).
So far, we’ve sort of been spewing entire entities back from the server without taking care to prune them down to the actual query of the client.
Unless you modify the network stack all client communication will be in the form of Query/Mutation expressions, which of course are recursive in nature. There is no built-in recursive processing, since Fulcro does not know anything about your server-side storage; however, there is a parsing mechanism that you can use to build processing to interface with it, and there are a number of libraries that can also help.
13.1. Avoiding Parsing
If you’re lucky, you can make use of a library to do this stuff for you. Here are some options we know about:
-
A really nice library for building recursive Fulcro query parsers. The Connect functionality lets you easily build parsers that can bridge any kind of data source (even Datomic schema mismatches) to your desired UI query. This is the recommended library for writing server-side query processing logic. It will let you accomplish anything you need, give you autocomplete in the Query tab of Fulcro Inspect, flatten schema, deepen nesting, etc. If you’re writing a full-stack Fulcro application, this will be your best general choice.
-
A library that can run Fulcro graph queries against SQL databases. This library lets you define your joins in relation to the Fulcro join notion. It can walk to-one, to-many, and many-to-many joins in an SQL database in response to a Fulcro join. This allows it to handle many Fulcro queries as graph queries against your SQL database with just a little configuration and invocatin code. This library is contributor maintained, so it may not fit with production needs.
-
Another SQL-centric offering. This one actually uses Pathom internally to give you a more declarative way to define parsers against an SQL database/schema. It can directly parse EQL and turn it into SQL queries, and handles the N+1 problem on to-many relations automatically.
-
If you’re lucky enough to be using Datomic, Fulcro’s graph query syntax will run in their
pull
API; however, you’ll quickly find that mismatches in how Datomic does things (and your schema) will make you want higher-level parsing. Use Pathom.
Of these, Pathom is the most general and allows you to easily process a query in a more abstract way. However, you should know a little bit about parsing the queries yourself.
13.2. Doing the Parsing Yourself
The expression parser needs two things: A function to dispatch reads to, and a function to dispatch mutations. Since we’re talking about query parsing we’ll only be talking about the read dispatch function.
The signature of the read function is (read [env dispatch-key params])
where the env
contains the state
of your application, a reference to your parser
(so you can
call it recursively, if you wish), a query root marker, an AST node describing the exact
details of the element’s meaning, and anything else you want to put in there if
you call the parser recursively.
The most important item in the query processing is the received environment (env
). On
the server it contains:
-
Any components you’ve asked to be injected. Perhaps database and config components.
-
ast
: An AST representation of the item being parsed. -
query
: The subquery (e.g. of a join) -
parser
: The query expression parser itself (which allows you to do recursive calls). If you’re using the built-in parser, this this will be that same parser that is already hooked into your dispatch mechanism (e.g. defquery-root). -
request
: The full incoming Ring request, which will contain things like the headers, cookies, session, user agent info, etc.
The return value of your read must be the value for the dispatch-key
. The parser assembles these back together and
returns a map containing those keys for all of the items for which you return a non-nil result.
If you understand that, you can probably already write a simple recursive parse of a query. If you need a bit more hand-holding, then read on.
Note
|
When doing recursive parsing You do not have to use the parser from env . Parsers are cheap. If you want to
make one to deal with a particular graph, go for it! The fulcro.client.primitives/parser can make one.
|
Now, let’s get a feeling for the parser in general. The example below runs a parser on an arbitrary query that you supply, records the calls to the read emitter, and shows the trace of those calls in order.
See the source code comments for a full description of how it works.
Try some queries like these:
-
[:a :b]
-
[:a {:b [:c]}]
(note that the AST is recursively built, but only the top keys are actually parsed to trigger reads) -
[(:a { :x 1 })]
(note the value of params)
(ns book.queries.parsing-trace-example
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[devcards.util.edn-renderer :refer [html-edn]]
[cljs.reader :as r]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.dom :as dom]))
(defn tracer-path
"The assoc-in path of a field on the ParsingTracer in app state."
[field] [:widget/by-id :tracer field])
(defn tracing-reader
"Creates a parser read handler that can record the trace of the parse into the given state-atom at the proper
tracer path."
[state-atom]
(fn [env k params]
(swap! state-atom update-in (tracer-path :trace)
conj {:env (assoc env :parser :function-elided)
:dispatch-key k
:params params})))
(defmutation record-parsing-trace
"Mutation: Run and record the trace of a query."
[{:keys [query]}]
(action [{:keys [state]}]
(let [parser (prim/parser {:read (tracing-reader state)})] ; make a parser that records calls to read
(try
(swap! state assoc-in (tracer-path :trace) []) ; clear the last trace
(swap! state assoc-in (tracer-path :error) nil) ; clear the last error
(parser {} (r/read-string query)) ; Record the trace
(catch js/Error e (swap! state assoc-in (tracer-path :error) e)))))) ; Record and exceptions
(defsc ParsingTracer [this {:keys [trace error query result]}]
{:query [:trace :error :query :result]
:ident (fn [] [:widget/by-id :tracer])
:initial-state {:trace [] :error nil :query ""}}
(dom/div
(when error
(dom/div (str error)))
(dom/input {:type "text"
:value query
:onChange #(m/set-string! this :query :event %)})
(dom/button {:onClick #(prim/transact! this `[(record-parsing-trace ~{:query query})])} "Run Parser")
(dom/h4 "Parsing Trace")
(html-edn trace)))
(def ui-tracer (prim/factory ParsingTracer))
(defsc Root [this {:keys [ui/tracer]}]
{:query [{:ui/tracer (prim/get-query ParsingTracer)}]
:initial-state {:ui/tracer {}}}
(ui-tracer tracer))
13.2.1. Injecting some kind of database
In order to play with this on a server, you’ll want to have some kind of state available. The most trivial thing you can do is just create a global top-level atom that holds data. This is sufficient for testing, and we’ll assume we’ve done something like this on our server:
(defonce state (atom {}))
One could also wrap that in a Stuart Sierra component and inject it into the parser (for details on modifying the parser environment, see Making Components Available in the Parsing Environment).
Much of the remainder of this section assumes this.
13.3. Read Dispatching
When building your server there must be a read function that can
pull data to fufill what the parser needs to fill in the result of a query. Fulcro supplies this by default
and gives you the defquery-*
macros as helpers to hook into it, but really it is just a multi-method.
For educational purposes, we’re going to walk you through implementing this read function yourself.
The parser understands the grammar, and is written to work as follows:
-
The parser calls your
read
with the key that it parsed, along with some other helpful information. -
Your read function returns a value for that key (possibly calling the parser recursively if it is a join).
-
The parser generates the result map by putting that key/value pair into the result at the correct position (relative to the query).
Note that the parser only processes the query one level deep. Recursion (if you need it) is controlled by you calling the parser again from within the read function.
The example below is similar to the prior one, but it has a read function that just records what keys it was triggered for. Give it an arbitrary legal query, and see what happens.
Some interesting queries:
-
[:a :b :c]
-
[:a {:b [:x :y]} :c]
-
[{:a {:b {:c [:x :y]}}}]
(ns book.queries.parsing-key-trace
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[devcards.util.edn-renderer :refer [html-edn]]
[cljs.reader :as r]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.dom :as dom]))
(defn tracer-path
"The assoc-in path of a field on the ParsingTracer in app state."
[field] [:widget/by-id :tracer field])
(defn tracing-reader
"Creates a parser read handler that can record just the dispatch keys of a parse."
[state-atom]
(fn [env k params]
(swap! state-atom update-in (tracer-path :trace) conj {:read-called-with-key k})))
(defmutation record-parsing-trace
"Mutation: Run and record the trace of a query."
[{:keys [query]}]
(action [{:keys [state]}]
(let [parser (prim/parser {:read (tracing-reader state)})] ; make a parser that records calls to read
(try
(swap! state assoc-in (tracer-path :trace) []) ; clear the last trace
(swap! state assoc-in (tracer-path :error) nil) ; clear the last error
(parser {} (r/read-string query)) ; Record the trace
(catch js/Error e (swap! state assoc-in (tracer-path :error) e)))))) ; Record and exceptions
(defsc ParsingTracer [this {:keys [trace error query result]}]
{:query [:trace :error :query :result]
:ident (fn [] [:widget/by-id :tracer])
:initial-state {:trace [] :error nil :query ""}}
(dom/div
(when error
(dom/div (str error)))
(dom/input {:type "text"
:value query
:onChange #(m/set-string! this :query :event %)})
(dom/button {:onClick #(prim/transact! this `[(record-parsing-trace ~{:query query})])} "Run Parser")
(dom/h4 "Parsing Trace")
(html-edn trace)))
(def ui-tracer (prim/factory ParsingTracer))
(defsc Root [this {:keys [ui/tracer]}]
{:query [{:ui/tracer (prim/get-query ParsingTracer)}]
:initial-state {:ui/tracer {}}}
(ui-tracer tracer))
In the example above you should have seen that only the top-level keys trigger reads.
So, the query:
[:kw {:j [:v]}]
would result in a call to your read function on :kw
and :j
. Two calls. No
automatic recursion. Done. The output value of the parser will be a map (that
parse creates) which contains the keys (from the query, copied over by the
parser) and values (obtained from your read):
{ :kw value-from-read-for-kw :j value-from-read-for-j }
Note that if your read accidentally returns a scalar for :j
then you’ve not
done the right thing…a join like { :j [:k] }
expects a result that is a
vector of (zero or more) things or a singleton map that contains key
:k
.
{ :kw 21 :j { :k 42 } }
; OR
{ :kw 21 :j [{ :k 42 } {:k 43}] }
Dealing with recursive queries is a natural fit for a recusive algorithm, and it
is perfectly fine to invoke the parser
function to descend the query. In fact,
the parser
is passed as part of your environment.
So, the read function you write will receive three arguments, as described below:
-
An environment containing:
-
:ast
: An abstract syntax tree for the element, which contains:-
:type
: The type of node (e.g. :prop, :join, etc.) -
:dispatch-key
: The keyword portion of the triggering query element (e.g. :people/by-id) -
:key
: The full key of the triggering query element (e.g. [:people/by-id 1]) -
:query
: (same as the query inenv
) -
:children
: If this node has sub-queries, will be AST nodes for those -
others…see documentation
-
-
:parser
: The query parser -
:query
: if the element had one E.g.{:people [:user/name]}
has:query
[:user/name]
-
Components you requested be injected
-
-
A dispatch key for the item that triggered the read (same as dispatch key in the AST)
-
Parameters (which are nil if not supplied in the query)
It must return a value that has the shape implied by the grammar element being read.
Note
|
The following examples run various parsers against arbitrary queries. The ParseRunner component source looks like
this:
|
(ns book.queries.parse-runner
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[devcards.util.edn-renderer :refer [html-edn]]
[cljs.reader :as r]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.dom :as dom]))
(defn parse-runner-path
([] [:widget/by-id :parse-runner])
([field] [:widget/by-id :parse-runner field]))
(defmutation run-query [{:keys [query database parser]}]
(action [{:keys [state]}]
(try
(let [query (r/read-string query)
result (parser {:state (atom database)} query)]
(swap! state update-in (parse-runner-path) assoc
:error ""
:result result))
(catch js/Error e (swap! state assoc-in (parse-runner-path :error) e)))))
(defsc ParseRunner [this {:keys [ui/query error result]} {:keys [parser database]}]
{:query [:ui/query :error :result]
:initial-state (fn [{:keys [query] :or {query ""}}] {:ui/query query :error "" :result {}})
:ident (fn [] [:widget/by-id :parse-runner])}
(dom/div
(dom/input {:type "text"
:value query
:onChange (fn [evt] (m/set-string! this :ui/query :event evt))})
(dom/button {:onClick #(prim/transact! this `[(run-query ~{:query query :database database :parser parser})])} "Run Parser")
(when error
(dom/div (str error)))
(dom/div
(dom/h4 "Query Result")
(html-edn result))
(dom/div
(dom/h4 "Database")
(html-edn database))))
(def ui-parse-runner (prim/factory ParseRunner))
13.3.1. Reading a keyword
If the parser encounters a keyword :kw
, your function will be called with:
(your-read
{ :dispatch-key :kw :parser (fn ...) } ;; the environment: parser, etc.
:kw ;; the keyword
nil) ;; no parameters
your read function should return some value that makes sense for that spot in the grammar. There are no real restrictions on what that data value has to be in this case. You are reading a simple property. There is no further shape implied by the grammar. It could be a string, number, Entity Object, JS Date, nil, etc.
Due to additional features of the parser, your return value must be wrapped in a
map with the key :value
. If you fail to do this, you will get nothing
in the result.
Thus, a very simple read for props (keywords) could be:
(defn read [env key params] { :value 42 })
below is an example that implements exactly this read
and plugs it into a
parser like this:
(defn read-42 [env key params] {:value 42})
(def parser-42 (prim/parser {:read read-42}))
The UI just passes your query off to the parser and shows the results.
Thus, the example returns the value 42 no matter what it is asked for. Run any query you want in it, and check out the answer.
Some examples to try:
-
[:a :b :c]
-
[:what-is-6-x-7]
-
[{:a {:b {:c {:d [:e]}}}}]
(yes, there is only one answer)
(ns book.queries.naive-read
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[book.queries.parse-runner :refer [ParseRunner ui-parse-runner]]
[fulcro.client.dom :as dom]))
(defn read-42 [env key params] {:value 42})
(def parser-42 (prim/parser {:read read-42}))
(defsc Root [this {:keys [parse-runner]}]
{:initial-state {:parse-runner {}}
:query [{:parse-runner (prim/get-query ParseRunner)}]}
(dom/div
(ui-parse-runner (prim/computed parse-runner {:parser parser-42 :database {}}))))
So now you have a read function that returns the meaning of life the universe and everything in a single line of code! But now it is obvious that we need to build an even bigger machine to understand the question.
If your server state is just a flat set of scalar values with unique keyword identities, then a better read is similarly trivial:
(defn property-read [{:keys [state]} key params] {:value (get @state key :not-found)})
(def property-parser (prim/parser {:read property-read}))
It just assumes the property will be in the top-level of some injected state atom. Let’s try that out. The database we’re emulating is shown at the bottom of the example. Run some queries and see what you get. Some suggestions:
-
[:a :b :c]
-
[:what-is-6-x-7]
-
[{:a {:b {:c {:d [:e]}}}}]
(yes, there is only one answer)
(ns book.queries.simple-property-read
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[book.queries.parse-runner :refer [ParseRunner ui-parse-runner]]
[fulcro.client.dom :as dom]))
(defn property-read [{:keys [state]} key params] {:value (get @state key :not-found)})
(def property-parser (prim/parser {:read property-read}))
(defsc Root [this {:keys [parse-runner]}]
{:initial-state {:parse-runner {}}
:query [{:parse-runner (prim/get-query ParseRunner)}]}
(dom/div
(ui-parse-runner (prim/computed parse-runner {:parser property-parser :database {:a 1 :b 2 :c 99}}))))
The result of those nested queries (the last suggestion above) is supposed to be a nested map. So, obviously we have more work to do.
13.3.2. Reading a join
Your state probably has some more structure to it than just a flat bag of properties. Joins are naturally recursive in syntax, and those that are accustomed to writing parsers probably already see the solution.
First, let’s clarify what the read function will receive for a join. When parsing:
{ :j [:a :b :c] }
your read function will be called with:
(your-read { :state state :parser (fn ...) :query [:a :b :c] } ; the environment
:j ; keyword of the join
nil) ; no parameters
But just to prove a point about the separation of database format and query structure we’ll implement this next example with a basic recursive parse, but use more flat data (the following is live code):
(def flat-app-state {:a 1 :user/name "Sam" :c 99})
(defn flat-state-read [{:keys [state parser query] :as env} key params]
(if (= :user key)
{:value (parser env query)} ; recursive call. query is now [:user/name]
{:value (get @state key)})) ; gets called for :user/name :a and :c
(def my-parser (prim/parser {:read flat-state-read}))
The important bit is the then
part of the if
. Return a value that is
the recursive parse of the sub-query. Otherwise, we just look up the keyword
in the state (which as you can see is a very flat map).
Try running the query [:a {:user [:user/name]} :c]
:
(ns book.queries.parsing-simple-join
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[book.queries.parse-runner :refer [ParseRunner ui-parse-runner]]
[fulcro.client.dom :as dom]))
(def flat-app-state {:a 1 :user/name "Sam" :c 99})
(defn flat-state-read [{:keys [state parser query] :as env} key params]
(if (= :user key)
{:value (parser env query)} ; recursive call. query is now [:user/name]
{:value (get @state key)})) ; gets called for :user/name :a and :c
(def my-parser (prim/parser {:read flat-state-read}))
(defsc Root [this {:keys [parse-runner]}]
{:initial-state (fn [params] {:parse-runner (prim/get-initial-state ParseRunner {:query "[:a {:user [:user/name]} :c]"})})
:query [{:parse-runner (prim/get-query ParseRunner)}]}
(dom/div
(ui-parse-runner (prim/computed parse-runner {:parser my-parser :database flat-app-state}))))
The first (possibly surprising thing) is that your result includes a nested object. The parser creates the result, and the recursion naturally nested the result correctly.
Next you should remember that a join implies there could be one OR many results. The singleton case is fine (e.g. putting a single map there). If there are multiple results it should be a vector.
In this case we’re just showing calling the parser recursively. Notice that it in turn will call your read function again. In a real application your data will not be this flat so you will almost certainly not do things in quite this way.
Let’s put a little better state in our application and write a more realistic parser.
13.3.3. A Non-trivial, recursive example
Let’s start with the following hand-normalized application state:
(def app-state (atom {
:window/size [1920 1200]
:friends [[:people/by-id 1] [:people/by-id 3]]
:people/by-id {
1 {:id 1 :name "Sally" :age 22 :married false}
2 {:id 2 :name "Joe" :age 22 :married false}
3 {:id 3 :name "Paul" :age 22 :married true}
4 {:id 4 :name "Mary" :age 22 :married false}}}))
Our friend db→tree
could handle queries against this database,
but let’s implement it by hand.
Say we want to run this query:
(def query [:window/size {:friends [:name :married]}])
From the earlier discussion you see that we’ll have to handle the top level keys one at a time.
For this query there are only two keys to handle: :friends
and :window-size
. So, let’s write a case for each:
(defn read [{:keys [state query]} key params]
(case key
:window/size {:value (get @state :window/size)}
:friends (let [friend-ids (get @state :friends)
get-friend (fn [id] (select-keys (get-in @state id) query))
friends (mapv get-friend friend-ids)]
{:value friends})
nil))
The default case is nil
, which means if we supply an errant key in the query no
exception will happen.
You can try it out below:
(ns book.queries.parsing-recursion-one
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[book.queries.parse-runner :refer [ParseRunner ui-parse-runner]]
[fulcro.client.dom :as dom]))
(def database {:window/size [1920 1200]
:friends #{[:people/by-id 1] [:people/by-id 3]}
:people/by-id {
1 {:id 1 :name "Sally" :age 22 :married false}
2 {:id 2 :name "Joe" :age 22 :married false}
3 {:id 3 :name "Paul" :age 22 :married true}
4 {:id 4 :name "Mary" :age 22 :married false}}})
(defn read [{:keys [state query]} key params]
(case key
:window/size {:value (get @state :window/size)}
:friends (let [friend-ids (get @state :friends)
get-friend (fn [id] (select-keys (get-in @state id) query))
friends (mapv get-friend friend-ids)]
{:value friends})
nil))
(def parser (prim/parser {:read read}))
(def query "[:window/size {:friends [:name :married]}]")
(defsc Root [this {:keys [parse-runner]}]
{:initial-state (fn [params] {:parse-runner (prim/get-initial-state ParseRunner {:query query})})
:query [{:parse-runner (prim/get-query ParseRunner)}]}
(dom/div
(ui-parse-runner (prim/computed parse-runner {:parser parser :database database}))))
Those of you paying close attention will notice that we have yet to need
recursion. We’ve also done something a bit naive: select-keys
assumes
that query contains only keys! What if query followed an ident link to
:married-to
:
[:window/size {:friends [:name :age {:married-to [:name]}]}]
and the database was:
{:window/size [1920 1200]
:friends [[:people/by-id 1] [:people/by-id 3]]
:people/by-id {
1 {:id 1 :name "Sally" :age 22 :married false}
2 {:id 2 :name "Joe" :age 33 :married false}
3 {:id 3 :name "Paul" :age 45 :married true :married-to [:people/by-id 1]}
4 {:id 4 :name "Mary" :age 19 :married false}}}
Now things get interesting, and I’m sure more than one reader will have an opinion on how to proceed. My aim is to show that the parser can be called recursively to handle these things, not to find the perfect structure for the parser in general, so I’m going to do something simple.
The primary trick I’m going to exploit is the fact that env
is just a map, and
that we can add stuff to it. When we are in the context of a person, we’ll add
:person
to the environment, and pass that to a recursive call to parser
.
The example below (with source) shows the result:
(ns book.queries.parsing-recursion-two
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[book.queries.parse-runner :refer [ParseRunner ui-parse-runner]]
[fulcro.client.dom :as dom]))
(def database {:window/size [1920 1200]
:friends [[:people/by-id 1] [:people/by-id 3]]
:people/by-id {
1 {:id 1 :name "Sally" :age 22 :married false}
2 {:id 2 :name "Joe" :age 33 :married false}
3 {:id 3 :name "Paul" :age 45 :married true :married-to [:people/by-id 1]}
4 {:id 4 :name "Mary" :age 19 :married false}}})
; we're going to add person to the env as we go
(defn read [{:keys [parser ast state query person] :as env} dispatch-key params]
(letfn [(parse-friend
; given a person-id, parse the current query by placing that person's data in the
; environment, and call the parser recursively.
[person-id]
(if-let [person (get-in @state person-id)]
(parser (assoc env :person person) query)
nil))]
(case dispatch-key
; These trust that a person has been found and placed in the env
:name {:value (get person dispatch-key)}
:id {:value (get person dispatch-key)}
:age {:value (get person dispatch-key)}
:married {:value (get person (:key ast))}
; a to-one join
:married-to (if-let [pid (get person dispatch-key)]
{:value (parse-friend pid)}
nil)
; these assume we're asking for the top-level state
:window/size {:value (get @state :window/size)}
; a to-many join
:friends (let [friend-ids (get @state :friends)]
{:value (mapv parse-friend friend-ids)})
nil)))
(def parser (prim/parser {:read read}))
(def query "[:window/size {:friends [:name :age {:married-to [:name]}]}]")
(defsc Root [this {:keys [parse-runner]}]
{:initial-state (fn [params] {:parse-runner (prim/get-initial-state ParseRunner {:query query})})
:query [{:parse-runner (prim/get-query ParseRunner)}]}
(dom/div
(ui-parse-runner (prim/computed parse-runner {:parser parser :database database}))))
It can be a little bit of work to build these parsers for the queries (which is why libraries exist so you don’t have to); however, we hope you can see that it is actually pretty tractable to build them once you understand the basics.
Now we’ll move on to another thing that servers typically need in their queries: parameters!
13.4. Parameters
The Fulcro query grammar accept parameters on most elements. These are intended to be combined with dynamic queries that will allow your UI to have some control over what you want to read from the application state (think filtering, pagination, sorting, and such).
As you might expect, the parameters on a particular expression in the query are just passed into your read function as the third argument. You are responsible for both defining and interpreting them. They have no rules other than that they are maps.
To read the property :load/start-time
with a parameter indicating a particular
time unit you might use a query like:
[(:load/start-time {:units :seconds})]
this will invoke read with:
(your-read env :load/start-time { :units :seconds})
the implication is clear. The code is up to you. Let’s add some quick support for this in our read so you can try it out.
The parser below understands the following queries:
[(:load/start-time {:units :seconds})]
[(:load/start-time {:units :minutes})]
[(:load/start-time {:units :ms})]
(ns book.queries.parsing-parameters
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[book.queries.parse-runner :refer [ParseRunner ui-parse-runner]]
[fulcro.client.dom :as dom]))
(def database {
:load/start-time 40000.0 ;stored in ms
})
(defn convert [ms units]
(case units
:minutes (/ ms 60000.0)
:seconds (/ ms 1000.0)
:ms ms))
(defn read [{:keys [state]} key params]
(case key
:load/start-time {:value (convert (get @state key) (or (:units params) :ms))}
nil))
(def parser (prim/parser {:read read}))
(defsc Root [this {:keys [parse-runner]}]
{:initial-state (fn [params] {:parse-runner (prim/get-initial-state ParseRunner {:query "[(:load/start-time {:units :seconds})]"})})
:query [{:parse-runner (prim/get-query ParseRunner)}]}
(dom/div
(ui-parse-runner (prim/computed parse-runner {:parser parser :database database}))))
13.5. Using a Completely Custom Parser
Most of this book has assumed you’ll be using Fulcro’s predefined server parser. Note that you can still do that
and switch out to an alternate (custom) parser at any phase of parsing. You can even install a custom parser in your server
(though then the macros for defining mutations and query handlers won’t work for you).
Parsers can be constructed using the prim/parser
function.
13.6. Parsing on the Client
Fulcro allows you to augment the built-in query parser for local reads on the client. It uses the exact same techniques discussed above, and it is similar in that you must be able to start at the root of the query (even though you may only want to augment something rather deep in the query tree).
Actually, there are two cases that such a custom read must be able to do:
-
Handle the path from root to the point of interest (it can hand off uninteresting side branches to
db→tree
. -
Handle (or safely ignore) ident-based queries for any "virtual" entities that are purely parser-generated.
The first is a limitation of how queries are processed. Fulcro normally runs the query through db→tree
, which attempts to
fill the entire result. If you supply a :read-local
function during client construction, then your :read-local
will
get first shot at each element of the query that was submitted. Note that the query can start at an ident.
If your read-local
function returns nil
for an element, then the normal Fulcro
processing takes place on that sub-query. If your read-local
returns a value, then that is all of the processing that
is done for the sub-tree rooted at that key. Thus, custom client parsing always requires you to process sub-trees of the query, not just
individual elements. Of course, you can use db→tree
at any time to "finish out" some subquery against real data.
This has the advantage of letting you dynamically fill queries without having to have a concrete representation of the graph in your database. This can be helpful if you have some rapidly changing data (e.g. updated by a web worker) and some views of that data that would otherwise be hard to keep up-to-date.
It is also useful if you’re trying to port from Om Next.
14. UI Routing
UI Routing is a very important task in Fulcro. It is the primary means by which you keep your application running quickly. You see, in Fulcro your query is run from root. If your entire application’s query runs on every render frame things can get slow indeed.
The solution is easy: use union queries with to-one relations to ensure only the portions of your query that are active on the UI are processed.
Unfortunately many people find hand-writing union components a litle challenging. Fulcro provides a nice pre-written facility that can write much of the code for you, making the process more conceptual as UI Routing.
Note
|
Fulcro Incubator includes a new routing namespace that is more feature complete, and as of
version 2.6.18 Fulcro’s routing namespace includes defsc-router , which is a replacement for
defrouter .
|
14.1. A Basic Router
A basic router looks like this:
(ns app
(:require [fulcro.client.routing :refer [defrouter defsc-router]]) ; requires 2.6.18 for defsc-router
(defsc Index [this {:keys [db/id router/page]}]
{:query [:db/id :router/page]
:ident (fn [] [page id])
:initial-state {:db/id 1 :router/page :PAGE/index}}
...)
(defsc Settings [this {:keys [db/id router/page]}]
{:query [:db/id :router/page]
:ident (fn [] [page id])
:initial-state {:db/id 1 :router/page :PAGE/settings}}
...)
;; Pre-2.6.18
(defrouter RootRouter :root/router
; OR (fn [t p] [(:router/page p) (:db/id p)])
[:router/page :db/id]
:PAGE/index Index
:PAGE/settings Settings)
;; Post 2.6.18
(defsc-router RootRouter [this {:keys [router/page db/id]]
{:router-id :root/router
:ident (fn [] [page id]) ; props are destructured in the context of the route TARGET
:default-route Index
:router-targets {:PAGE/index Index
:PAGE/settings Settings}}
(dom/div "What to render if the route is bad"))
The newer form lets you define React lifecycle methods, and also lets you define what to render when the route is
invalid (the body is only rendered under that circumstance, otherwise is renders the routed component). It can also
be set to resolve like defsc
in Cursive, so you get proper symbol resolution.
See old versions of the book in Fulcro’s git history for docs on the old defrouter
.
14.1.1. Details on defsc-router
The required parameters of defsc-router
are:
:router-id
-
An ID to give the router
:ident
-
MUST be in
fn
form, and can use destructured parameters fromprops
(which are in the context of the route target) :default-route
-
The component that should be shown by default. Should have initial state so it exists on app start.
:router-targets
-
A map from the component’s Fulcro database table to the component that can be routed to. Routing switches on the FIRST element of the ident (the table name). This is because unions are used, and this allows sub-screens to have ID’s that vary. For example, you could router to
[:user/by-id 44]
by adding:user/by-id User
as an entry to this map and making sure the ident function can resolve the ident correctly for the props of a user.
You may also include most other defsc
options, other than :query
and :initial-state
, which are set internally.
The optional body of defsc-router
is what to render if the route is bad (e.g. the ident in state for the route doesn’t
point to a valid routing target).
(ns book.simple-router-1
(:require [fulcro.client.routing :as r :refer-macros [defsc-router]]
[fulcro.client.dom :as dom]
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :as m]))
(defsc Index [this {:keys [db/id router/page]}]
{<