We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Post 95
LiveView 10 - Chapter 5: Forms and Changesets
Published on: 2025-12-13
Tags:
elixir, Blog, State, LiveView, Ecto, Html/CSS, Phoenix, schema, embedded_schema
Part II - LiveView Composition
Chapter 5 Forms and Changesets
We might want to have a way to update a page as a user fills out a form we will use changesets and forms to accomplish this. Let's start by revisiting changesets.
Model Change with Changesets
First, consider Ecto changesets. Changesets are policies for changing data and they play these roles:
• Changesets cast unstructured user data into a known, structured form—most commonly, an Ecto database schema, ensuring data safety
• Changesets capture differences between safe, consistent data and a pro- posed change, allowing efficiency.
• Changesets validate data using known consistent rules, ensuring data consistency.
• Changesets provide a contract for communicating error states and valid states, ensuring a common interface for change
Looking back at the changeset we have for the product resource pento/lib/pento/catalog/product.ex
@doc false
def changeset(product, attrs, user_scope) do
product
|> cast(attrs, [:name, :description, :unit_price, :sku])
|> validate_required([:name, :description, :unit_price, :sku])
|> validate_number(:unit_price, greater_than: 0.0)
|> unique_constraint(:sku)
|> put_change(:user_id, user_scope.user.id)
end
Important parts of this are the cast where it only allows the keys that we want, and the changeset itself. The changeset can take as many things as you want but its usually a struct of some kind and the the map of the changes you want made.
Right now this is a database backed validation we want to move to a schemaless, then we can move to an embedded one that is completely free of the database.
Then last we can work with live uploads. This will allows us to upload images.
Model Change with Embedded Schemas
Right now all the data that a form takes in is meant to be persistent. But there might be times that we don't want to keep the data withing a server. Maybe a search form where we just want to see the output and not save the search.
Build Database-Free Schemas from Structs
So we can use some of the functionality of an ecto with a data that will not be persistent. We will just need to use the Ecto.Changeset.cast/4 to start the validation.
Let's open up an iex session and test some things out.
iex -S mix
iex(1)> defmodule Player do
...(1)> defstruct [:username, :age]
...(1)> end
{:module, Player,
<<70, 79, 82, 49, 0, 0, 8, 248, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 249,
0, 0, 0, 22, 13, 69, 108, 105, 120, 105, 114, 46, 80, 108, 97, 121, 101, 114,
8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>,
%Player{username: nil, age: nil}}
iex(2)> player = %Player{}
%Player{username: nil, age: nil}
We have a struct and want to use changesets to validate an update to the struct. Let's now look at the docs for the Ecto.Changeset.cast/4
iex(3)> h Ecto.Changeset.cast/4
def cast(data, params, permitted, opts \\ [])
@spec cast(
Ecto.Schema.t() | t() | {data(), types()},
%{required(binary()) => term()}
| %{required(atom()) => term()}
| :invalid,
[atom()],
Keyword.t()
) :: t()
Applies the given params as changes on the data according to the set of
permitted keys. Returns a changeset.
data may be either a changeset, a schema struct or a {data, types} tuple.
Looks at this we can see a types() that can be used to be sure that we take those values.
iex(4)> types = %{username: :string, age: :integer}
%{username: :string, age: :integer}
iex(5)> attrs = %{username: "player1", age: 20}
%{username: "player1", age: 20}
iex(6)> changeset = {player, types} \
...(6)> |> Ecto.Changeset.cast(attrs, Map.keys(types))
#Ecto.Changeset<
action: nil,
changes: %{username: "player1", age: 20},
errors: [],
data: #Player<>,
valid?: true,
...
>
Great we have a changeset that kept track of the changes and is ready to be sent to a function. Now let's look at how we might do the same thing with an embedded version.
iex> defmodule Player do
...> use Ecto.Schema
...> @primary_key false
...> embedded_schema do
...> field :user_name, :string
...> field :age, :integer
...> end
...> end
{:module, Player, ...}
iex> %Player{}
%Player{user_name: nil, age: nil}
The difference here is the fact that we have the primary_key false to avoid the id part. And that we use the embedded_schema. Now we can use the same schema style validates and casts.
iex> import Ecto.Changeset
Ecto.Changeset
iex> changes = %{age: 16, name: "Mario"}
%{name: "Mario", age: 16}
iex> allowed_fields = [:user_name, :age]
[:user_name, :age]
# Now we can use the cast as we imported the Ecto.Changeset
iex> changeset = cast(player, changes, allowed_fields)
#Ecto.Changeset<
action: nil,
changes: %{age: 16},
errors: [],
data: #Player<>,
valid?: true
>
We have the changeset and then we can even validate with a check
iex> validate_number(changeset, :age, greater_than: 16)
#Ecto.Changeset<
action: nil,
changes: %{age: 16},
errors: [
age: {"must be greater than %{number}",
[validation: :number, kind: :greater_than, number: 16]}
],
data: #Player<>,
valid?: false
>
Now that we have theses tools let's build something.
Use Embedded Schemas in LiveView
We want to celebrate and we want a user to be able to go the /promo route and enter a friends email and we can send them a code for 10% off the game!!!
For this we will need a form but we don't want to persist the data as this is a friends email and they didn't give us the okay to store their data.
We will need a new route for the form. Let's start with the core the thing that will always work because it's meant to get the right information all the time. Promo.Recipient
Promo Boundary and Core
We have to create some files here as we at least need to have the form set and then we will need the embedded_schema, let's start there. pento/lib/pento/promo/recipient.ex
defmodule Pento.Promo.Recipient do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field :email, :string
field :first_name, :string
end
@doc false
def changeset(recipient, attrs) do
recipient
|> cast(attrs, [:first_name, :email])
|> validate_required([:first_name, :email])
|> validate_format(:email, ~r/^[\w.!#$%&’*+=?^`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/)
end
end
You can test this if you want by starting up an other iex session or recompile the current session.
iex(7)> recompile
Compiling 1 file (.ex)
Generated pento app
:ok
iex(8)> alias Pento.Promo.Recipient
Pento.Promo.Recipient
iex(9)> user = %Recipient{}
%Pento.Promo.Recipient{id: nil, email: nil, first_name: nil}
We also have the changeset so let's leverage that as well.
iex(10)> valid = %{first_name: "Mario", email: "super@mushroom.com"
...(10)> }
%{email: "super@mushroom.com", first_name: "Mario"}
iex(11)> changeset = Recipient.changeset(user, valid)
#Ecto.Changeset<
action: nil,
changes: %{email: "super@mushroom.com", first_name: "Mario"},
errors: [],
data: #Pento.Promo.Recipient<>,
valid?: true,
...
>}
We can even use the i command to learn more about the changeset
i changeset
Term
#Ecto.Changeset<..., valid?: true, ...>
Data type
Ecto.Changeset
Description
This is a struct. Structs are maps with a __struct__ key.
Reference modules
Ecto.Changeset, Map
Implemented protocols
IEx.Info, Inspect, Jason.Encoder, Phoenix.HTML.FormData, Phoenix.Param, ...
There is even functionality for the Phoenix.HTML.FormData. So that is what we can use to submit and create forms. Let's take a second to make sure that bad values still fail.
iex(12)> invalid = %{email: "joe@emai.com", first_name: 1234}
%{email: "joe@emai.com", first_name: 1234}
iex(13)> Recipient.changeset(user, invalid)
#Ecto.Changeset<
action: nil,
changes: %{email: "joe@emai.com"},
errors: [first_name: {"is invalid", [type: :string, validation: :cast]}],
data: #Pento.Promo.Recipient<>,
valid?: false,
...
>
We can test to see what happens with a break of the custom rules too.
iex(14)> invalid_email = %{email: "joe email", first_name: "joe"}
%{email: "joe email", first_name: "joe"}
iex(15)> Recipient.changeset(user, invalid_email)
#Ecto.Changeset<
action: nil,
changes: %{email: "joe email", first_name: "joe"},
errors: [email: {"has invalid format", [validation: :format]}],
data: #Pento.Promo.Recipient<>,
valid?: false,
...
>
Now let's move onto the Promo context. This is where the interface will take place. pento/lib/pento/promo.ex
defmodule Pento.Promo do
alias Pento.Promo.Recipient
def change_recipient(%Recipient{} = recipient, attrs \\ %{}) do
Recipient.changeset(recipient, attrs)
end
def send_promo(recipient, attrs) do
recipient
|> change_recipient(attrs)
|> Ecto.Changeset.apply_action(:update)
end
end
This is all we need, the change_recipient takes a form value and creates a changeset for us, the send_promo will trigger and email to be sent. As you can see it's almost the exact same as we would have to a context for a database.
The Promo Live View
This is where the Live View will come into play the location will be pento/lib/pento_web/live/promo_live.ex start by creating that file.
defmodule PentoWeb.PromoLive do
use PentoWeb, :live_view
alias Pento.Promo
alias Pento.Promo.Recipient
def render(assigns) do
~H"""
<.header>
Send Your Promo Code to a Friend
<:subtitle>
Use this form to send a 10% off promo code for their first game purchase!
</:subtitle>
</.header>
"""
end
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Now let's create the route for the /promo route. pento/lib/pento_web/router.ex
live("/promo", PromoLive)
Now you can start up the session and test the page by heading to the /promo route. Let's start to add in all the functionality we want. Let's add the recipient struct to the socket and then clear the form. pento/lib/pento_web/live/promo_live.ex
defmodule PentoWeb.PromoLive do
use PentoWeb, :live_view
alias Pento.Promo
alias Pento.Promo.Recipient
def render(assigns) do
~H"""
<.header>
Send Your Promo Code to a Friend
<:subtitle>
Use this form to send a 10% off promo code for their first game purchase!
</:subtitle>
</.header>
"""
end
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_recipient()
|> clear_form()}
end
def assign_recipient(socket) do
assign(socket, :recipient, %Recipient{})
end
def clear_form(socket) do
changeset =
socket.assigns.recipient
|> Promo.change_recipient()
socket |> assign_form(changeset)
end
def assign_form(socket, changeset) do
assign(socket, :form, to_form(changeset))
end
end
Lot's to unpack here so here we go. The first mount was changed to do 2 things add in a blank recipient to the socket and then clear_form will be used to build the form that we will use.
Then we have the assign_recipient which simply builds a clear Recipient
Then we have the clear_form that will take the socket and build the form in the socket.
Then the assign_form will add the form to the socket. Let's talk about the Phoenix.Component.to_form/2. This is built in to the phoenix lib and is meant to take values from and to a form and render them or pull them from a page. It utilizes the .form component to do this. So as long as you are leveraging the .form it should work fine. Let's add that now.
<.form
for={@form}
id="promo-form"
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:first_name]} type="text" label="First Name" />
<.input field={@form[:email]} type="email" label="Email" />
<.button phx-disable-with="Sending...">Send Promo</.button>
</.form>
With all this set we need to work on the events and we should be good. We know that changes will trigger the validate event and the submit will trigger the save event.
def handle_event("validate", %{"recipient" => recipient_params}, socket) do
changeset =
socket.assigns.recipient
|> Promo.change_recipient(recipient_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
This will take any change to the form and run it through the changeset to see if it is valid, then the last line is making sure that the live view can be notified if there is an issue with the desired input.
Real quick let's head to the core_components.ex and look at the input(assigns) for the .form
def input(assigns) do
~H"""
...
<.label for={@id}><%= @label %></.label>
<input type={@type} name={@name} ... />
<.error :for={msg <- @errors}><%= msg %></.error>
...
"""
end
def input(assigns) do
~H"""
<.label for={@id}><%= @label %></.label>
<input type={@type} name={@name} ... />
<.error :for={msg <- @errors}><%= msg %></.error>
"""
end
This again shows us that there is now a way to express issues with the inputs.
Now the only thing left is to take care of the "save" event
def handle_event("save", %{"recipient" => recipient_params}, socket) do
case Promo.send_promo(socket.assigns.recipient, recipient_params) do
{:ok, _recipient} ->
{:noreply,
socket
|> put_flash(:info, "Promo email sent successfully!")
|> clear_form()}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end