Wire (htmx + Datastar)
One wiring spec, two transports — forms, filters, wizard.
# Wire — One Action, Both Transports
Every button here is the same display.Button with the same wire.Action shape — only the Transport differs. The endpoint is shared: it detects the caller via request headers and tells Datastar where to patch via response headers (htmx targets client-side through hx-target). Use the selector to flip every button's dialect:
&wire.Action{URL: "/api/wire/fragment", Target: "#out"} // hx-get="/api/wire/fragment" + hx-target
&wire.Action{Transport: wire.TransportDatastar, URL: "/api/wire/fragment"} // data-on:click="@get('/api/wire/fragment')"Datastar transport (all buttons)
Note the Datastar button carries no target attribute: Datastar fetch actions (v1.0.2 audited) have no target option — the region is chosen by the response (Datastar-Selector) or by matching fragment ids. wire.Action.Target documents this contract and renders only for htmx. The endpoint is wrapped in wire.Handler, which does the response-header targeting for you.
Server validation
A forms.Input wired with EventChange: the field's value travels to /api/wire/validate and the verdict patch lands in the region below. Under htmx the input carries its own name/value; under Datastar the static-URL common subset cannot carry the value, so the demo uses the documented Attrs escape hatch with a bound signal.
Dual-transport form
The same forms.Form submitting under either runtime. The form's fields serialize in both dialects: htmx natively (hx-post + implicit submit trigger), Datastar via the contentType form option on the submit expression — a pinned v1.0.3 runtime capability that also runs HTML5 validation and carries the submit button's name/value. One handler serves both.
&wire.Action{Method: wire.MethodPost, URL: "/api/wire/form", Target: "#wire-form-htmx-region"} // hx-post + hx-target
&wire.Action{Transport: wire.TransportDatastar, Method: wire.MethodPost, URL: "/api/wire/form"} // data-on:submit="@post(…, {contentType: 'form'})"Submit invalid data (an email without a domain): the server re-renders the form with a ValidationSummary and inline field errors, preserving what you typed — on either runtime. Valid data renders the success verdict plus a fresh form. Errors travel as 200 OK: htmx 2's default response handling does not swap 4xx responses, and Datastar dispatches a fetch error event at status >= 400 — 200 keeps both zero-config.
Debounced filter
forms.FilterInput: a <search> form whose input auto-submits as you type, debounced 300ms. Under htmx it renders hx-trigger="input changed delay:300ms"; under Datastar data-on:input__debounce.300ms (the modifier spelling decoded from the pinned bundle) with the form encoding path — so the query travels as a clean ?q= parameter on either runtime. One endpoint serves both via wire.Handler.
&wire.Action{URL: "/api/wire/filter", Target: "#wire-filter-out"} // hx-trigger="input changed delay:300ms"
&wire.Action{Transport: wire.TransportDatastar, URL: "/api/wire/filter"} // data-on:input__debounce.300ms- Alpine.js
- Datastar
- Htmx
- Hyperscript
- Tailwind
- Templ
Busy state
Perceived performance on wired submits. htmx swaps to a LoadingButton during the request; Datastar toggles an Indicator via the data-indicator signal (composed on the same wired display.Button through Attrs). The endpoint sleeps 800ms so the busy state is actually visible.
Swap styles (one field, both dialects)
Action.Swap = PatchModeAppend renders htmx's hx-swap="beforeend"; under Datastar the merge mode is the endpoint's datastar-mode response header (wire.Handler) — the Action is IDENTICAL. Each click appends a timestamped line.
Remove mode (region retraction)
PatchModeRemove DELETES the matched element instead of patching into it — htmx's hx-swap="delete", Datastar's remove merge mode via the response header. The region is gone until you reload.
Dismiss me — the whole card goes away.
File upload
The classic hypermedia wallflower: Enctype: forms.FormEnctypeMultipart on a wired forms.Form with a FileInput. htmx serializes the FormData natively; the pinned Datastar bundle sends a FormData body exactly when enctype is multipart. The endpoint echoes the file's name and size into the region below — errors travel as 200 OK fragments (the validation rule).
forms.FormProps{Method: forms.FormPost, Enctype: forms.FormEnctypeMultipart,
Wire: &wire.Action{Method: wire.MethodPost, URL: "/api/wire/upload"}}GET search form
Search-as-a-GET: a forms.Form with Method: forms.FormGet and a wired submit. Under htmx the fields ride the query string natively; under Datastar a GET with contentType: 'form' carries the fields as query parameters too — identical URLs, one endpoint.
Multi-step wizard
A stepper is a server-side state machine: feedback.StepIndicator + one step's fields re-render into the region per request; the server validates each step (a step number from the client is a hint, not authorization) and errors never advance. Recipe: docs/recipes/multi-step-forms.md.
Wired nav links
NavLinkProps.Wire puts the wiring on the link itself — the anchor keeps its href (no-JS fallback) and patches a region instead of navigating. Because the field rides NavLinkProps, every nav (Nav, SimpleNav, MobileMenu) inherits it.
Month navigation
A forms.Calendar whose prev/next arrows carry a wire.Action with {year}/{month} placeholders — the component substitutes the navigation target per arrow. The endpoint re-renders the whole calendar: under htmx the arrows self-swap (outerHTML settle:0s, so a rapid second click can never land on an unprocessed anchor); under Datastar the response headers pick the region.