Liking cljdoc? Tell your friends :D

Components

The contract

A component <group>.<name> is two namespaces under a search-path prefix:

FileMust defineRole
<group>/<name>.clj(defn <name> [ctx] …)pure render: ctx → hiccup, or an effects list
<group>/<name>_controller.cljmachinethe state machine (see State machines)

The render fn is named after the last namespace segment. Anything else is a 400 on one-shot events and a silent reconnect loop on connected ones (Gotchas).

A different controller for the same render fn can be selected per request through the four-segment route: /ds/nds/<component>/<id>/<controller>/<event>, where <controller> is the controller namespace's last segment (for example counter-page-controller).

Events

POST /ds/nds/<component>/<component-id>/<event>. The event name is upper-cased into the machine's keyword: increment → :INCREMENT, save_edit → :SAVE_EDIT.

Event params are the request's query/form params plus every key of the JSON body that is not this component's own slice (so other components' signals ride along). They reach the machine as the second argument of :actions and :cond functions, with query-string values as strings.

"data-on:click" "@post('/ds/nds/todomvc.todomvc/todos1/toggle?id=3')"
;; → (fn [ctx {:keys [id]}] (parse-long id))

The context

The render fn and the machine's functions see one map:

KeyWhat
:statethe FSM state, a keyword or a vector of keywords for nested states
:memorythe component's own slice of the signal store, JSON-shaped
:component-idthe instance id — also the DOM id and the signal namespace
:component-routethe component name ("counter.counter") for building URLs
:logged-userwhatever your middleware put under [:params :logged-user]
:request-method:get / :post, for transitions that must not happen on a GET
:app-configpresent only if the request carried one

Only :memory and :state survive between requests — they are what the browser sends back. Everything else is rebuilt each request from the request itself, and anything an action adds at top level is gone on the next event. That is a feature: top-level ctx is the right home for lazily loaded display data you do not want echoed back from the client.

:memory, the signal store and data-signals

Put (ds/ctx->data-signals ctx) in the data-signals attribute of the component's root element. It serializes {<component-id> (assoc memory :state state)} as JSON; Datastar keeps one store per page, namespaced by component-id, and sends the whole store with every request.

Rules that follow from this:

  • JSON-shaped values only in :memory: numbers, strings, booleans, nil, vectors, maps of those. A java.util.Date comes back as a string; a keyword as a string.
  • Bind inputs to scalars. data-bind="<cid>.<key>" writes the field straight into :memory. Use camelCase keys for anything referenced in a Datastar expression ($todos1.newTodo is an identifier; new-todo is a subtraction).
  • Removed keys are sent back as null: when an action dissocs a memory key, the pipeline re-adds it as nil so the client deletes the signal. A test that compares :memory after a removal must expect the nil.
  • Keys the server never seeded but a Datastar expression references are auto-created as "", which is truthy in Clojure. Seed every key you read.

What a render fn may return

Hiccup. The whole component, root element carrying the component-id. One-shot responses are text/html; Datastar morphs the response into the element with the same id. Over an SSE stream the same hiccup is sent as a patch-elements event.

An effects list — a vector of vectors, when one morph is not enough:

[[:patch [:div#form …] {}]                                 ; morph by id, or target with opts
 [:patch [:li …] {:d*/selector "#list" :d*/patch-mode :d*/pm-append}]
 [:patch-signals {:todos1 {:editingId nil}} {}]            ; change signals without touching the DOM
 [:execute-script "document.getElementById('x').focus()" {}]]

Option keys and values written as :d*/… keywords are resolved against starfederation.datastar.clojure.api (:d*/selector, :d*/patch-mode, :d*/pm-append, :d*/pm-inner, …). An effects list is answered as a one-shot SSE response with one event per effect; a list with no patch at all is answered with an empty body.

:broadcast is not a render effect — the one-shot path ignores it. It belongs to send-ui!.

Loading states

Invokes are synchronous, so never fetch in INIT. Render a skeleton whose data-init posts a LOAD event, and do the work in the state LOAD enters — first paint is instant and the slow request is a second round trip. For feedback while a request is in flight, put data-indicator="_busy" on the triggering element and bind data-show / data-attr:disabled to $_busy; the underscore keeps that signal client-local. Example 6 shows both.

Full-page renders at a canonical URL

(let [{:keys [html]} (ds/process-component-event req "home.home" "home" "INIT" nil)]
  (ds/html-response html))

Runs the pipeline in process (discovery → transition → render) so GET / can serve a page component directly instead of redirecting into /ds/…. (The examples app goes one step further: its home page is a component reached by a GET on its own INIT route, so it needs no page route.)

Discovery and the first request

Component namespaces are required lazily, on the first request that needs them, on the request thread. The cache holds namespace symbols, never vars, so REPL redefinitions are picked up. set-component-search-paths! clears the cache; warm-component-cache! pre-loads a list of components so the first click does not pay the load cost.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close