elixir

Phoenix LiveView Under The Hood: The Form Function Component

Sophie DeBenedetto

Sophie DeBenedetto on

Phoenix LiveView Under The Hood: The Form Function Component

Thanks to HEEx and function components, LiveView provides developers with a sleek, ergonomic syntax for building and maintaining sophisticated interactive UIs. LiveView's form/1 function component is a great example of this, making it easier than ever before to render complex forms within LiveView.

However, the form/1 function component can feel a little mysterious to anyone unfamiliar with LiveView's function components.

In this post, we'll look under the hood of the form/1 function component. We'll dive into the Phoenix Component functionality that underpins this function and explore features like component slots. When we're done, you'll know exactly how the form/1 function renders your forms and you'll have a deeper understanding of LiveView components, setting you up to build your own components in the future.

What Are Function Components in Phoenix LiveView?

Before we dive into the form/1 function component, let's discuss LiveView's function components at a high level. Function components are defined in modules that use the Phoenix.Component behavior. Any function that takes in an argument of assigns and returns some markup wrapped in a HEEx template is a function component.

Let's take a look at a simple example. We'll define a UserDetails module that implements a function component, contact_info.

defmodule MyAppLive.UserDetails do
  use Phoenix.Component
 
  def contact_info(assigns) do
    ~H"""
    <div class="contact-info">
      <p><strong>Phone:</strong><%= @user.phone_number %></strong></p>
      <p><strong>Email:</strong><%= @user.email %></strong></p>
    </div>
    """
  end
end

Now, we can call on this component with any HEEx template like this (assuming you have an available @current_user assigns):

<UserDetails.contact_info user="{@current_user}" />

If you import the UserDetails module or if you're calling on this function component somewhere where the function is defined locally (i.e., elsewhere in the UserDetails module), you can leave off the module name from the function call:

<.contact_info user={@current_user}>

With this basic understanding of function components under your belt, we're ready to dive into the .form/1 function component that LiveView makes available to you. We'll take a look at some more advanced features of function components along the way.

Calling the form/1 Function Component

LiveView implements a function component, form/1. This function component is defined in Phoenix.LiveView.Helper and imported into all of your live views. We'll start with the entry point of the form/1 code flow—the caller.

In this example, we'll construct a form for a book record that has a title, description, and author, like this:

<.form
    let={f}
    for={@changeset}
    id="book-form"
    phx-change="validate"
    phx-submit="save">
 
  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>
 
  <%= label f, :description %>
  <%= textarea f, :description %>
  <%= error_tag f, :description %>
 
  <%= label f, :author %>
  <%= select f, :author_id, Enum.map(@authors, &{&1.full_name, &1.id}) %>
  <%= error_tag f, :author_id %>
 
  <div>
    <%= submit "Save", phx_disable_with: "Saving..." %>
  </div>
</.form>

Already, we can see that the form/1 function component offers a clean and easy-to-read syntax for describing forms. Let's break down the function invocation here. Then, we'll trace what's happening under the hood.

The form/1 function component, like all function components, is called with one argument of the assigns. Some of the attributes passed as part of the assigns probably look familiar from working with Phoenix.HTML.form_for/4. We pass in the changeset, an ID, and some LiveView form bindings. In fact, we can optionally pass in any of the same options you would give to Phoenix.HTML.form_for/4, and the form/3 function component will use those options in the same way.

In addition to the form options we're giving to assigns, we also use the let assigns to tell the function component to give a value back to the caller. More on this in a bit.

Lastly, you'll notice that we're actually calling the form/1 function component with an opening and closing <.form> tag, and we've enclosed the content of our form within those tags. This eloquent, declarative syntax is made possible by the Phoenix Component's render_slot/2 functionality. We'll see that in action later on in this post.

Now that we've seen how to call on the form/1 function component, let's dive into the function's implementation.

The form/1 Component Under the Hood

You already know that a function component takes in an argument of some assigns and returns some markup wrapped in a HEEx template. So, you won't be surprised to see that at its core, the form/1 function component sets up a Phoenix.HTML.Form struct and renders it in a HEEx template that returns an HTML form.

We'll break down this process one step at a time, beginning with the creation of the form struct.

Creating the Phoenix.Form

