Purpose: Core Fulcro concepts - the "why" behind the patterns. Scope: Core Fulcro only. See FULCRO-RAD.md for RAD extensions.
- Mental Model
- Normalization and Idents
- EQL Queries
- Initial State
- Data Loading
- Mutations
- Component Rendering
- Common Pitfalls
Fulcro is a graph database UI framework. Think of it as:
┌─────────────────────────────────────────────────────────────┐
│ Normalized State │
│ (Single source of truth - like a local database) │
│ │
│ {:person/id {1 {...} 2 {...}} │
│ :film/id {1 {...} 2 {...}}} │
└─────────────────────────────────────────────────────────────┘
▲
│ Queries (EQL)
│
┌─────────────────────────────────────────────────────────────┐
│ Components │
│ (Declare what data they need, receive denormalized props) │
└─────────────────────────────────────────────────────────────┘
Key insight: Components don't fetch data. They declare what they need (query), and Fulcro denormalizes the graph to provide props.
| Concept | Redux | Fulcro |
|---|---|---|
| State shape | You design it | Normalized by default |
| Data fetching | Manual (thunks, sagas) | Declarative (load!) |
| Query language | None (selectors) | EQL |
| Server integration | Separate | Same query language |
| Concept | Re-frame | Fulcro |
|---|---|---|
| State | Flat app-db | Normalized graph |
| Subscriptions | Manual | Automatic from queries |
| Data fetching | Effects | Built-in load! |
| Normalization | Manual | Automatic |
Fulcro stores app state as a normalized graph database. This is the core concept.
Without normalization - data duplicated, updates are error-prone:
{:current-user {:user/id 1 :user/name "Alice"}
:messages [{:message/author {:user/id 1 :user/name "Alice"}} ; duplicate!
{:message/author {:user/id 1 :user/name "Alice"}}]} ; duplicate!With normalization - single source of truth:
{:user/id {1 {:user/id 1 :user/name "Alice"}} ; stored once
:message/id {1 {:message/author [:user/id 1]} ; reference
2 {:message/author [:user/id 1]}}} ; same referenceUpdate Alice's name once → all references see the change.
An ident is [<table-key> <id-value>] - a pointer into the normalized state:
[:person/id "1"] ; Points to person with ID "1"
[:film/id "4"] ; Points to film with ID "4"Components declare their ident to enable normalization:
(defsc Person [this {:person/keys [name]}]
{:query [:person/id :person/name]
:ident :person/id} ; ⭐ Keyword shorthand (recommended)
(dom/div name))Critical rule: Always use keyword shorthand for :ident. The function form (fn [] ...) runs during normalization before props exist - a common source of bugs.
Components declare what data they need using EQL (EDN Query Language).
;; Properties
[:person/name :person/height]
;; Joins (nested data)
[{:person/films [:film/title]}]
;; Ident lookup (specific entity)
[{[:person/id "1"] [:person/name]}]
;; Parameterized
[({:search/results [:entity/name]} {:term "luke"})]Child queries compose into parent queries automatically:
(defsc Film [this {:film/keys [title]}]
{:query [:film/id :film/title]
:ident :film/id}
...)
(defsc Person [this {:person/keys [name films]}]
{:query [:person/id :person/name
{:person/films (comp/get-query Film)}] ; ⭐ Include child query
:ident :person/id}
...)When data contains idents (references), the query follows them:
;; State has reference:
{:person/id {"1" {:person/name "Luke"
:person/homeworld [:planet/id "1"]}}} ; ident reference
;; Query with join follows reference:
[:person/name {:person/homeworld [:planet/name]}]
;; Props received (denormalized):
{:person/name "Luke"
:person/homeworld {:planet/name "Tatooine"}}Components can declare initial state for pre-populating the normalized database:
(defsc Person [this {:person/keys [name]}]
{:query [:person/id :person/name]
:ident :person/id
:initial-state {:person/id :param/id ; :param/* pulls from args
:person/name :param/name}}
...)
;; Used by parent:
(defsc Root [this {:keys [current-person]}]
{:query [{:current-person (comp/get-query Person)}]
:initial-state {:current-person {:id "1" :name "Default"}}}
...)When to use: Pre-loading UI state, default values, component-local UI state (:ui/* keys).
Fulcro provides df/load! to fetch data from the server:
(require '[com.fulcrologic.fulcro.data-fetch :as df])
;; Load into a specific ident (entity by ID)
(df/load! app [:person/id "1"] Person)
;; Load into a root key
(df/load! app :all-people PersonList)
;; Load with parameters
(df/load! app :search-results SearchResult
{:params {:term "luke"}})df/load! called
↓
EQL query sent to server (via Pathom)
↓
Response normalized into state
↓
Components re-render with new data
Track loading state with markers:
(df/load! app :people PersonList
{:marker :loading-people})
;; In component, check marker:
(when (df/loading? (get props :ui/loading-people))
(dom/div "Loading..."))Mutations are how you change state. They can be local-only or remote.
(defmutation set-name [{:keys [id name]}]
(action [{:keys [state]}] ; Local state change
(swap! state assoc-in [:person/id id :person/name] name))
(remote [env] true)) ; Also send to server
;; Call from component:
(comp/transact! this [(set-name {:id "1" :name "New Name"})])The action runs immediately (optimistic). If remote returns true, it's sent to server. On error, Fulcro can roll back.
(defmutation save-entity [params]
(action [{:keys [state]}]
(swap! state assoc-in [...] {:saving? true}))
(remote [env] true)
(ok-action [{:keys [state]}] ; Server succeeded
(swap! state assoc-in [...] {:saving? false}))
(error-action [{:keys [state]}] ; Server failed
(swap! state assoc-in [...] {:saving? false :error true})))Components receive denormalized props based on their query:
(defsc Person [this {:person/keys [name films]}]
{:query [:person/id :person/name
{:person/films [:film/id :film/title]}]
:ident :person/id}
;; `films` is a vector of denormalized film maps, not idents
(dom/div
(dom/h1 name)
(dom/ul
(map #(dom/li {:key (:film/id %)} (:film/title %)) films))))The component doesn't see idents - Fulcro resolves them automatically based on the query.
Wrong - props not available during normalization:
:ident (fn [] [:person/id (:person/id props)]) ; ❌Right - keyword shorthand:
:ident :person/id ; ✅If data isn't normalizing, check that the component has :ident:
(defsc Person [this props]
{:query [:person/id :person/name]
:ident :person/id} ; ⭐ Required for normalization
...)Always handle nil/loading states:
(defsc Person [this {:person/keys [name]}]
{:query [:person/name]}
(if name
(dom/div name)
(dom/div "Loading...")))If data exists but component doesn't see it, check query includes the key:
;; State has :person/email but component doesn't query it
{:query [:person/name]} ; ❌ won't receive :person/email
{:query [:person/name :person/email]} ; ✅