BuildBot

HTML Over the Wire

Forms and validation

Lesson 3 of 5

What you'll learn

  • Handle the form POST round trip with two outcomes: success and validation failure
  • Re-render the form partial with inline errors, returning 422 Unprocessable Entity
  • Choose hx-target/hx-swap so both outcomes land in the right place

Validation is where hypermedia gets pleasantly boring. There's no client-side schema, no error-state store: the server validates (as it must anyway — the client can always lie), and on failure it re-renders the form partial with the errors inside it. The user's input and the messages come back as one fragment.

The trick is the target. Point the form at itself with hx-swap="outerHTML", so whatever comes back replaces the whole form:

{{define "todo-form"}}
<form hx-post="/todos" hx-swap="outerHTML">
  <input name="title" value="{{.Values.title}}">
  {{with .Errors.title}}<p class="error">{{.}}</p>{{end}}
  <button>Add</button>
</form>
{{end}}

(No hx-target means "the element that made the request" — the form.) The handler has one branch per outcome:

mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
    title := strings.TrimSpace(r.FormValue("title"))
    if title == "" {
        w.WriteHeader(http.StatusUnprocessableEntity) // 422
        tmpl.ExecuteTemplate(w, "todo-form", FormData{
            Values: map[string]string{"title": r.FormValue("title")},
            Errors: map[string]string{"title": "Title is required"},
        })
        return
    }
    todo := store.Add(title)
    tmpl.ExecuteTemplate(w, "todo-form", FormData{}) // a fresh, empty form
    tmpl.ExecuteTemplate(w, "todo-item-oob", todo)   // plus the new row, out of band
})

On failure: same form back, now carrying the user's values and inline errors, with a 422 so logs and tests can tell rejection from success. On success: a fresh form replaces the old one — and the new row rides along out of band: a fragment marked hx-swap-oob="beforeend:#list" gets routed to #list no matter what the main swap targets. One response, two destinations.

htmx 2 ignores 422 bodies by default

Out of the box, htmx 2 treats 4xx/5xx responses as errors and swaps nothing — your beautiful form-with-errors arrives and the page doesn't change. Opt in once, in your page head: <meta name="htmx-config" content='{"responseHandling":[{"code":"422","swap":true},{"code":"[23]..","swap":true},{"code":"...","swap":false,"error":true}]}'>. Forgetting this is the most common "validation silently does nothing" bug.

The challenge models the handler's two branches: validate, then return either the form-with-errors (422) or the fresh form plus the out-of-band row (200).

Validate, then pick a fragment (JS model)

Run it. postTodos() validates and returns a status plus HTML: 422 re-renders the form with inline errors and the user's values preserved; 200 returns a fresh form plus the new row marked out-of-band for #list. Add a length rule to validate() and test it.

Loading editor…
Knowledge check

Your handler returns 422 with a form-with-errors body, but the page doesn't change. Why?

Next: every one of these POSTs hits the database — time to configure SQLite so concurrent handlers don't collide.

Saved on this device. Sign in to sync your progress everywhere.