Form data validation
Every input validates live on a 240ms debounce — Datastar posts the real form with contentType: 'form', and the server patches field feedback, status, and payload as you type.
Demo
How it works
- novalidate keeps the browser from intercepting the demo.
- data-on:input__debounce.240ms fires 240ms after you stop typing in any field.
- contentType: 'form' serializes the whole form, so every check validates all three fields.
- patch/3 updates status, field errors, summary, and payload fragments.
The form is a real <form>: Datastar posts it
with contentType: 'form', so the server receives
ordinary form fields rather than signals. Each input carries data-on:input__debounce.240ms, so 240ms after you
stop typing the whole form is posted and validated. The response patches six separate
fragments — the status badge, three field-feedback spans, the validation panel and the
payload panel — leaving the inputs themselves untouched. That is why what you typed
survives the round trip. Submitting runs the same validation with the final verdict.
def handle_event(conn, "check", params) do
validation = validate(params)
conn
|> patch_validation(validation, "typing")
|> patch(&payload_panel/1, submitted: submitted_fields(params))
end
# ...
defp patch_validation(conn, validation, intent) do
conn
|> patch(&status_badge/1, intent: intent)
|> patch(&field_feedback/1,
name: "email",
hint: "Company addresses only — try ada@example.com.",
field: validation["email"],
intent: intent
)
|> patch(&field_feedback/1,
name: "first_name",
field: validation["first_name"],
intent: intent
)
|> patch(&field_feedback/1,
name: "last_name",
field: validation["last_name"],
intent: intent
)
|> patch(&validation_panel/1, validation: validation, intent: intent)
end
If this were LiveView
Both sides validate the same three fields with the same rules, and both keep what you typed while showing server-owned feedback.
Dstar
def handle_event(conn, "check", params) do
validation = validate(params)
conn
|> patch_validation(validation, "typing")
|> patch(&payload_panel/1, submitted: submitted_fields(params))
end
# ...
defp patch_validation(conn, validation, intent) do
conn
|> patch(&status_badge/1, intent: intent)
|> patch(&field_feedback/1,
name: "email",
hint: "Company addresses only — try ada@example.com.",
field: validation["email"],
intent: intent
)
|> patch(&field_feedback/1,
name: "first_name",
field: validation["first_name"],
intent: intent
)
|> patch(&field_feedback/1,
name: "last_name",
field: validation["last_name"],
intent: intent
)
|> patch(&validation_panel/1, validation: validation, intent: intent)
end
LiveView
# Illustrative LiveView equivalent — not compiled or run.
def mount(_params, _session, socket) do
changeset = AccessPass.changeset(%AccessPass{}, %{})
{:ok, assign(socket, form: to_form(changeset, as: :access_pass))}
end
def handle_event("validate", %{"access_pass" => params}, socket) do
form =
%AccessPass{}
|> AccessPass.changeset(params)
|> Map.put(:action, :validate)
|> to_form(as: :access_pass)
{:noreply, assign(socket, form: form)}
end
def render(assigns) do
~H"""
<.form for={@form} id="access-pass-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:email]}
type="email"
label="Email"
required
phx-debounce="240"
/>
<.input field={@form[:first_name]} type="text" label="First name" minlength="2" phx-debounce="240" />
<.input field={@form[:last_name]} type="text" label="Last name" phx-debounce="240" />
<.button phx-disable-with="Checking…">Request access</.button>
</.form>
"""
end