jonnifyelixir
F6 / F6.05 / forms

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

to_form/1 to_form(changeset) — wrap a changeset as a form structure from F6.03 <.form> <.form for={@form}> — the form element + CSRF token the wrapper <.input> <.input field={@form[:title]}> — one field, with errors a field

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

changeset F6.03 to_form <.form> renders inputs + token submit params Catalog.create the context {:ok} → redirect {:error, cs} → to_form → re-render errors back to the form

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.