Signals
Signals are Datastar's client-side state: named values that data-*
attributes read and write, with dependency tracking so that changing one value re-runs exactly
the bindings that read it. Not more, not fewer. Exactly. They also ride along on every request,
like a kid brother you weren't allowed to leave at home. This part covers both halves: what
signals do in the browser, and what the server receives.
So far, in parts 1 and 2, the server has been doing all the pushing: it shoves HTML into the
page and the page takes it. But some interactions don't need the server at all. Changing a
quantity from 1 to 2 should update "Your drinks" and the draft summary right now, and a round
trip for that would be silly. Other interactions need the browser to tell the server something:
which drink, which size, how many. Signals do both jobs. They hold the values the page reacts to,
and they are what @get serializes into the datastar parameter. Here's
what signals do not do, and it's the most important sentence in this part: they don't
decide anything. The price of the draft, and whether it's a valid order at all, come back from
the server as HTML.
The draft form
Before a Brew Board customer adds a drink to the cart, they configure it. Here is that form as
lesson 3 renders it. Every attribute that starts with data- is Datastar's; the rest
is plain old HTML.
(def initial-signals
{:drink "latte" :size "medium" :milk "oat" :qty 1
;; Underscore: the browser keeps it, @get never sends it.
:_loading false})
(def summary-expr
"$qty + ' × ' + $size + ($milk ? ' ' + $milk : '') + ' ' + $drink")
[:div#app {:data-signals (json/generate-string initial-signals)}
[:div#draft {:data-computed:_summary summary-expr
:data-computed:_unit "$qty == 1 ? 'drink' : 'drinks'"
:data-class:busy "$_loading"}
[:h2 "Your " [:span {:data-text "$_unit"}]]
[:label "Drink " [:select {:data-bind:drink ""} …]]
[:label " Size " [:select {:data-bind:size ""} …]]
[:label " Milk " [:select {:data-bind:milk ""} …]]
[:label " Qty " [:input {:type "number" :min 1 :max 10 :data-bind:qty ""}]]
[:p "Draft: " [:span {:data-text "$_summary"}]]
[:button {:data-on:click "@get('/quote')"
:data-indicator:_loading ""
:data-attr:disabled "$_loading"}
"Ask the server"]
[:p#quote.muted "Ask the server for a price."]
[:h3 "What @get('/quote') sends (no _ signals)"]
[:pre {:data-json-signals "{exclude: /(^|\\.)_/}"}]]]
From lesson03.signals/page, with the option lists elided. The
selects are static here; part 4 makes the size and milk lists the server's job, which turns out
to be more interesting than it sounds.
That's a lot of attributes. Let's go through them one at a time:
data-signalsmerges an object into the store. Its value is an expression, so JSON works (JSON is a valid JavaScript object literal, a happy accident we get to exploit). A keyed form exists too:data-signals:qty="1". Careful, though: the value is evaluated, so a string needs inner quotes:data-signals:drink="'latte'". Forget them and Datastar evaluateslatteas code, not as text.data-computed:_summarycreates a read-only signal whose value is the expression. It recomputes when something it read changes. One medium oat latte reads "1 × medium oat latte". It's pure formatting: it moves signal values into one string and decides nothing._unitis the same kind of thing, "drink" or "drinks", because nobody wants to read "1 drinks". Both names start with_, so neither is sent to the server (more on that in the next section but one). Keys keep a leading underscore: the camel-casing inutils/text.tsonly rewrites-xtoX.data-bind:qtywires an input to a signal in both directions. Here's a nice touch: the type follows the signal.qtystarted as the number 1, so typing "2" stores the number 2, not the string. For aselect, the binding listens forchange. For a number or range input it listens forinput, so it updates on every keystroke.data-text,data-show,data-class:busy,data-attr:disabled, anddata-json-signalsare effects (data-show, not used on this page, is another). They all play the same game. Each evaluates its expression and writes the result into the element (text content,display: none, a class, an attribute, a JSON dump), and does it again whenever the expression's inputs change.data-on:clickruns an expression when the event fires. No surprises there.data-indicator:_loadingsets$_loadingtotruewhile a request is in flight and back tofalseafter. And here's the catch that gets everybody once: it only counts requests started by the element it is on.indicator.tscompares the fetch event's element with its own and ignores the rest. Put it on the button that has thedata-on:click, or it never turns on, and you'll stare at it wondering why. The lesson also declares_loading falseup front, because#draft'sdata-class:busyreads it before the button has been processed.
Why the signals are on a different element
You may have noticed the signals are declared on div#app, with everything that
reads them inside it. That's not a style preference. It's a workaround for a collision between
two perfectly reasonable behaviors. Datastar applies an element's data-*
attributes in the order they appear in the source (applyEls in
engine.ts loops over el.dataset), and it processes elements in
document order, parents before children. Hiccup, however, writes attributes sorted by name:
{:data-signals … :data-computed:_summary …} comes out as
data-computed:_summary="…" data-signals="…". Oops. On one element, the computed
would be set up before the signals it reads even exist. Putting data-signals on an
ancestor makes the question go away: by the time Datastar reaches anything inside
#app, the store is populated. Make that a habit. One element declares, its
descendants use.
And while we're here, three rules about names that will save you real debugging time. First,
attribute keys are converted to camelCase, so data-bind:customer-name creates
$customerName. In an expression, though, $customer-name means
$['customer-name'] (the rewrite rules in engine.ts say so
explicitly), which is a different signal. Use camelCase in expressions, and put spaces
around minus signs: $qty-1 is the signal qty-1, while
$qty - 1 is arithmetic. Yes, really. Second, reading a signal that doesn't exist
creates it with the value '', so a typo gives you an empty string instead of an
error. Silent failure: everybody's favorite. Third, a name that starts with an underscore is
private to the browser. That's the next section.
What re-runs when a signal changes
Look at that markup again. Nothing in it says "when qty changes, update the
summary". So how does Datastar know? It works that out by watching what each expression reads.
Its store, in engine/signals.ts, follows the alien-signals algorithm (the internal
types are named AlienSignal, AlienComputed, and
AlienEffect, which is the best naming in the codebase). Simplified, it works like
this:
run(effect)
activeSub = effect -- "whoever reads now, reads on my behalf"
effect.deps = [] -- dependencies are collected fresh on every run
effect.fn() -- each signal read does: link(signal, activeSub)
activeSub = previous
write(signal, v)
if signal.value === v -> return -- same value: nothing is notified
signal.value = v
for each sub of signal.subs
match sub
case Computed -> mark dirty; propagate to its subs -- recomputed lazily, when read
case Effect -> queue it
if not inside a batch -> flush the queue -- data-on handlers run inside a batch
Two consequences matter for writing pages. One: a binding re-runs only when a signal it
actually read on its last run changes. And since dependencies are collected on every run, a
conditional expression can change its own dependencies: $open ? $a : $b reads
$open and one of the other two, and is linked only to what it read last time.
Clever, right? Two: writing the value a signal already has does nothing at all. That's why a
server can patch the same signals over and over without making the page lift a finger.
There's one more consequence, and it's less obvious. An effect that reads a computed re-runs
only if the computed's value changed. Say you change the quantity from 2 to 3.
$_unit has to recompute, because it read $qty, but it comes out as
"drinks" again. The heading's data-text reads only $_unit, so it
doesn't run. ($_summary does change, so its binding runs.) The store checks whether
a dirty computed actually changed before running anything that depends on it. It's lazy in the
very best sense of the word.
Try it yourself. Each button writes one signal. Accent marks the write, teal marks what re-ran, and the rest stayed put, minding their own business.
What travels to the server
When the button runs @get('/quote'), the fetch plugin collects the signals it is
allowed to send and serializes them. Who's allowed? The rule is in fetch.ts: the
default filter is include /.*/, exclude /(^|\.)_/. Any signal whose
path has a segment that starts with _ stays in the browser. Note that computed
signals get no special treatment. If the lesson had named it
data-computed:summary, the summary string would go to the server with every single
request. The underscore is the only thing keeping it home.
For the draft above, the request is
GET /quote?datastar=%7B%22drink%22%3A%22latte%22%2C%22size%22%3A%22medium%22…
-- decoded:
{"drink":"latte","size":"medium","milk":"oat","qty":1}
$_loading, $_summary, and $_unit are not sent. That's
the whole point of the underscore: they exist for this tab's display, and the server has no use
for them whatsoever. The lesson's page prints both views live with
data-json-signals: everything in the store, and the same filter the request uses.
You can override the filter per request with
@get('/quote', {filterSignals: {include: /^drink$/}}), but the default is almost
always what you want. Almost.
Reading signals in Clojure
Now let's switch sides and look at what the server gets. For GET and DELETE the JSON is in the
datastar query parameter. For POST, PUT, and PATCH it is the request body, with
Content-Type: application/json. And that's the whole contract (it's the
ReadSignals table in the ADR). Two cases. That's it:
(defn read-signals
"Read the signals Datastar sent. GET and DELETE send them as JSON in the datastar query
parameter; other methods send a JSON body. Return a map with keyword keys. Bad input gives {}:
anyone can send any text, so it must not cause a 500."
[{:keys [request-method query-params body]}]
(let [raw (if (#{:get :delete} request-method)
(get query-params "datastar") ; query-params: ring's wrap-params
(some-> body slurp))
parsed (when-not (string/blank? raw)
(try (json/parse-string raw true) (catch Exception _ nil)))]
(if (map? parsed) parsed {})))
The last two lines matter more than they look. Remember, the datastar parameter
is just text, and anyone can type anything into a URL. ?datastar={oops would make
cheshire throw, and ?datastar=[1,2] parses to a vector that the handler's
destructuring can't use. Either one would be a 500, which is a rude thing to hand somebody for a
typo. Treating both as "no signals" sends them down the normal path, where validation answers
"Pick a drink." The demo sends exactly that request, just to prove it.
And here's the handler behind "Ask the server". The server prices the draft from its own menu,
validates the combination, and answers with one element: #quote, holding either
the price or the reason the draft isn't valid.
(defn ->draft
"Turn signals (strings from the browser) into a draft with keywords."
[{:keys [drink size milk qty]}]
{:drink (some-> drink not-empty keyword)
:size (some-> size not-empty keyword)
:milk (some-> milk not-empty keyword)
:qty qty})
(defn quote-response
"Answer the browser's signals with the server's quote, as HTML: patch #quote with the price,
or with why the draft is not valid."
[signals]
(let [{:keys [qty] :as draft} (->draft signals)]
(sse/sse-response
(sse/patch-elements
(str (h/html (if-let [err (draft-error draft)]
[:p#quote.error err]
[:p#quote "Server price: " [:strong (dollars (* qty (price-cents draft)))]])))
{}))))
draft-error returns a sentence or nil: "Espresso does not take that milk.",
"Quantity must be 1 to 10.", and so on. The router passes (read-signals req) in, so
the handler is a function of plain data. No request object, no mocks, nothing to set up in a
test.
Two medium oat lattes come back as <p id="quote">Server price:
<strong>$11.20</strong></p>: 450 + 50 + 60 = 560 cents, times 2. (Told
you that latte would follow us around.) An espresso with oat milk comes back as
<p class="error" id="quote">Espresso does
not take that milk.</p>.
Either way the browser morphs #quote and nothing else changes. No signal holds the
price or the error, so there's nothing for the page to keep in sync: the next answer replaces
the element, and a reload shows the server's initial text. Nothing to go stale, because there's
nothing to remember.
Step through one click of "Ask the server" with two lattes in the draft:
Why the browser doesn't compute the price
I know what you're thinking, because everybody thinks it. Ship the menu as a private signal,
$_prices, add a data-computed:estimate that multiplies it out, and the
price updates on every click with no request at all. Lesson 3 doesn't do this, and it's worth
being really clear why. It writes the pricing rule twice, once in Clojure and once in a
JavaScript expression, and the two copies drift. They always drift. Change oat to 70 cents, add
a discount for a second drink, or make chai large-only, and the browser's copy is wrong until
somebody remembers it exists. And nobody remembers it exists. The server has to price the order
again when it's placed anyway, so the browser copy saves no work. And when the copies disagree,
the page shows the customer a price you won't charge. That's not a bug report. That's an angry
email.
The rule is short enough to tattoo somewhere. Expressions may format and move values, count
things, toggle flags, and manage focus. They never derive a business value (a price, a total,
eligibility, a validation result). If you want the price to follow every change, then ask the
server on every change: data-on:change="@get('/quote')" on #draft
catches the change events bubbling up from the selects, and part 4 shows why a burst
of them is harmless (a new request to the same URL cancels the old one).
When a signal is the wrong tool (or: how to accidentally rebuild your SPA)
The draft form shows the right use. Signals hold the user's in-progress input, and
presentation-only values formatted from it. The browser can handle all of that by itself. But
once you've got a hammer this nice, everything starts looking like a nail. Put the cart in a
signal! Put the order list in a signal! Render everything from signals with
data-text and friends, and use the server as a JSON API! And congratulations: you've
turned Datastar back into the single-page application it was designed to replace, except now
with a worse templating language.
That's why the quote is HTML. The server could have sent {"quote": 1120, "error":
""} with patch-signals, but then the page has to know where to show a number,
how to format cents, when to hide the error line, and it's stuck holding two signals whose only
job is to parrot what the server said. Rendered as HTML, the wording, the currency format, and
the error text are all server code, and the page holds nothing. patch-signals still
has a real job, mind you: the server writing into the user's own input, as when part 4 resets
$size because the new drink doesn't come in medium. Part 4 turns this into a rule,
finds a home for every piece of Brew Board's state, and builds the drop-down where the decision
is least obvious.
Tip. @post('/orders', {contentType: 'form'})
sends the fields of the closest <form> (or the one a selector
option names) instead of the signals. Instead, not as well: in fetch.ts the
form branch never calls the signal filter, so no signal reaches the server. It also runs the
browser's form validation first and throws FetchFormNotFound if there's no form. Use
it for a real form submission (a file upload, a plain HTML form), never where the handler needs
the signals.
The code is in clojure/src/lesson03/signals.clj (ns
lesson03.signals): the draft page, read-signals,
price-cents, draft-error, and quote-response, with the
SSE writer copied into lesson03.sse. The demo sends the exact GET the button
sends (signals URL-encoded in datastar) for two oat lattes, then an espresso with
oat milk (which the static selects allow and the server rejects), then the same signals as a
POST body, then a hand-made request whose datastar parameter isn't JSON, just to be
mean: cd clojure && clojure -M -m lesson03.signals. In a browser,
(start! 8080) and watch the network tab while you change the quantity. The summary
updates and no request goes out until you press the button. It's oddly satisfying to watch
nothing happen.
Your turn. The exercise track for this part: read-signals
(query param for GET/DELETE, body otherwise, and {} for anything that isn't a JSON
object), price-cents (base + size delta + milk delta), and
quote-response (validate with draft-error, then patch
#quote with the price or the reason). Hints are on the
exercises page.
cd clojure && clojure -M:ex -d ex -n exercises.lesson03.signals-testHere's the napkin. A signal is a named reactive value. Bindings re-run
only when a signal they read changes value; computed signals recompute lazily. Every signal whose
name (or path segment) doesn't start with _ is sent with every request, in the
datastar query param for GET/DELETE and as the JSON body otherwise. Use signals for
input and presentation. Let the server decide what's true, and send its answer as HTML.