This is a post about getting a form in a LiveView to invoke a Phoenix controller action, on your terms. If you want to deploy a Phoenix LiveView app right now, then check out how to get started. You can be up and running in minutes.
Have you ever wanted to use LiveViews for a site’s authentication? Among many other implementation details, you need to save some data to identify the logged-in user. This can be a token or some unique identifier, and it needs to persist even as the user navigates around your app and different LiveViews get created and destroyed.
The obvious solution is to store this token or unique identifier in the session. You can create a Phoenix controller with a :create
action that generates a token, then saves it in the session using functions of the Plug.Conn
module:
defmodule MyAppWeb.SessionController do
use MyAppWeb, :controller
def create(conn, %{"user" => user}) do
token = Accounts.generate_user_session_token(user)
conn
|> put_session(:user_token, token)
|> redirect(to: signed_in_path(conn))
end
end
You continue building your authentication system and decide that once a user signs up, using a form in a LiveView, they should be automatically logged in. This means saving the session data from within the LiveView—and only after the new user is finished signing up and you’re happy for them to have access to the app.
Problem
The LiveView lifecycle starts as an HTTP request, but then a WebSocket connection is established with the server, and all communication between your LiveView and the server takes place over that connection.
Why is this important? Because session data is stored in cookies, and cookies are only exchanged during an HTTP request/response. So writing data in session can’t be done directly from a LiveView.
Can we call the controller’s :create
action from our LiveView form, and have it write the data for us? And can we make sure that happens only once the new user’s registration process is complete: their data validated and saved?
Solution
We can make an HTTP route call to a controller when the form is submitted by adding the :action
attribute to our forms, specifying the URL we want to use.
And the :phx-trigger-action attribute allows us to make form submission conditional on some criteria.
In this case, we want to trigger the form submit, and log the new user in, after saving their registration data in the database without errors; if this doesn’t happen, the action should not trigger, and instead we need to keep our LiveView connected and display any generated errors.
Let’s see how to do it.
Let’s start by defining, in our LiveView, the form that we’ll use to fill out the user’s data:
def render(assigns) do
~H"""
<h1>Register</h1>
<.form
id="registration_form"
:let={f}
for={@changeset}
as={:user}
>
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= error_tag f, :email %>
<%= label f, :password %>
<%= password_input f, :password, required: true %>
<%= error_tag f, :password %>
<div>
<%= submit "Register" %>
</div>
</.form>
"""
end
This form uses a changeset to build the necessary inputs. In this case, just a couple of inputs to save the user’s email and password.
We define the changeset and add it to the LiveView assigns:
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%User{})
{:ok, assign(socket, changeset: changeset)}
end
We also add a couple of callbacks: validate
to validate the data that the user enters into the form, and show us live errors if needed, and save
to persist the user’s information into the database.
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%User{}, user_params)
{:noreply, assign(socket, changeset: changeset}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
changeset = Accounts.change_user_registration(user)
{:noreply, assign(socket, changeset: changeset)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
We add three more attributes to our form: :phx-submit
, :phx-change
and :action
. The first two invoke the callbacks we defined above, and :action
executes our controller’s :create
action using the URL users/log_in/
.
Spoiler alert! Verified routes coming soon
<.form
id="registration_form"
:let={f}
for={@changeset}
as={:user}
phx-submit="save"
phx-change="validate"
action={~p"/users/log_in/"} #{Routes.session_path(@socket, :create)}
>
With this, we get the :create
action to run once the form is submitted; however, the action will run happily even if there was an error saving the user data. We don’t want that!
This is where the :phx-trigger-action
attribute comes into play. Let’s use it to submit the form only if the user has been successfully saved to the database.
First we add the phx-trigger-action
attribute to the form:
<.form
id="registration_form"
:let={f}
for={@changeset}
as={:user}
phx-submit="save"
phx-change="validate"
action={~p"/users/log_in/"} #{Routes.session_path(@socket, :create)}
phx-trigger-action={@trigger_submit}
>
You can probably see where this is going: phx-trigger-action
takes a boolean value, so when @trigger_submit
is true
, the form will get submitted and the action defined in our action
attribute will be triggered. Let’s add trigger_submit
to the LiveView assigns:
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%User{})
{:ok, assign(socket, changeset: changeset, trigger_submit: false)}
end
We change trigger_submit
to true
only if the user has been saved correctly:
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
changeset = Accounts.change_user_registration(user)
socket = assign(socket, changeset: changeset, trigger_submit: true)
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
Now the :create
action is only executed once the user is saved correctly. In case of error, the LiveView shows the registration errors to the user.
Possible errors and how to fix them
Let’s prevent two common errors that can trip us up when using the phx-trigger-action
option.
Form parameters are empty when phx-trigger-action is triggered
The first error is very specific to our use case and is related to our form fields: When the form is submitted and the form’s parameters reach the controller, the parameter that stores the user’s password is empty, even though we’re sure we’ve entered a value.
This is related to the password type input and its behavior. All we have to do is explicitly give the input a value by adding the value
option like so:
<%= password_input f, :password,
required: true,
value: input_value(f, :password)
%>
With this simple step, the value of our password input will be sent in the parameters of the form!
The controller route is not found, even though it is defined in the router
The second mystifying error is this: phx-trigger-action
tries to execute the controller action we specified, but the route cannot be found on the router, even when it is the correct one.
[debug] ** (Phoenix.Router.NoRouteError)
no route found for PUT /users/log_in (MyAppWeb.Router)
In this case, it’s related to how our changeset is interpreted when the form and its attributes are being built.
In our example, we insert the user into the database just before submitting the form, so our changeset contains the data of a record that already exists. Phoenix thinks that we’re trying to modify that record; that’s when the form is built using the put
method instead of the post
method.
The solution is simple; we just have to add the option method="post"
to our form’s definition.
<.form
id="registration_form"
:let={f}
for={@changeset}
as={:user}
phx-submit="save"
phx-change="validate"
action={~p"/users/log_in/"} #{Routes.session_path(@socket, :create)}
phx-trigger-action={@trigger_submit}
method="post"
>
Discussion
The phx-trigger-action
option is ideal when you need to do final validations just before submitting form data via an HTTP request from a LiveView.
It’s also so simple to use that you’d think nothing could go wrong. However, as we’ve seen, headaches can arise from the underlying form behavior, and they can be tricky to debug. We’ve highlighted two such problems to help you use the phx-trigger-action
option painlessly.