When to use a signal
Every piece of state in a Datastar app has exactly one home: the server, a signal, a
private _ signal, or HTML the server renders. Pick the wrong one and it will haunt
you, politely, for months. This part gives you a rule for choosing, uses it to place every piece
of Brew Board's state, and then builds the case where the choice is least obvious: a drop-down
whose options depend on the menu and on what's in stock.
Part 3 made two choices that are easy to get wrong in the other direction, and I want to dwell
on them for a second. The browser never computes a price, and the server's answer is HTML rather
than a $quote signal. Now imagine following the other road for a few weeks. It's a
pleasant road at first. Then one morning you look up and the whole app's state is in signals,
every view is built from data-text and data-show, the business rules
have been copied into expressions, and the server has been quietly demoted to a JSON API.
Congratulations: you've built a single-page app, only with fewer tools.
Datastar's design pushes the other way, and pushes hard. The server renders HTML, the browser patches it in, and signals carry the little bit of state that only exists in the browser. That's the easy part to say. The hard part is knowing which little bit that is.
The rule
Ask these questions in order. The first "yes" decides, and you stop asking.
- Does anyone besides this tab need it, or must it survive a reload? Another device, another person, the next page load, the server's own rules. Then it lives on the server. Carts, orders, stock, the signed-in user.
- Is it a list or a block of markup that depends on server data? Then the server renders it as HTML. It is never, ever mirrored into a signal. The options in a drop-down, the rows of a table, a "sold out" label.
- Is it input the user hasn't committed yet? Then it's a signal. It goes to the server with the request that commits it, and the server validates it, because the server validates everything. A form in progress, a search box, a quantity stepper.
- Is it purely about presentation in this tab? Then it's a
_-prefixed signal, which never leaves the browser. Whether a menu is open, which tab is selected, whether a request is in flight.
That's it. Four questions. You could fit them on an index card, which is roughly the right size
for a rule you want people to actually follow. A data-computed may format those
values for display, like part 3's $_summary ("2 × medium oat latte"), count them, or
turn them into a flag. What it may not do is derive a business value: a price, a total,
whether an order is valid. Those belong to the server, and they come back as HTML. Errors work the
same way. A validation message is the server's answer, rendered into an element with an id, never
a signal the page has to keep in sync.
Here's the rule applied to Brew Board, top to bottom:
| state | home | why |
|---|---|---|
drink being configured: drink size milk qty | signals | input in progress; sent with "Add to cart" |
| customer name | signal customer | input; sent with "Place order" |
| is a menu open, hover, local toggles | _ signal, e.g. _open | presentation only, never sent |
| request in flight | data-indicator:_loading | presentation only, transient |
| the draft's price, a validation message | server-rendered HTML (#quote, #draft-error) | business rules exist only on the server; the page shows its answer |
| cart lines | server, keyed by a sid cookie | survives reload, works across devices, validated against stock |
| orders, statuses, sold-out milks | server | shared between people |
| the size and milk option lists | server-rendered HTML | depend on menu and stock |
Tip. A signal count that grows with your data is a bug.
Not a smell. A bug. Every signal whose name doesn't start with _ rides along in every
request, and for a GET that means in the URL (fetch.ts puts the JSON in the
datastar query parameter). One signal per row, say sel_1 …
sel_400 for checkboxes, eventually overflows the URL; a production Datastar app hit
HTTP 414 exactly this way. URLs have a length limit, and it turns out your data doesn't care. Keep
per-row state on the server, keyed by one id, and render it back as HTML (a
checked attribute, a count).
Now try placing these yourself before you read on. Some of them are deliberately close calls, because the easy ones teach you nothing.
Why the cart is not a signal
The cart is the most tempting thing in the whole app to put in a signal. Go on, admit it, you
were already thinking it. It's per-customer, it changes on every click, and a $cart
array with data-text totals would update instantly. Instant is nice! So here's the
bill for instant:
- It travels with every request. A non-
_signal is in every payload. Every one. Each drop-down change would send the whole cart to/drink-options, which has no use for it whatsoever. - It's a second copy. Ada adds a latte on her phone; her laptop's
$carthas no idea. Reload and the cart is gone, unless you add storage, and then congratulations, you have three copies. - The server has to check it anyway. When the order is placed, the server can't trust the lines or the prices it receives (you'd never trust a customer who walked up and told you what their latte costs), so it validates everything again. The client copy saved no work at all. It just added a place for bugs to live.
So the cart lives in the server's state atom, keyed by a session id. The first page load sets a cookie, and Datastar's fetches are same-origin, so every request carries it along for free:
(defn page-response [req]
(let [sid (or (sid-of req) (str (UUID/randomUUID)))]
(cond-> (sse/html-response (page @state sid))
(not (sid-of req)) (assoc :cookies {"sid" {:value sid :path "/" :http-only true :same-site :lax}}))))
(defn add-to-cart
"Add the draft to the cart of session sid. Return the new state, or nil when the draft is
not valid (sold-out milks count)."
[state sid draft]
(when-not (draft-error draft (:sold-out state))
(update-in state [:carts sid] (fnil conj []) (select-keys draft [:drink :size :milk :qty]))))
"Add to cart" is @post('/cart/items'). The draft signals arrive as the JSON body.
The handler validates them against the menu and the current stock, updates the atom, and answers
with two element patches: the re-rendered #cart, and an empty
#draft-error to clear out any old message. On a bad draft it sends only
<p class="error" id="draft-error">Oat milk is sold out.</p>. Look at who
owns what here. The cart's markup, its line format, its total, and the error's wording are all
server code. The browser holds none of it. Not one byte.
(defn error-view
"The #draft-error element: the server's reason the draft was refused, or empty."
[message]
[:p#draft-error.error message])
(if-let [err (draft-error draft (:sold-out @state))]
(sse/sse-response (sse/patch-elements (str (h/html (error-view err))) {}))
(sse/sse-response
(sse/patch-elements (str (h/html (cart-view (get-in new-state [:carts sid])))) {})
(sse/patch-elements (str (h/html (error-view nil))) {})))
From add-item-response, abridged. (The real one also refuses a
request with no sid cookie, and computes new-state with
swap!.)
Notice what's in the signals and what isn't, because it's the whole lesson in miniature. The draft (one line being configured) is a signal, because it's input. Once it's committed, the line becomes server state, and from then on the browser only ever sees it as HTML. Input goes up as signals; truth comes down as HTML. Tattoo it somewhere tasteful.
The drop-down (or: where the rule earns its keep)
Now for the hard case. When the customer picks a drink, the size and milk drop-downs have to change: an espresso comes in single or double and takes no milk, a cappuccino comes in small or medium with whole or oat. And when Sam marks oat milk sold out, oat must show up as unavailable.
The SPA reflex, and it's a strong one, is to ship the menu and the stock to the browser as
signals and build the <option>s with expressions. Resist it. Run the rule
instead. The option list depends on server data (rule 2), so it's HTML from the server. The
selected values are input (rule 3), so they're signals. So the drop-down is both, and
the boundary runs right between the <option> list and the
value. That's the trick of this whole part: one widget, two homes.
[:select#drink {:data-bind:drink "" :data-on:change "@get('/drink-options')"}
(for [[k {:keys [name]}] menu]
[:option (cond-> {:value (clojure.core/name k)} (= k :latte) (assoc :selected "")) name])]
(drink-options-view :latte sold-out choice)
The drink select is bound to $drink and, on change, asks the server
for new options. Here's a subtle bit. Both data-bind and
data-on:change listen to the same change event, so their order matters:
the binding has to update $drink before the request serializes the signals.
Otherwise you'd be asking the server about the drink the customer just un-picked. Listeners run
in the order they were added, attributes are applied in source order, and hiccup sorts
data-bind:drink before data-on:change. So the request carries the new
drink, and everybody's happy. (If you ever need to be explicit rather than lucky, read the value
from the event instead: evt.target.value.)
The options themselves are a pure function of the drink, the sold-out set, and the current choice. Pure functions! In a web UI! Savor it:
(defn drink-options-view
"The #drink-options element: a size select and a milk select for this drink. The option
lists exist only here, as HTML from the server."
[drink sold-out {:keys [size milk]}]
(let [{:keys [sizes milks]} (menu drink)]
[:div#drink-options
[:label "Size "
[:select#size {:data-bind:size ""}
(for [s sizes]
[:option (cond-> {:value (name s)} (= s size) (assoc :selected "")) (label s)])]]
(if (seq milks)
[:label " Milk "
[:select#milk {:data-bind:milk ""}
(for [m milks]
[:option (cond-> {:value (name m)}
(= m milk) (assoc :selected "")
(sold-out m) (assoc :disabled ""))
(cond-> (label m) (sold-out m) (str " (sold out)"))])]]
[:p.muted "No milk"])]))
When the drink changes, the old size or milk may not exist for the new drink. Medium has no
meaning for an espresso. (Ask a barista for a medium espresso and watch their face.)
valid-choice keeps each value if it's still allowed (and in stock) and otherwise
takes the first one that is. The response then carries two events:
(defn drink-options-response
"Answer a change of drink: new option lists, and new size/milk signals where the old ones
no longer fit."
[signals sold-out]
(let [{:keys [drink size milk]} (->draft signals)
choice (valid-choice drink sold-out {:size size :milk milk})
changed (cond-> {}
(not= size (:size choice)) (assoc :size (name (:size choice)))
(not= milk (:milk choice)) (assoc :milk (or (some-> (:milk choice) name) "")))]
(sse/sse-response
(sse/patch-elements (str (h/html (drink-options-view drink sold-out choice))) {:mode :replace})
(sse/patch-signals changed {}))))
The first event replaces #drink-options by id, with the chosen options marked
selected. The second sets only the signals whose old value no longer fits. You need
both, and here's why. The DOM shows the new selection, but the signals are what the next request
sends, and a $size still saying "medium" would put a medium espresso in
the cart. The face again.
Now note mode replace, because this is where things get genuinely weird. The
default, outer, would morph the old selects into the new ones, and the morph gets
this case wrong. Options have no ids, so it pairs them by position. It sets an option's
selected property only when the selected attribute's
presence differs between old and new (updateElementProp in
patchElements.ts), but choosing an option changes only its property. Attribute,
property: two different things with the same name, which is the kind of design decision the DOM
is famous for. Walk through it with me. Pick medium on a latte, then switch to chai: the page
loaded with small marked selected, chai's list marks
medium (its first option) selected, so the attribute is present at
index 0 both times and nothing is touched. The second option keeps its selected property, and it
is now large. Medium is still valid for chai, so no signal patch arrives to correct it:
the screen says Large while $size says medium, and the cart gets a medium. The UI is
lying to the customer, and nobody did anything wrong. replace fixes it by swapping
in new elements instead (replaceWith), and a fresh data-bind starts from
the signal. Part 6 meets the same morph rule from the other side, so file this away.
The milk-less case is a small decision worth a closer look. An espresso has no milk, so the
obvious patch is {"milk": null}. Obvious, and wrong. A merge patch treats
null as "remove this signal". $milk would vanish from the store and
from every later payload, and the server would have to treat "missing" and "none" as the same
thing, forever, everywhere. So the lesson sends "" instead. The key stays in every
request, the value stays a string, and ->draft turns "" into
nil on the way in. And when nothing needs to change (mocha to mocha, say),
changed is empty and the second event is {}, a merge patch that does
exactly nothing, which is the best kind.
Step through the cases. Each one is the exact response drink-options-response
produces, no hand-waving.
And here's one more property you get for free, which is my favorite kind of property. If the
customer flicks through drinks quickly, several /drink-options requests could be in
flight at once, and a slow early response could land after a fast late one. That's a classic
race. Datastar prevents it by default. With requestCancellation: 'auto',
fetch.ts keeps one AbortController per method and URL and aborts the
previous request to the same URL when a new one starts. Only the last drink's options are
applied. (Part 8 comes back to this rule, and to the cases where it doesn't save you.)
A searchable variant
The same split works for a combobox with server-side search, which makes a good exercise once the lesson passes. The text the customer types is input, so it's a signal, and it must not start with an underscore, because the server needs to see it. Whether the list is open is presentation, so it's private:
<div data-signals="{query: '', _open: false}">
<input data-bind:query
data-on:input__debounce.200ms="@get('/drinks/search')"
data-on:focus="$_open = true">
<ul id="drink-results" data-show="$_open"></ul>
</div>
The server filters the menu by query and answers with a patch of
#drink-results. Nothing about the menu goes to the browser except the matching rows,
as HTML. The __debounce.200ms modifier waits until typing pauses for 200 ms
before sending, so you're not hammering the server once per keystroke like an anxious
woodpecker.
Reading a Datastar attribute name. That
__debounce.200ms is the first modifier in the series, so here's the whole grammar,
once. Datastar parses every attribute name as
data-{plugin}:{key}__{modifier}.{tag}.{tag}__{modifier}…. Each double underscore
starts a modifier, and dots hang tags off that modifier. What's left before the first
__ splits at its first colon into plugin and key, so the key runs from
that colon to the first __, and may contain colons of its own (part 7's
brew:native). So data-on:click__window__debounce.500ms.leading is plugin
on, key click, modifier window (listen on
window), and modifier debounce with tags 500ms and
leading. You'll meet __ifmissing on data-signals in part 6
and __window in parts 7 and 8. Which modifiers a plugin accepts is listed under each
attribute in the
attribute reference; there's no single
page with the grammar, which is why it's here.
What happens when oat sells out
Try this. Run the lesson, open two browser windows, and mark oat sold out at the REPL. Then
watch. Nothing changes in either window. Nothing! Not until someone picks a different drink. The
next /drink-options response renders oat as disabled and moves any oat
selection to whole. And if a window loaded before the change and its $milk still
says oat, add-to-cart refuses it, because the server checks stock on every add. So
the state is right. The display is stale until the next request.
That's the honest limit of request/response, and there's no point pretending otherwise. Part 6 removes it, and here's the beautiful part: it does so without changing a single decision made here. The drop-down stays server-rendered HTML, and the moment Sam marks oat sold out, every open customer page will show it, with no code specific to the drop-down. That only works because the option list was never a signal. Put it in a signal today and you'd be writing sync code for it in part 6.
The code is in clojure/src/lesson04/where_state_lives.clj (ns
lesson04.where-state-lives): valid-choice,
drink-options-view, drink-options-response, the server-owned cart
(add-to-cart, cart-view, add-item-response), and the
sid cookie. The demo is a little play in five acts: it gets a cookie, switches to
espresso, marks oat sold out and switches to latte, adds two whole-milk lattes, then replays a
stale page that still says oat:
cd clojure && clojure -M -m lesson04.where-state-lives. In a browser,
(start! 8080), then
(swap! lesson04.where-state-lives/state update :sold-out conj :oat) at the
REPL, and go break somebody's oat latte.
Your turn. The exercise track for this part has three functions:
drink-options-view (the hiccup for #drink-options, with
selected, disabled, and "(sold out)"),
drink-options-response (two events; only changed signals; a missing milk as
""), and add-to-cart (validate against the state's sold-out set, append,
or return nil). Hints are on the exercises
page if the morph gets the better of you.
cd clojure && clojure -M:ex -d ex -n exercises.lesson04.where-state-lives-testHere's the index card. Shared or durable state lives on the server.
Anything derived from server data, like an option list, is server-rendered HTML. Uncommitted
input is a signal that goes to the server with the command that commits it. Presentation-only
state is a _ signal. And a drop-down splits right across that line: the options are
HTML, the selected value is a signal, and when the drink changes the server sends both.