Form Component
Live Component
Read time: 10 minutes
In the previous lesson, we learned how to reuse HEEx code using components. However, up to now, we haven't seen any case where we could reuse callback code in a LiveView. For this, we will learn about an important new part of LiveView: Live Components.
This lesson is a direct continuation of the previous one.
git clone https://github.com/adopt-liveview/refactoring.git --branch form-component-done
.
#What is a Live Component?
So far, we've only talked about functional components. They allowed us to greatly simplify our code by preventing us from repeating HTML and will facilitate maintaining our code in the future. However, their limitation is that they have no relationship with business logic.
Live Components, on the other hand, bring not only the advantages of functional components but they can also manage their own local state. Think of Live Components as if they were LiveViews that can be nested within other LiveViews.
#Converting our current code to Live Component
Let's start by converting the new product form to a Live Component. Open your ProductLive.FormComponent
and edit it to:
defmodule SuperStoreWeb.ProductLive.FormComponent do
use SuperStoreWeb, :live_component
def render(assigns) do
~H"""
<div class="bg-grey-100">
<.form
for={@form}
phx-change="validate"
phx-submit="save"
class="flex flex-col max-w-96 mx-auto bg-gray-100 p-24"
>
<%= render_slot(@inner_block) %>
<.input field={@form[:name]} placeholder="Name" />
<.input field={@form[:description]} placeholder="Description" />
<.button type="submit">Send</.button>
</.form>
</div>
"""
end
end
There were no major changes here other than removing the attr
and slot
, removing the @rest
assign, and the main change: we changed at the top from use SuperStoreWeb, :html
to use SuperStoreWeb, :live_component
. With this, we can now apply the Live Component.
#Using a Live Component
Go to your ProductLive.New
and edit its HEEx code for the form to:
~H"""
...
<.live_component
module={FormComponent}
id="new-form"
>
<h1>Creating a product</h1>
</.live_component>
"""
To use a Live Component, we should use the <.live_component>
component, passing at least the module
and id
as parameters. At the moment, it doesn't do anything. Let's go back to the ProductLive.FormComponent
.
#Initializing the state of the FormComponent
At the moment, your creation form page should raise an exception. This happens because we haven't initialized the @form
. Let's start by learning the new initialization callback for Live Components: update/2
:
defmodule SuperStoreWeb.ProductLive.FormComponent do
use SuperStoreWeb, :live_component
alias SuperStore.Catalog
alias SuperStore.Catalog.Product
def update(assigns, socket) do
form =
Product.changeset(%Product{})
|> to_form()
{:ok,
socket
|> assign(form: form)
|> assign(assigns)}
end
# ...
end
The update/2
callback for Live Components looks very similar to the mount/3
of a LiveView. It receives the assigns
passed in the <.live_component>
and the socket
. Just like in mount/3
, we create the form
and assigned it.
The main difference here is that we take the received assigns
in the callback and do assign(assigns)
so that all of them are available within the component as well. In other words, if you use <.live_component module={FormComponent} x={10} y={20}>
, within your Live Component, @x
and @y
will be available.
#Adding events
# ...
def handle_event("validate", %{"product" => product_params}, socket) do
form =
%Product{}
|> Product.changeset(product_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("save", %{"product" => product_params}, socket) do
case Catalog.create_product(product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product created successfully")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
end
def render(assigns) do
~H"""
<div class="bg-grey-100">
<.form
for={@form}
phx-target={@myself}
phx-change="validate"
phx-submit="save"
class="flex flex-col max-w-96 mx-auto bg-gray-100 p-24"
>
<%= render_slot(@inner_block) %>
<.input field={@form[:name]} placeholder="Name" />
<.input field={@form[:description]} placeholder="Description" />
<.button type="submit">Send</.button>
</.form>
</div>
"""
end
As you can see, the entire creation logic is a copy of the original. It's worth mentioning that we added a |> push_navigate(to: ~p"/products/")
so that when the product is created, the user is redirected to the product list.
#phx-target
In our current render/1
, we updated our <.form>
to add the form bindings, but a new binding appears: phx-target
. To understand this binding, I need to reveal to you a new piece of information about Live Components: they live in an isolated process.
Knowing that a Live Component lives in its own process, you need to make it explicit that the form events are handled by it and not by the parent Live View. Using phx-target={@myself}
, the <.form>
will know where to send events.
#Generalizing the component
At the moment, the Live Component only knows how to create new products. Now, let's see how to generalize it to handle editing.
#Where to change the code?
Lets identify which areas need some changes to make editing work:
-
The
update/2
should know to initialize the form with an empty product or an existing product. -
The
handle_event("validate", ...)
should know to initialize the form with an empty product or an existing product. -
The
handle_event("save", ...)
should know whether to useCatalog.create_product/1
orCatalog.update_product/2
.
Here's a suggestion: items 1 and 2 are all about "knowing the product". For new product form we'll use an empty product and for editing product form we'll use the existing product. This can be solved with an assign like <.live_component module={FormComponent} product={...}>
.
As for the third item it depends on knowing whether it's edition or creation form. We can also solve this with an assign like <.live_component module={FormComponent} action={:new / :edit}>
. Additionally, we can use the automatic assign @live_action
that comes from the router. If the page is :edit
, @live_action
will be :edit
. This simplifies things!
#Updating our LiveViews
In your ProductLive.New
, update the HEEx code to:
~H"""
...
<.live_component module={FormComponent} id="new-form" product={%Product{}} action={@live_action}>
<h1>Creating a product</h1>
</.live_component>
...
"""
In your ProductLive.Edit
, update the HEEx code to:
~H"""
...
<.live_component module={FormComponent} id={@product.id} product={@product} action={@live_action}>
<h1>Editing a product</h1>
</.live_component>
...
"""
#Improving the update/2
Let's go back to the FormComponent
. Since we know that the Live Component will always receive a product
as an assign, we can do:
def update(%{product: product} = assigns, socket) do
form =
Product.changeset(product)
|> to_form()
{:ok,
socket
|> assign(form: form)
|> assign(assigns)}
end
Additionally, since the product
variable is part of the assigns
, in the future we can use socket.assigns.product
.
#Improving the handle_event("validate", ...)
def handle_event("validate", %{"product" => product_params}, socket) do
form =
socket.assigns.product
|> Product.changeset(product_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
Instead of directly using %Product{}
, the only thing that changed here is that we built the form
using socket.assigns.product
, which comes from our <.live_component ... product={...}>
.
#Improving the handle_event("save", ...)
At this point, we will use socket.assigns.action
to determine which action to take:
def handle_event("save", %{"product" => product_params}, socket) do
case socket.assigns.action do
:new ->
case Catalog.create_product(product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product created successfully")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
:edit ->
case Catalog.update_product(socket.assigns.product, product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product updated successfully")
|> push_navigate(to: ~p"/products/#{product.id}/edit")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
end
end
As you can see, the only new thing here is the outermost case
that checks the value of socket.assigns.action
. However, our function has become quite large and with nested case
statements. We can improve this by creating another function!
def handle_event("save", %{"product" => product_params}, socket) do
save_product(socket, socket.assigns.action, product_params)
end
defp save_product(socket, :edit, product_params) do
case Catalog.update_product(socket.assigns.product, product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product updated successfully")
|> push_navigate(to: ~p"/products/#{product.id}/edit")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
end
defp save_product(socket, :new, product_params) do
case Catalog.create_product(product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product created successfully")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
end
Now our "save"
event simply forwards values to a new private function called save_product/3
. This function uses pattern matching to check the second argument if it is :edit
or :new
and applies the necessary functions.
#Reviewing the final code
Let's take a look at each part of the code we touched in this lesson to see the final product.
#ProductLive.FormComponent
defmodule SuperStoreWeb.ProductLive.FormComponent do
use SuperStoreWeb, :live_component
alias SuperStore.Catalog
alias SuperStore.Catalog.Product
def update(%{product: product} = assigns, socket) do
form =
Product.changeset(product)
|> to_form()
{:ok,
socket
|> assign(form: form)
|> assign(assigns)}
end
def handle_event("validate", %{"product" => product_params}, socket) do
form =
socket.assigns.product
|> Product.changeset(product_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("save", %{"product" => product_params}, socket) do
save_product(socket, socket.assigns.action, product_params)
end
defp save_product(socket, :edit, product_params) do
case Catalog.update_product(socket.assigns.product, product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product updated successfully")
|> push_navigate(to: ~p"/products/#{product.id}/edit")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
end
defp save_product(socket, :new, product_params) do
case Catalog.create_product(product_params) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product created successfully")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
form = to_form(changeset)
{:noreply, assign(socket, form: form)}
end
end
def render(assigns) do
~H"""
<div class="bg-grey-100">
<.form
for={@form}
phx-target={@myself}
phx-change="validate"
phx-submit="save"
class="flex flex-col max-w-96 mx-auto bg-gray-100 p-24"
>
<%= render_slot(@inner_block) %>
<.input field={@form[:name]} placeholder="Name" />
<.input field={@form[:description]} placeholder="Description" />
<.button type="submit">Send</.button>
</.form>
</div>
"""
end
end
#ProductLive.New
defmodule SuperStoreWeb.ProductLive.New do
use SuperStoreWeb, :live_view
import SuperStoreWeb.CoreComponents
alias SuperStore.Catalog.Product
alias SuperStoreWeb.ProductLive.FormComponent
def render(assigns) do
~H"""
<.header>
New Product
<:subtitle>Use this form to create product records in your database.</:subtitle>
</.header>
<.live_component module={FormComponent} id="new-form" product={%Product{}} action={@live_action}>
<h1>Creating a product</h1>
</.live_component>
<.back navigate={~p"/"}>Back to products</.back>
"""
end
end
#ProductLive.Edit
defmodule SuperStoreWeb.ProductLive.Edit do
use SuperStoreWeb, :live_view
alias SuperStore.Catalog
alias SuperStoreWeb.ProductLive.FormComponent
def mount(%{"id" => id}, _session, socket) do
product = Catalog.get_product!(id)
{:ok, assign(socket, product: product)}
end
def render(assigns) do
~H"""
<.header>
Editing Product <%= @product.id %>
<:subtitle>Use this form to edit product records in your database.</:subtitle>
</.header>
<.live_component module={FormComponent} id={@product.id} product={@product} action={@live_action}>
<h1>Editing a product</h1>
</.live_component>
<.back navigate={~p"/products/#{@product}"}>Back to product</.back>
"""
end
end
#Conclusion
As we can see, the LiveViews became quite lean. There is no repeated form code. With this lesson, we learned how to reuse business logic in more than one LiveView using Live Components!
If you had any issues you can see the final code for this lesson using git checkout live-component-done
or cloning it in another folder using git clone https://github.com/adopt-liveview/refactoring-crud.git --branch live-component-done
.
#Recap!
- Live Components are components capable of managing their own state. They are also an excellent tool to avoid code duplication.
-
To create a Live Component, you can use
use YourProjectWeb, :live_component
at the top of the module. -
To use a Live Component, you use the
<.live_component module={SomeModule} id="some-id">
component. -
You can use the
update/2
callback of a Live Component to define the initial state. -
You can use
assign(socket, assigns)
within theupdate/2
to save all assigns passed in the<.live_component x={10} y={20} z={30}>
call to the component. - Live Components live in separate processes from the LiveView that uses them.
-
When creating events in Live Components, you can use
phx-target={@myself}
to make it clear that the event will be handled by this component and not the LiveView that contains it.
Feedback
Got any feedback about this page? Let us know!