Fulcro's full-stack operation model unifies server interaction into a clean, data-driven structure. Once you understand the core primitives (component-based queries, idents, and normalization), server interactions become straightforward and predictable.
:ui/
namespaced query elements automatically removed from server queries;; Example: UI query with mixed concerns
[:user/id :user/name :ui/selected? {:user/posts (comp/get-query Post)}]
;; Automatically becomes server query (ui/ elided)
[:user/id :user/name {:user/posts (comp/get-query Post)}]
CRITICAL: In EQL, mutations are represented as symbols, not keywords. This is different from queries, which use keywords.
;; ❌ WRONG: Using a keyword
[(create-person {:name "Alice"})] ; Won't work!
;; ✅ CORRECT: Using a symbol (no namespace colon)
[(create-person {:name "Alice"})] ; This is a symbol!
;; Server response is also keyed by SYMBOL
{create-person {:person/id 42 :person/name "Alice"}}
; ^
; └─ Symbol as key, not :create-person keyword!
Application startup data loading
Sub-graphs of previously loaded data
User interactions or timer-triggered data fetching
Server push, WebSocket data, third-party APIs
Server-side mutations with optional data responses
Because Fulcro normalizes all data into a flat graph database, targeting NEVER requires deep paths. Maximum depth is 3 elements: [table-name id field]
;; Nested UI structure (arbitrarily deep)
Root → MainPanel → UserProfile → FriendsList → Person
;; ❌ You DON'T need deep paths:
{:target [:component/id :root :main-panel :user-profile :friends-list :friends]}
;; ✅ You only need this:
{:target [:component/id :friends-list :friends]}
;; Why? Normalization flattens everything!
Components can have constant idents (singletons/panels), making them perfect load targets:
(defsc FriendsList [this {:keys [friends]}]
{:query [{:friends (comp/get-query Person)}]
:ident (fn [] [:component/id :friends-list])} ; Constant ident!
...)
;; ❌ WRONG: This writes to ROOT, not to component
(df/load! this :friends Person)
;; Result: {:friends [...]} at ROOT
;; ✅ CORRECT: Use explicit :target
(df/load! this :friends Person
{:target [:component/id :friends-list :friends]})
;; Now the idents are placed at the component's location
All external data integration uses the same mechanism: query-based merge.
Query → Server → Response + Original Query → Normalized Data → Database Merge → New Database
External Data + Query → Normalized Data → Database Merge → New Database
merge!
Tree of Data + Query → merge! → New Database
;; Run abstract (possibly full-stack) changes
(comp/transact! this [(some-mutation {:param "value"})])
(comp/transact! app [(some-mutation {:param "value"})])
;; Merge tree of data via UI query
(merge/merge! app tree-data query)
;; Merge using component instead of query
(merge/merge-component! app User user-data)
;; Merge within a mutation (using swap!)
(merge/merge* state tree-data query)
;; In a component event handler
(defn handle-create-user [this user-data]
(comp/transact! this [(api/create-user user-data)]))
;; In a WebSocket message handler
(defn handle-user-update [app user-data]
(merge/merge-component! app User user-data))
;; In a mutation
(defmutation update-local-data [new-data]
(action [{:keys [state]}]
(merge/merge* state new-data [{:updated-items (comp/get-query Item)}])))
UI needs may not match server data structure directly.
Example: "All people who've had a particular phone number"
Query Parser on Server (Preferred)
Well-known Root Keywords
Client-side Morphing
;; Client sends UI-based query
[{:people-by-phone (comp/get-query Person)}]
;; Server (with Pathom) resolves to actual data structure
;; Returns tree matching the query shape
{:people-by-phone [{:person/id 1 :person/name "Alice"}
{:person/id 2 :person/name "Bob"}]}
;; Single event - may be batched together
(defn handle-click [this]
(df/load! this :users User) ; Request 1
(df/load! this :posts Post)) ; Request 2 - may batch with Request 1
;; Separate events - guaranteed sequential
(defn handle-first-click [this]
(df/load! this :users User)) ; Request 1
(defn handle-second-click [this]
(df/load! this :posts Post)) ; Request 2 - waits for Request 1
Fulcro automatically reorders writes before reads in the same processing event:
;; This transaction
[(create-user {:name "Alice"})
(df/load! :users User)]
;; Becomes: create-user first, then load users
;; Ensures the load sees the newly created user
;; Force parallel processing
(df/load! this :users User {:parallel true})
(comp/transact! this [(some-mutation)] {:parallel? true})
Different views of the same entity may have different query depths:
;; List view query (minimal)
[:person/id :person/name {:person/image (comp/get-query Image)}]
;; Detail view query (comprehensive)
[:person/id :person/name :person/age :person/address
{:person/phones (comp/get-query Phone)}
{:person/image (comp/get-query Image)}]
Fulcro uses an advanced merging algorithm:
;; Current database state
{:person/id {1 {:person/id 1
:person/name "Alice"
:person/age 30
:person/phone "555-1234"}}}
;; List refresh query: [:person/id :person/name]
;; Server response: {:person/id 1 :person/name "Alice Smith"}
;; Result after merge
{:person/id {1 {:person/id 1
:person/name "Alice Smith" ; Updated
:person/age 30 ; Preserved (not in query)
:person/phone "555-1234"}}} ; Preserved (not in query)
The merge algorithm can create states that never existed on the server:
;; Default: HTTP status code based
;; 200 = success, anything else = error
(def app
(app/fulcro-app
{:remote-error? (fn [result]
(or (not= 200 (:status-code result))
(contains? (:body result) :error)))}))
IMPORTANT: Do not couple loads to React lifecycle methods (like componentDidMount
, useEffect
, etc.). Logic should not be tied to UI lifecycle. Instead:
;; ❌ DON'T: Loads in lifecycle methods
(defsc MyComponent [this props]
{:componentDidMount (fn [this] (df/load! this :data SomeComponent))}
...)
;; ✅ DO: User-triggered loads
(defsc MyComponent [this {:keys [data]}]
{:query [{:data (comp/get-query SomeComponent)}]
:ident (fn [] [:component/id :my-component])}
(dom/div
(if (seq data)
(ui-some-component data)
(dom/button
{:onClick #(df/load! this :data SomeComponent
{:target [:component/id :my-component :data]})}
"Load Data"))))
;; ✅ DO: Mutation-based conditional loading
(defsc PersonRow [this {:person/keys [id name]}]
{:query [:person/id :person/name]
:ident :person/id}
(dom/div
{:onClick #(comp/transact! this [(select-person {:person/id id})])}
name))
(defmutation select-person [{:person/id id}]
(action [{:keys [state app]}]
;; Check state and conditionally load
(swap! state assoc-in [:component/id :main-panel :current-person]
[:person/id id])
(let [person (get-in @state [:person/id id])
needs-details? (not (contains? person :person/age))]
(when needs-details?
(df/load! app [:person/id id] PersonDetail))))
(remote [_] false))
(defn load-users [this]
(df/load! this :users User
{:target [:users/list]
:post-mutation `users-loaded}))
(defn handle-websocket-message [app message]
(case (:type message)
:user-update
(merge/merge-component! app User (:user message))
:new-notification
(merge/merge! app
{:new-notification (:notification message)}
[{:new-notification (comp/get-query Notification)}])))
(defmutation create-and-load-user [user-params]
(action [{:keys [state]}]
;; Optimistic update
(merge/merge-component* state User
(merge user-params {:ui/creating? true})))
(remote [env]
;; Server mutation
true)
(result-action [{:keys [state result]}]
;; Handle server response
(let [new-user (:create-user result)]
(merge/merge-component* state User new-user)
(targeting/integrate-ident* state [:user/id (:user/id new-user)]
:append [:users/list]))))
This unified approach to full-stack operation makes Fulcro applications predictable, testable, and maintainable while handling the complexities of distributed systems transparently.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
Ctrl+k | Jump to recent docs |
← | Move to previous article |
→ | Move to next article |
Ctrl+/ | Jump to the search field |