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

Form surface

Create an access pass

not submitted

Waiting for the server to validate

server on submit
email Server feedback appears here
first Server feedback appears here
last Server feedback appears here

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.

lib/dstar_demo_web/pages/commands_page.ex
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

lib/dstar_demo_web/pages/commands_page.ex
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
# 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