Triggering a Phoenix controller action from a form in a LiveView

Image by Annie Ruygt

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/.

<.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.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!

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.