Datastar in Clojure Part 4 of 8
Part 4 · When to use a signal

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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:

statehomewhy
drink being configured: drink size milk qtysignalsinput in progress; sent with "Add to cart"
customer namesignal customerinput; sent with "Place order"
is a menu open, hover, local toggles_ signal, e.g. _openpresentation only, never sent
request in flightdata-indicator:_loadingpresentation only, transient
the draft's price, a validation messageserver-rendered HTML (#quote, #draft-error)business rules exist only on the server; the page shows its answer
cart linesserver, keyed by a sid cookiesurvives reload, works across devices, validated against stock
orders, statuses, sold-out milksservershared between people
the size and milk option listsserver-rendered HTMLdepend 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:

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-test

Here'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.