LiveView is a compelling choice for building modern web apps. Built on top of Elixir's OTP tooling and leveraging WebSockets, it offers super-fast real-time, interactive features alongside impressive developer productivity.
LiveView keeps the developer's mind firmly rooted on the server-side, even when testing and debugging. This can empower you to deliver interactive features in single-page apps faster than ever before. Some of the most common interactions on the web are form validation and submission. These days, users expect to see form feedback presented to them in real-time, and LiveView offers first-class support for exactly that.
In this post, I'll show you how to build LiveView forms that validate changes and provide feedback to the user in real-time. Along the way, you'll learn how to model change in your Phoenix application with schemaless changesets and compose LiveView code to handle that change.
The Feature: Adding a Form to a Phoenix LiveView App
The form examples we'll be looking at in this post are inspired by the "Forms and Changesets" chapter in my book, Programming LiveView, co-authored with Bruce Tate. Check it out for an even deeper dive into LiveView testing and so much more.
Before we dive into writing any actual code, let's talk about the feature we'll build. Imagine that you're responsible for a Phoenix web app, Arcade, that provides in-browser games to users. A user can log in, select a game to play, and even invite friends to play games with them.
We'll focus on that last piece of functionality. A user can invite a friend to play a game with them by filling out a form with the friend's email address. This will email a link to the recipient's email address so they can join the game.
Our form will need to implement some validation — namely, to ensure that a valid email address is provided. It should show any validation errors, as well as the results of a successful form submission in real-time. We won't worry too much about the exact details of sending emails for now. Instead, we'll keep our focus on the form validations in LiveView.
We'll begin where you'll almost always want to start when building a new feature in a Phoenix application — in the application core. We'll model game invitations in their module. You'll build a boundary layer in a Phoenix context module that we'll call on in LiveView later to validate form input and send invitation emails.
Let's get started!
Model Change in Phoenix with Ecto Changesets
We're almost ready to start writing code. But first, let's think about the role that forms and changesets play in our Phoenix application.
Consider Ecto changesets. Changesets are policies for changing data, and they:
- Cast unstructured user data into a known, structured form — most commonly, an Ecto database schema, ensuring data safety.
- Capture differences between safe, consistent data and a proposed change, allowing for efficiency.
- Validate data using known consistent rules, ensuring data consistency.
- Provide a contract for communicating error states and valid states, ensuring a common interface for change.
1def changeset(game, attrs) do
2 game
3 |> cast(attrs, [:name, :description, :unit_price, :sku])
4 |> validate_required([:name, :description, :unit_price, :sku])
5 |> unique_constraint(:sku)
6end
The changeset/2
function captures differences between the structured game
and the unstructured attrs
.
Then, with cast/4
, the changeset trims the attributes to a known field list and converts them to the correct types. This ensures safety by guaranteeing that you won't let any unknown or invalid attributes into your database.
Finally, the validate/2
and unique_constraint/2
functions validate the inbound data, ensuring consistency.
The result is a data structure with known states and error message formats, ensuring interface compatibility.
Consequently, any forms that use this changeset know exactly how to behave — validating form input and presenting errors in accordance with the changeset's rules.
In this post, we're going to shift off of this well-known path of generated changesets. You'll see just how versatile changesets can be when it comes to modeling changes to data, with or without a database. You'll build a custom, schemaless changeset for data that isn't backed by a database table, and you'll use that changeset in a form within a live view.
Schemaless Changesets in the Phoenix Application Core
You've likely used changesets to model changes to data that is persisted in your database. Our game invitation feature doesn't require database persistence, however. A user will provide the email address of the friend they'd like to invite, and our application will simply send out the email with the link to the game. We don't need to store that invitee's data at this point.
Luckily, we can use schemaless changesets to model data that doesn't get saved to the database. A schemaless changeset is based on a simple Elixir map or struct, rather than an Ecto schema-backed struct. The only difference is that, when working with a plain Elixir struct, we need to provide the changeset with the type of information that Elixir would normally handle. We'll see this in action in a moment.
First, let's define the core module that we'll use to model a game Recipient. Create a new file, arcade/lib/game/Recipient.ex
, and define the module like this:
1defmodule Arcade.Invite.Recipient do
2 defstruct [:name, :email]
3end
Our module is simple so far. It implements a struct with two keys, :name
and :email
.
Next up, we need to give our module awareness of the types that will be considered valid by any changeset we create. Let's use a module attribute to store this map of types so that we can access it later:
1defmodule Arcade.Invite.Recipient do
2 defstruct [:name, :email]
3 @types %{game_name: :string, email: :string}
4end
Now, we'll alias the module and import Ecto.Changeset
so that we can use the changeset functions:
1defmodule Arcade.Invite.Recipient do
2 defstruct [:name, :email]
3 @types %{game_name: :string, email: :string}
4
5 alias Arcade.Invite.Recipient
6 import Ecto.Changeset
7end
Finally, we're ready to define the changeset/2
function that will be responsible for casting recipient data into a changeset and validating it:
1defmodule Arcade.Invite.Recipient do
2 # ...
3 def changeset(%Recipient{} = invitation, attrs) do
4 {invitation, @types}
5 |> cast(attrs, Map.keys(@types))
6 |> validate_required([:game_name, :email])
7 |> validate_format(:email, ~r/@/)
8 end
9end
We validate the presence of the :game_name
and :email
attributes, and then validate the format of :email
.
Now, we can create recipient changesets like this:
1iex> alias Arcade.Invite.Recipient
2iex> i = %Recipient
3iex> Recipient.changeset(r, %{email: "juniper@email.com", game_name: "Chess"})
4#Ecto.Changeset<...valid?: true>
Let's see what happens if we try to create a changeset with an attribute of an invalid type:
1iex> Recipient.changeset(r, %{email: "juniper@email.com", game_name: 1234})
2#Ecto.Changeset<errors: [game_name: {"is invalid", ...]}],valid?: false>
Ecto.Changeset.cast/4
relies on @types
to identify the invalid type and provide a descriptive error.
Next, try a changeset that breaks one of the custom validation rules:
1iex> Recipient.changeset(r, %{email: "juniper's email", game_name: "Chess"})
2#Ecto.Changeset<changes: %{email: "juniper's email", ...},
3 errors: [email: {"has invalid format", ...}],valid?: false>
This function successfully captures our change policy in code, and the returned changeset tells the user exactly what is wrong.
Now that our changeset is up and running, let's quickly build out an Invite
context that will present the interface for interacting with the changeset.
The Boundary Layer in Elixir
Create a file, lib/arcade/invite.ex
and add in the following:
1defmodule Arcade.Invite do
2 alias Arcade.Invite.Recipient
3
4 def change_invitation(%Recipient{} = recipient, attrs \\ %{}) do
5 Recipient.changeset(recipient, attrs)
6 end
7
8 def send_invite(recipient, attrs) do
9 # send email to promo recipient
10 end
11end
This context is a beautifully concise boundary for our service. The change_invitation/2
function returns a recipient changeset, and send_invite/2
is a placeholder for sending a game invite email.
Other than the internal tweaks we made inside Recipient.changeset/2
, building a Phoenix context module with a schemaless changeset looks identical to building an Ecto-backed one. When all is said and done, in the view layer, schemaless changesets and schema-backed ones will look identical.
Let's turn our attention to the view layer now and build out our live view.
Build and Define the Live View
This live view will have the feel of a typical live view with a form. First, we'll create a simple route and wire it to a live view. Next, we'll use our Invite
context to produce a schemaless changeset, and add it to the socket within a mount/3
function. We'll render a form with this changeset and apply changes to the changeset by handling events from the form.
Let's get going!
Create a file, lib/arcade_web/live/invite_live.ex
and fill in the following:
1defmodule ArcadeWeb.InviteLive do
2 use ArcadeWeb, :live_view
3 alias Arcade.Invite
4 alias Arcade.Invite.Recipient
5
6 def mount(_params, _session, socket) do
7 {:ok, socket}
8 end
9end
We pull in the LiveView behavior, alias our modules for later use and implement a simple mount/3
function.
Let's use an implicit render/1
. Create a template file in lib/arcade_web/live/invite_live.html.leex
, starting with some simple markup:
1<h2>Invite a Friend to Play!</h2>
2<h4>
3 Enter the name of a game and your friend's email below and we'll send them an
4 invite to join you in playing a game!
5</h4>
Now, let's define a live route and fire up the server. In the router, add the following route behind authentication:
1scope "/", ArcadeWeb do
2 pipe_through [:browser, :require_authenticated_user]
3 live "/invite", InviteLive
4end
With that, you should be able to fire up the Phoenix server, point your browser at /invite
and see your live view render the invitation template:
As the live view is up and running, we're ready to build out the form for an invitation recipient.
Render the Phoenix LiveView Form
We'll use mount/3
to store a recipient struct and a changeset in the socket:
1 def mount(_params, _session, socket) do
2 {:ok,
3 socket
4 |> assign_recipient()
5 |> assign_changeset()}
6end
7
8def assign_recipient(socket) do
9 socket
10 |> assign(:recipient, %Recipient{})
11end
12
13def assign_changeset(%{assigns: %{recipient: recipient}} = socket) do
14 socket
15 |> assign(:changeset, Invite.change_recipient(recipient))
16end
The mount/3
function uses two helper functions, assign_recipient/1
and assign_changeset/1
, to add a recipient struct and a changeset for that recipient to socket assigns. These pure, single-purpose reducer functions are reusable building blocks for managing the live view's state.
Remarkably, the schemaless changeset can be used in our form exactly like database-backed ones. We'll use socket.assigns.changeset
in the template's form, like this:
1<%= f = form_for @changeset, "#",
2 id: "invite-form",
3 phx_change: "validate",
4 phx_submit: "save" %>
5
6 <%= label f, :game_name %>
7 <%= text_input f, :game_name %>
8 <%= error_tag f, :game_name %>
9
10 <%= label f, :email %>
11 <%= text_input f, :email%>
12 <%= error_tag f, :email %>
13
14 <%= submit "Send Invite"%>
15</form>
Now, if you point your browser at /invite
, you should see this:
Our form implements two LiveView bindings, phx-change
, and phx-submit
. Let's dig into these events now.
Handle Form Events in LiveView
We'll start with a look at the phx-change
event. LiveView will send a "validate"
event each time the form changes and include the form params in the event metadata.
So, we'll implement a handle_event/3
function for this event that builds a new changeset from the params and adds it to the socket:
1# lib/arcade_web/live/invite_live.ex
2def handle_event(
3 "validate",
4 %{"recipient" => recipient_params},
5 %{assigns: %{recipient: recipient}} = socket) do
6 changeset =
7 recipient
8 |> Invite.change_recipient(recipient_params)
9 |> Map.put(:action, :validate)
10
11 {:noreply,
12 socket
13 |> assign(:changeset, changeset)}
14end
Let's break this down. The Invite.change_recipient/2
context function creates a new changeset using the recipient from socket state and the params from the form change event.
Then, we use Map.put(:action, :validate)
to add the validate
action to the changeset, a signal that instructs Phoenix to display errors. Phoenix will not display the changeset's errors otherwise.
When you think about it, this approach makes sense. Not all invalid changesets should show errors on the page. For example, the empty form for the new changeset shouldn't show any errors because the user hasn't provided any input yet. So, the Phoenix form_for
function needs to be told when to display a changeset's errors. If the changeset's action is empty, then no errors are set on the form object — even if the changeset is invalid and has a non-empty :errors
value.
Finally, assigns/2
adds the new changeset to the socket, triggering render/1
and displaying any errors.
Let's take a look at the form tag that displays those errors on the page. Typically, each field has a label, an input control, and an error tag, like this:
1<%= label f, :email %>
2<%= text_input f, :email%>
3<%= error_tag f, :email %>
The error_tag/2
Phoenix view helper function displays the form's errors for a given field on a changeset, when the changeset's action is :validate
.
Now, point your browser at /invite
and fill out the form with a game name and an invalid email. As you can see in this image, the UI updates to display the validation errors:
That was surprisingly easy! We built a simple and powerful live view with a reactive form that displays any errors in real-time.
The live view calls on the context to create a changeset, renders it in a form, validates it on form change, and then re-renders the template after each form event. We get reactive form validation for free without writing any JavaScript or HTML. We let Ecto changesets handle the data validation rules, and we let the LiveView framework handle the client/server communication for triggering validation events and displaying the results.
There's just one thing about our form validation that needs some improvement. You'll notice that as soon as you start typing into the email form, an error appears because our validation event fires whenever the form field changes. This doesn't present our users with the best experience — we're telling them there is an error with their input before they get a chance to finish typing their full email.
Instead, we want the validation event to fire when a user clicks away or blurs from the email input. Luckily for us, LiveView makes it easy to implement this functionality with the help of the phx-debounce
binding. Update your email form field to look like this:
1<%= text_input f, :email, phx_debounce: "blur" %>
Now the "validate"
event will only fire when a user blurs away from the email input field, and we won't show any premature validation error messages.
Learn more about LiveView's support for debouncing and other rate-limiting options.
As you might imagine, the phx-submit
event works very similarly to phx-change
. The "save"
event fires when the user submits the form. To respond to this event, we can implement a handle_event/3
function that uses the (currently empty) context function, Invite.send_invite/2
. The context function should create and validate a changeset.
If the changeset is, in fact, valid, we can pipe it to some helper function or service that handles the details of sending invitation emails.
If the changeset is not valid, we can return an error tuple. Then we can update the UI with a success or failure message accordingly. I'll leave you to practice what you're learning by implementing this behavior on your own.
Wrap-up: LiveView — A Great Choice to Build and Validate Forms in Elixir
Now you've seen that Ecto changesets are not tightly coupled to the database. Schemaless changesets let you tie backend services to Phoenix forms any time you require validation and security, whether or not your application needs to access a full relational database.
LiveView supports custom integration of forms to backend code with these schemaless changesets. To do so, you need only provide the Changeset.cast/4
function with the first argument of a two-tuple holding both data and type information. This type of code is ideal for implementing form scenarios requiring validation but without the typical database backend.
Whether you're working with schema-backed or schemaless changesets, LiveView provides real-time form validation and feedback, with very little hand-rolled code. We can use LiveView event bindings to handle form validation and submission in real-time with a few simple event handlers that call out to our nice, clean Phoenix context code.
With that, you have everything you need to build basic forms in LiveView. To dig deeper into LiveView's rich forms offerings, check out the docs. Happy coding!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!