F6.05 · part 3 of 3
Forms & inputs
A form is where the view layer hands control back to the domain, and Phoenix builds it on the
changeset you already wrote in F6.03. to_form/1 wraps a changeset in a
%Phoenix.HTML.Form{} — a structure that knows the field names, their current
values, and any errors. <.form for={@form}> renders the
<form> element with a CSRF token included automatically, and
<.input field={@form[:title]}> renders one field, reading its value and its errors
straight off the form. The loop closes on submit: the params arrive at a controller action, which calls the
context’s create_course/1; on {:ok, course} you redirect,
and on {:error, changeset} you call to_form on the invalid
changeset and re-render — now the inputs show the F6.03 validation messages inline. You write the changeset
once; it powers both the write and the form’s error display. The CSRF token and HTML escaping are handled
for you, so the common security mistakes are closed by default.
Four parts: the three building blocks of a form, the round trip from changeset to submit and back, a real form in code, and how validation errors reach the screen.
Three building blocks
A form is three things working together. to_form/1 turns a changeset into a form
structure. <.form> renders the form element and its hidden token.
<.input> renders a single field, wired to one changeset field. Select one.
Form building blocks · select one
block: to_form/1
does: changeset becomes a form
<.input> is itself a function component with declared attrs —
exactly the kind you built in F6.05.2. The form layer is not special machinery; it is components over the
changeset structure.
The form round trip
A form is a loop, not a one-shot render. The changeset becomes a form; the template renders it; the user
submits params; the controller calls the context; success redirects, and failure re-wraps the invalid
changeset with to_form and re-renders — now carrying errors. The same changeset
definition validates the write and supplies the messages.
changeset → form → submit → context → ok | errors
The error path returns to the same template with the same form variable — only the changeset now has errors. The user sees their input preserved and the messages beside the fields, with no extra wiring.
A real form
The new action builds an empty changeset through the context and wraps it with
to_form; the template renders <.form> with an
<.input> per field. The action points at the
create route with a verified ~p path.
# controller — build a form from a changeset (F6.03) def new(conn, _params) do changeset = Catalog.change_course(%Course{}) render(conn, :new, form: to_form(changeset)) end <%!-- new.html.heex — render the form and its inputs --%> <.form for={@form} action={~p"/courses"}> <.input field={@form[:title]} label="Title" /> <.input field={@form[:published]} type="checkbox" label="Published" /> <.button>Save</.button> </.form>
No raw <form>, no manual field names, no hand-written CSRF input.
<.form> and <.input> read everything they need from
the form structure, so the markup stays declarative.
How errors reach the screen
The create action sends params to the context. On success it redirects; on failure it
re-wraps the invalid changeset with to_form and re-renders the same template. Because
<.input> reads its errors from the form field, the F6.03 validation messages
appear beside the inputs with nothing else to write.
# create — params flow to the context; an invalid changeset re-renders with errors def create(conn, %{"course" => params}) do case Catalog.create_course(params) do {:ok, course} -> conn |> put_flash(:info, "Course created") |> redirect(to: ~p"/courses/#{course.id}") {:error, %Ecto.Changeset{} = changeset} -> render(conn, :new, form: to_form(changeset)) # errors now on the form end end
one changeset
F6.03 validation drives both the write and the form’s error messages.
two outcomes
{:ok} redirects; {:error, cs} re-renders with errors inline.
That completes F6.05. The view layer renders assigns, reuses components, and edits through changeset-backed forms — all server-rendered. The next module, F6.06 — LiveView, makes these same templates live, updating over a socket without a full page reload. Back to the module overview or the F6 chapter.