The first thing that the form/1 function does under the hood is to construct a Phoenix.HTML.Form struct. This is constructed using the data in the assigns that we passed into our function call. Let's take a look at the code:

# Extract options and then to the same call as form_for
action = assigns[:action] || "#"
form_for = assigns[:for] || raise ArgumentError, "missing :for assign to form"
form_options = assigns_to_attributes(assigns, [:action, :for])
 
# Since FormData may add options, read the actual options from form
%{options: opts} =
  form = %Phoenix.HTML.Form{
    Phoenix.HTML.FormData.to_form(form_for, form_options)
    | action: action
  }

First, it casts the data from the assigns into the same format that Phoenix.HTML.form_for/4 uses to construct form structs. Then, it initializes a new struct.

Next up, the function constructs the form method, CSRF token, and multi-part setting from the data in the form struct, like this:

# And then process method, csrf_token, and multipart as in form_tag
{method, opts} = Keyword.pop(opts, :method, "post")
{method, hidden_method} = form_method(method)
 
{csrf_token, opts} =
  Keyword.pop_lazy(opts, :csrf_token, fn ->
    if method == "post", do: Plug.CSRFProtection.get_csrf_token_for(action)
  end)
 
opts =
  case Keyword.pop(opts, :multipart, false) do
    {false, opts} -> opts
    {true, opts} -> Keyword.put(opts, :enctype, "multipart/form-data")
  end

With the form struct and options in place, we then move on to constructing the assigns that will be rendered in the HEEx template here:

assigns =
  LiveView.assign(assigns,
    form: form,
    csrf_token: csrf_token,
    hidden_method: hidden_method,
    attrs: [action: action, method: method] ++ opts
  )

For LiveView to track changes to assigns values rendered by a function component, it must render a valid assigns either passed in as the only argument given to the function or created via a call to Phoenix.LiveView.assign/3 or Phoenix.LiveView.assign_new/3. So, the function component uses LiveView.assign/3 here to construct a new set of assigns to render in the HEEx template.

With the assigns in place, the function will then render the template. Let's take a look at that now.

Rendering the HEEx Template

The form/1 function component, like all function components, returns some markup wrapped in a HEEx template. Let's take a look at that return value now:

 ~H"""
<form {@attrs}>
  <%= if @hidden_method && @hidden_method not in ~w(get post) do %>
    <input name="_method" type="hidden" value={@hidden_method}>
  <% end %>
  <%= if @csrf_token do %>
    <input name="_csrf_token" type="hidden" value={@csrf_token}>
  <% end %>
  <%= render_slot(@inner_block, @form) %>
</form>
"""

Unsurprisingly, the form/1 function component simply returns a HEEx template wrapping a call to an HTML <form>. Let's dig into how the template uses some of the assigns established in the previous step.

First up, you can see that the @attrs assigns is interpolated directly into the opening <form> tag. This will render the tag with the appropriate method= and action= attributes. Next up, you can see that the @hidden_method assigns determines if and how to populate the hidden input. Then the @csrf token, if present, is added to the HTML form in another hidden input.

Most of this template has been pretty straightforward so far. The HEEx template renders, and HTML is constructed from various assigns values. Next up, we'll look at how the form fields we specified between our opening and closing <.form> tags are rendered into the template with the component slot functionality.

Render the Inner Block with Phoenix Component Slots

Phoenix Components implement a feature called "slots". Slots enable us to give blocks to our function component calls, nesting them within opening and closing function component tags like regular HTML tags. Slots are the reason for the form/1 syntax we used above:

<.form ...assigns>
  <!-- form fields -->
</.form>

Here's how it works. Suppose you call on a function component with opening and closing function component tags. That function component's template renders the content in those tags with the help of the render_slot/2 function.

Let's take a look at an example. Recall the UserDetails.contact_info/1 function component we defined earlier:

defmodule MyAppLive.UserDetails do
  use Phoenix.Component
 
  def contact_info(assigns) do
    ~H"""
    <div>
      <p><strong>Phone:</strong><%= @user.phone_number %></p>
      <p><strong>Email:</strong><%= @user.email %></p>
    </div>
    """
  end
end

We can refactor it to use slots like this:

