Form data validation
Datastar submits a real form with contentType: 'form'. The server validates the fields and patches only the status, field feedback, and submitted payload.
Demo
How it works
- novalidate keeps the browser from intercepting the demo.
- contentType: 'form' sends form fields instead of Datastar signals.
- The inputs keep their values while server-owned feedback is patched.
- 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. Validation runs server-side and 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.
def handle_event(conn, "validate", params) do
validation = validate(params)
intent = if ready?(validation), do: "accepted", else: "rejected"
submitted = submitted_fields(params)
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)
|> patch(&payload_panel/1, submitted: submitted)
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, "validate", params) do
validation = validate(params)
intent = if ready?(validation), do: "accepted", else: "rejected"
submitted = submitted_fields(params)
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)
|> patch(&payload_panel/1, submitted: submitted)
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 />
<.input field={@form[:first_name]} type="text" label="First name" minlength="2" />
<.input field={@form[:last_name]} type="text" label="Last name" />
<.button phx-disable-with="Checking…">Request access</.button>
</.form>
"""
end