Forms
A server-rendered form already works without client-side code: the browser submits it, the handler validates it, and the response displays any errors. The round trip does make simple feedback, such as a missing required value, feel unnecessarily late.
Browser validation can provide that immediate feedback. Server validation must remain authoritative, because only the server can enforce every request. The useful design is to share the same rules without confusing their roles.
Two validators, one truth
Section titled “Two validators, one truth”The server’s check rules are the truth. They run on every request, including
the ones that never touched your form:
type createInput struct { Title string `payload:"title" check:"required,maxlen=60"` Owner string `payload:"owner" check:"required,maxlen=24"` Priority string `payload:"priority" enum:"low,normal,high" default:"normal"`}The HTML attributes are an echo of those rules, placed where the reader is:
<input id="title" name="title" value={form.title} required maxlength="60" autocomplete="off"><input id="owner" name="owner" value={form.owner} required maxlength="24" autocomplete="off">They are duplication, and worth it — but only in one direction. Attributes may restate a server rule; they must never be the only place a rule exists. A narrower attribute than the server’s check is a bug the server will accept silently; a wider one is just a round trip you did not save.
required, maxlength, min/max, step, pattern and the input types
(email, url, number, date) cover most of what check expresses.
Style the failure, not the emptiness
Section titled “Style the failure, not the emptiness”:invalid matches an empty required field before anyone has typed in it, which
is why forms styled with it look broken on arrival. :user-invalid waits until
the reader has actually interacted:
<head><style>.field input:user-invalid { border-color: crimson }.field input:user-invalid + .hint { display: block }.hint { display: none }</style></head>Remember that a scoped selector needs a class to hang off — a bare
input:user-invalid fails generation. See
Browser controls for the rule.
Errors that come back from the server
Section titled “Errors that come back from the server”Client-side checks stop the obvious cases. Everything else — uniqueness, a value that was valid until someone else changed something — is only knowable after the request.
The classic form of the answer is to re-render the page with the errors and the
reader’s own text still in the fields. pw.Parse returns the zero value when a
check fails, so that text comes from the request rather than from the parsed
struct:
input, err := pw.Parse[createInput](r)if err != nil { mapped, ok := httpbind.AsHTTPError(err) if !ok || len(mapped.Fields) == 0 { pw.WriteProblem(w, r, pw.BadRequest(err)) return } form := FormState{Title: r.PostFormValue("title"), Owner: r.PostFormValue("owner")} applyFieldErrors(&form, mapped.Fields) pw.WriteHTML(w, r, NewTask(NewTaskParams{Form: form})) return}The distinction in that first branch matters: field-level failures belong next to an input, and an unreadable or oversized body does not — that one is a problem response.
examples/htmx_fragment runs the same logic on the fragment path, where one
extra rule applies: a swap library ignores a non-2xx response, so a rejected
form is answered with HTML and a 200 rather than with a problem document.
The status is not a lie about validity; it is a statement that this response is
the thing to display.
Forms inside dialogs
Section titled “Forms inside dialogs”A <dialog> can hold either kind of form, and the difference is one attribute:
<dialog id="rename" class="sheet"> <form method="dialog"><button value="cancel">Cancel</button></form> <form method="post" action="/tasks/rename"> <input type="hidden" name="id" value={id}> <label for="title">New title</label> <input id="title" name="title" required maxlength="60"> <button type="submit">Rename</button> </form></dialog>method="dialog" closes without submitting and sets returnValue to the
button’s value. The POST leaves the page entirely, and Post/Redirect/Get
brings back a fresh document in which the dialog is closed because it was never
opened. Nothing needs to remember to close it.
Constraint validation works normally inside a dialog: the browser refuses to submit and focuses the offending field, in the top layer, with no arrangement on your part.
The case that needs thought is a rejected submission you want to show in the still-open dialog. That is a fragment swap, not a navigation — see Fragments and islands.
Suggestions without a script
Section titled “Suggestions without a script”<label for="owner">Owner</label><input id="owner" name="owner" list="owners" autocomplete="off"><datalist id="owners">{for owner in owners} <option value={owner}></option>{/for}</datalist><datalist> is a suggestion list, not a constraint — the reader may type
something not in it, which is often the point. It suits a set the server can
send with the page: team members, tags, recent values. It does not suit ten
thousand rows, or a list that has to filter server-side; that is a fragment
swap, and the same input gains hx-get and a target instead of a list.
One route or two
Section titled “One route or two”A form that works without JavaScript and gets better with it is the point of this ladder, and it raises a question the framework deliberately does not answer for you: should the swap-driven submission go to the same route as the ordinary one?
Nothing in a fragment response is derived from the client — no request is classified, and no header is inspected on your behalf. So if one route is to serve both, you inspect the header yourself:
if r.Header.Get("HX-Request") == "true" { pw.WriteHTMLFragment(w, r, TaskPanel(params)) return}pw.WriteHTML(w, r, Page(pageParams))Two routes are usually clearer, and they let the page path keep a redirect while the fragment path answers with markup. One route is worth it when the logic before the response is long enough that duplicating it would be worse than the branch.
