A consumer is a thin custom element that renders a <server-resource>'s value into a
presentational BareDOM component. You write two small pieces, a pure projection and a render
function, and register them with consumer-resource/register!. The shared mechanism supplies
everything else: the applyResource method, the change-guards, child caching, and gesture
submission.
The value your projection reads is the accepted server envelope, carried on the view described below. Its exact shape is the server contract.
BareBuild ships the runtime and the consumer mechanism, plus two pure validators. Accepted
values are contract-checked automatically upstream, which you never call yourself, and
validation/conform-payload reads a write payload as the shape declares it and reports what
does not fit, before you submit.
It does not ship a generic projection or formatter layer. There is no built-in project,
format-vm, or formatter registry. Turning an accepted value into what the component wants,
including any date, number, or nil formatting, is plain code in your model.cljs projection
and render!. That is the one place per consumer that differs.
A consumer therefore works for any component you can drive by setting attributes or properties from a value. A component with an imperative-only API, canvas rendering, or internal animation state may need a pattern that does not exist yet.
x_<name>_consumer/ ; app code. This repo's demo keeps these under demo/src/demo/
model.cljs ; pure: tag metadata + projection (resource -> view data). Node-tested.
x_<name>_consumer.cljs ; DOM: render + optional hooks + init! -> register!
model.cljs holds tag-name and a pure projection function (accepted response ->
whatever the child needs). No DOM. This is where unit tests live. A consumer with nothing to
project, one that only reacts to the :pending? / :writing? flags as x-spinner-consumer
does, has no pure layer to test and keeps the element file alone.x_<name>_consumer.cljs holds the DOM effects (render, failure UI, loading) and calls
consumer-resource/register! from init!.register! config(consumer-resource/register!
{:tag "x-<name>-consumer" ; the consumer element tag
:child-tag "x-<child>" ; the driven child, cached on connect
:render render! ; optional, but the usual reason to write a consumer
:on-failure on-failure! ; optional
:on-pending on-pending! ; optional
:on-writing on-writing! ; optional
:render-key render-key ; optional
:on-connect on-connect!}) ; optional
A consumer drives its child from the view alone and never from an attribute, so a consumer
element observes none and there is no :observed-attributes to declare.
Every view hook shares one signature: (child view this), and every one is handed the whole
view. child is the cached child element, this is the consumer host. on-connect is handed the
same two, with no view yet.
A hook's slice decides only when it fires, never what it may read. So on-writing sees
:accepted too, and render sees :writing?. Nothing has to be cached on the element to be read
back by a later hook.
| Hook | Fires when… |
|---|---|
:render | the render-key slice of the view changes |
:on-failure | :failure changes. It is nil on recovery, so clear your failure UI |
:on-pending | :pending? changes. Show or hide loading |
:on-writing | :writing? changes. Disable the submit control, and use the true→false edge to close a form on success |
:on-connect | (child this), the consumer element connects, before any view arrives. Wire your listeners here |
Every hook also fires once on the first apply, whatever its slice holds at that moment, so a
hook is always given a starting value rather than only being told about later movement. Write
your hooks to be idempotent: the boot call hands a view whose :failure is nil and whose
:writing? is false.
A DOM listener fires from the DOM, not from an apply, so it is handed no view. Call
(consumer-resource/view this) for the one this consumer last saw, rather than stashing a slice of
it on the element during render.
Within one projection the hooks run in the order of that table: on-failure, then render,
then on-pending and on-writing. The data is painted before the flags describing the
transition that produced it.
The practical consequence: when on-writing fires false, the value that write returned is
already rendered. A component that suppresses work while a write is in flight, a board
holding a reserved drop zone say, can resume on that edge and find the new value in place.
The reverse does not hold, so do not expect render to see a flag that a later hook is about
to set.
render receives a view, and the view is the whole of what a consumer may read:
{:accepted <the accepted envelope, or nil before the first response>
:failure <the current failure, or nil>
:intent <the current query as a map, unprefixed keys>
:pending? <boolean>
:writing? <boolean>
:write <the write last submitted, or nil before any was>}
Everything else in the resource is the runtime's own bookkeeping and is deliberately not handed to you.
:write, and whose write it wasA resource can drive several writing consumers, and a write moves :writing? for all of
them. So :writing? alone cannot answer the question a form actually has, which is whether the
write that just finished was its own.
:write answers it. It is the write this resource last submitted and how that write ended:
{:payload {:op :create :submitter "x-task-form-consumer" :record {...}}
:status :in-flight} ;; then :accepted, :rejected or :failed
submit-write! stamps :submitter with the submitting consumer's tag. The stamp is client-side
only: a request body is built from the payload's :record alone, so it never reaches the server.
Pass your own :submitter in the payload if one tag is not enough to tell two instances apart.
Ask (consumer-resource/own-write? this view) rather than keeping a flag:
(defn- on-writing! [^js form view ^js this]
(let [status (when (consumer-resource/own-write? this view)
(get-in view [:write :status]))]
(if (= :in-flight status)
(du/set-attr! button "loading" "")
(do (du/remove-attr! button "loading")
(when (= :accepted status)
(close-and-clear! form))))))
:status distinguishes the three ways a write ends. :rejected is the server saying no.
:failed is the outcome never being learned, which is not the same thing: the write may have
committed. The runtime reconciles that case with a fresh read, so the value you paint next is
still the server's, but a message you show the user should not claim the write did not happen.
Paint from the view and from nothing else. Reading js/location, document, or any other
ambient state inside a consumer makes it render a mixture of two moments, because a
BareReplay time-travel projection rewinds the view but cannot
rewind the address bar. A consumer that obeys this replays correctly by construction.
:intent is what makes that possible. A gate like "no project is selected yet" is
(some? (:project intent)), not a URLSearchParams lookup.
A view carries its own resource's state. Coordination between resources is deliberately
one-directional: submit-intent! with a target id lets a consumer drive a named sibling,
and there is no counterpart for reading one.
So a consumer that needs to display state owned by a sibling has no supported route to it, and reading the URL is the only way out. That consumer is then outside the replay guarantee: a projection rewinds its view, but not the address bar it is really reading.
x-project-selector-consumer in the demo is exactly this case. It lives in the projects
resource, drives tasks, and has to show the tasks.project selection it just set.
Prefer to model your way around it. A resource should own the state it displays. If a component needs a sibling's state to paint, that usually means the state is modelled on the wrong resource, or the two components should be one per resource rather than one spanning both. Reach for the URL only when neither is possible, and expect that component not to time-travel.
Adding cross-resource reads later would be additive, so nothing written today forecloses it.
render-keyrender fires only when the slice you name changes, so render-key is where you state what
your component actually paints from:
:render-key (fn [{:keys [accepted intent]}]
[(:value accepted) (some? (:project intent))])
It defaults to the accepted envelope, which is right for a component that draws server data and
nothing else. The envelope you get has no :request/id on it: that names the exchange that
fetched the value rather than the value, so two identical refetches compare equal and your
component does not repaint on a poll that changed nothing.
A consumer is projected once at connect, before any response, with :accepted nil. That is
what lets a component paint its empty state from the intent alone. If yours has nothing to draw
without data, say so with a plain (when accepted …).
What you get for free:
:accepted untouched, so render
simply no-ops during failures. The last good view stays on screen.step upstream. The consumer only ever sees the resulting value.The failure your on-failure receives is a value tagged by :cause, one of
#{:rejected :network :protocol :contract}, and by :for, either :read or :write, saying
which kind of request it came from. Every failure also carries the :query it concerns.
Dispatch on the tag, and for a :network failure read :error to tell the kinds apart:
(defn- message [failure]
(case (:cause failure)
:rejected (get-in failure [:response :error :message]) ; the server's rejection message
:network (case (get-in failure [:error :kind])
:http-status (case (get-in failure [:error :status])
(401 403) "Your session has expired." ; re-auth here
404 "Not found."
"Server error, please try again.")
:timeout "The server took too long, please try again."
:decorator "Couldn't sign you in, please try again."
"Couldn't reach the server.") ; :offline
:protocol "The server sent an unexpected response."
:contract "The server's data did not match the expected format."))
:network carries :error {:kind ...}: :offline for a transport failure (the request never
reached a server), :http-status with a :status code for a non-ok response, :timeout with
the :after budget in milliseconds when the request outlived its
timeout, and :decorator
when a registered request decorator could not
produce its headers, in which case the request was never sent at all. This is how a
consumer tells "you are offline" from "your session expired" (401). BareBuild surfaces the failure,
what a 401 means (redirect to login, refresh a token and re-submit intent) is your app's call.
A non-ok response is always a :network/:http-status failure, a query the server rejects comes
back as a normal 2xx envelope with :outcome :rejected, not an HTTP error status. For attaching
the credential in the first place, see
request configuration.
In a DOM event handler, translate the gesture into an intent patch (a model function) and
submit it:
(defn- on-sort [^js e]
(let [consumer (.closest (.-currentTarget e) "x-<name>-consumer")
patch (model/translate-gesture …)] ; {:query-patch {…} :gesture-class :refinement}
(consumer-resource/submit-intent! consumer patch)))
A gesture is only submittable once the host has booted, which it does after every custom element inside it is defined. Your listeners are live before that, so a gesture fired in the gap is reported to the console and dropped rather than thrown out of your handler.
:gesture-class is :refinement (-> replace history) or :navigation (-> push history), and
step resolves it to a URL-write mode. This mapping is fixed: a :navigation gesture pushes a
history entry, everything else replaces. It is not configurable per resource. Display-only
consumers have no gestures.
A read follows an intent patch only when the intent moved. When the gesture is "ask again" rather than "ask something else" — a reload button, a retry after an error — there is nothing to patch, and inventing a field to perturb would put a non-fact in the URL:
(defn- on-reload [^js e]
(consumer-resource/submit-refresh! (.closest (.-currentTarget e) "x-<name>-consumer")))
submit-refresh! re-reads the current intent. The intent does not move, the URL is not written,
and what the resource holds stands until an answer replaces it. A read already in flight is the
answer to that request, so a second one is not opened on top of it.
A write is the same shape of gesture, submitted with submit-write! instead:
(defn- on-delete [^js e]
(let [consumer (.closest (.-currentTarget e) "x-<name>-consumer")]
(consumer-resource/submit-write! consumer {:op :delete :id 42})))
Four ops, each one payload. record is a map keyed by the shape's field keys (opaque
domain strings, not keywords), and id is the value of the row's idKey:
| Op | Payload | Request |
|---|---|---|
:create | {:op :create :record {…}} | POST <endpoint> |
:update | {:op :update :id <id> :record {…}} | PUT <endpoint>/<id>, a full replace of every field the shape declares |
:move | {:op :move :id <id> :record {"status" … "index" …}} | PATCH <endpoint>/<id>, a positional command carrying only the destination |
:delete | {:op :delete :id <id>} | DELETE <endpoint>/<id> |
step turns the payload into a :write effect, and the ack comes back as :write-ack. An
accepted ack already carries the server's new collection state (value + shape), which step
installs directly, so no separate refetch is issued. You never render a write's result
yourself. It arrives on the view's
:accepted and reaches you through render like any other accepted value. Writes never touch
the URL.
A write that fails without the server saying no is re-read automatically. If the connection
drops, the budget runs out, or the body comes back unreadable, the client cannot know whether the
write committed, so it fetches the collection again and your render is called with whatever the
server actually has. Your on-failure still fires first, so tell the user the write failed, but
do not assume the old view is still accurate while you do. Only a :rejected ack, the server
explicitly refusing, skips the re-read, because there its answer is already definitive.
That re-read does not retire the write failure. A read answers the read and says nothing
about whether a write committed, so the report survives the refetch that follows it and your
failure UI stays up until something actually answers it: a later write that succeeds, or the
user dismissing it. A read succeeding only retires a :read failure.
Conform a create payload before submitting, against the shape the server sent:
(let [{:keys [record errors]} (validation/conform-payload form-values shape)]
(if (seq errors)
(doseq [{:keys [field message]} errors] (.setFieldError form field message))
(consumer-resource/submit-write! consumer {:op :create :record record})))
conform-payload returns the record to send and the errors to show. It reads each value as
the type its field declares first, so a number typed into a form arrives as a number rather
than being reported as the wrong type.
Each error is {:field :code :message} with :code one of :missing-required,
:wrong-type, :not-in-enum. This is a UX shortcut only. The server re-validates and is
the authority. A server-side rejection arrives through :on-failure as a :rejected
failure whose error.details names the offending field, so map that back onto the form the
same way.
init!,
alongside the driven BareDOM component's init!. Then call barebuild.core/init to
install <server-resource> (the BareBuild runtime). Consumers are app code. They are
never added to barebuild.core, which registers only <server-resource>. In this
repo's demo all of that lives in demo.app (demo/src/demo/app.cljs). In
production the driven BareDOM component is a peer dependency the host page loads.<server-resource> and nest the child
inside it. collect-consumers walks all descendants, so wrapping consumers in layout
elements is fine. A nested <server-resource> keeps its own:
<server-resource src="…">
<x-«name»-consumer>
<x-«child»></x-«child»>
</x-«name»-consumer>
</server-resource>
One <server-resource> fans out to any number of consumers.
Minimal, x-stat-consumer (display-only scalar): project-stat (model) + a render-key
naming that same projection + a one-line render! (set the value attr). ~15 lines.
Flags only, x-spinner-consumer: no model projection and no render!. on-pending and
on-writing share one handler that shows the spinner while the resource is reading or writing.
The one to copy for a consumer that draws no server data.
Full, x-table-consumer: accepted-response->view-model + gesture translators (model),
then render! (build x-table-row/x-table-cell children + pagination), on-failure! (an
x-alert), sort/page gestures via submit-intent!, and row delete via submit-write!.
Writing, x-task-form-consumer: populates an x-form from the shape, conforms the
payload locally, submits with submit-write!, uses on-writing! to disable the submit
button and close the modal on success, and on-failure! to map a server rejection back onto
the offending field.
Intent-driven, x-board-consumer: paints from the accepted rows and from whether a
project is selected, so its render-key names both and its empty gate reads (:project intent)
rather than the URL. It is the reference for a component whose view is not server data alone.
All of them are driven by the same single consumer_resource.cljs. The difference between
them is exactly their projection and their hooks.
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 |