defmodule MyAppLive.UserDetails do
  use Phoenix.Component
 
  def contact_info(assigns) do
    ~H"""
    <div class="contact-info">
      <%= render_slot(@inner_block) %>
    </div>
    """
  end
end

Now, we have a dynamic function component we can use to render any type of contact info:

<UserDetails.contact_info user="{@current_user}">
  <p><strong>Phone:</strong><%= @current_user.phone_number %></p>
</UserDetails.contact_info>

Or:

<UserDetails.contact_info user={@current_user}>
  <p>
    <strong>Address:</strong>
    <ul>
      <li><%= @current_user.street_address %></li>
      <li><%= @current_user.city %></li>
      <li><%= @current_user.zip_code %></li>
      <li><%= @current_user.country %></li>
    </ul>
  </p>
</UserDetails.contact_info>

The HTML markup we include between the opening and closing function component tags becomes available in the function component as the @inner_block assigns. The render_slot/2 function renders that content for us.

We can clean this up even further with the let assigns to yield a variable from the function component's template back to the calling template. Let's take a look.

First, we'll call the function component with an assigns of let set equal to a variable, user:

<UserDetails.contact_info let="{user}" user="{@current_user}">
  <!-- coming soon -->
</UserDetails.contact_info>

Then, we'll update the function component's call to render_slot/2 by invoking it with a second argument of the @user assignment, like this:

def contact_info(assigns) do
  ~H"""
  <div class="contact-info">
    <%= render_slot(@inner_block, @user) %>
  </div>
  """
end

The function component sets the variable we specified in the let assignment equal to the value of whatever is passed in as the second argument to render_slot/2. Now, the inner content between the function component's opening and closing tags in the calling template has access to the user variable. We can update our inner block to look like this:

<UserDetails.contact_info let="{user}" user="{@current_user}">
  <p><strong>Phone:</strong><%= user.phone_number %></p>
</UserDetails.contact_info>

With this basic understanding of slots in place, let's revisit the form/1 template:

~H"""
<form {@attrs}>
  <%= if @hidden_method && @hidden_method not in ~w(get post) do %>
  <input name="_method" type="hidden" value="{@hidden_method}" />
  <% end %> <%= if @csrf_token do %>
  <input name="_csrf_token" type="hidden" value="{@csrf_token}" />
  <% end %> <%= render_slot(@inner_block, @form) %>
</form>
"""

Here, its calling render_slot/2 with the @inner_block assignment and the @form assignment. Recall that we invoked form/1 like this:

<.form
    let={f}
    for={@changeset}
    id="book-form"
    phx-change="validate"
    phx-submit="save">
 
  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>
 
  <!-- ... -->
</.form>

So, the form fields render as the @inner_block assignment. The call to <.form> establishes a variable f, which gets set to a value of the second argument passed to render_slot/2. The second argument to render_slot/2 is the @form assignment, pointing to our Phoenix.HTML.Form struct. So, the inner content in our calling template can use the f variable to reference the form struct and build out the Phoenix form to render.

And that's it!

Wrap Up: Demystifying Phoenix LiveView's Form Function Component

While the call to the form/1 function component can seem mysterious, tracing the code under the hood isn't too daunting.

To recap: we can see that the function establishes a Phoenix form and assembles some assigns. Then, it returns a HEEx template that renders an HTML form with the assigns. The HEEx template uses Phoenix Component's slot functionality to render the inner content of our specified form fields, and yields the Phoenix form back to the calling template where we construct those fields.

I hope that this dive under the hood of the form/1 function component has not only demystified that function, but also given you a deeper understanding of how you can use function components and slots in your own live views. Now, you're ready to build out your own extensible function component that dynamically renders different inner blocks of content.

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!

Write for our blog

Would you like to contribute to the AppSignal blog? We're looking for skilled mid/senior-level Ruby, Elixir, and Node.js writers.

Find out more and apply

Share this article

RSS
Sophie DeBenedetto

Sophie DeBenedetto

Our guest author Sophie is a Senior Engineer at GitHub, co-author of Programming Phoenix LiveView, and co-host of the BeamRad.io podcast. She has a passion for coding education. Historically, she is a cat person but will admit to owning a dog.

All articles by Sophie DeBenedetto

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps