Extracting LiveView Logic Into LiveComponents

Published on: 9 January 2021
In this short guide, we’re going to look into extracting business logic from LiveView
modules, and specifically, extracting it into LiveComponent
s. We’re going to use the boilerplate Phoenix LiveView project throughout the guide, just to demonstrate the concepts.
We will start by quickly separating some logic into a new LiveComponent
, leaving some of the work to be done in the parent LiveView
. After that, we will decouple the component completely, making it reusable. Finally, we will look into options for exchanging data between the parent LiveView
and the LiveComponent
.
By the end of this article, you should be able to see how easy it is to break down any LiveView
into smaller, reusable pieces. Let’s get started.
Creating a new project
Create a new boilerplate LiveView project, using the standard mix phx.new
generator, adding the --live
option. We’re not using Ecto
so let’s exclude it with --no-ecto
. We have to give it a name, so let’s call it breakout
:
$ mix phx.new breakout --live --no-ecto
Enter Y
to install dependencies and cd breakout
to get into the directory. You can then start the server as usual:
$ iex -S mix phx.server
Now when you go to localhost:4000
in your browser, you’ll see the “Welcome to Phoenix!” page. It has a search field powered by LiveView, which uses the currently installed dependencies in your project to generate results. The LiveView module responsible for this page is BreakoutWeb.PageLive
, and we’re going to focus on it next.
Extracting logic
We’re going to extract the search functionality of PageLive
using a LiveComponent
. LiveComponent
s are useful for breaking down large LiveView
s to smaller, more managemeable pieces. Let’s create an empty file search_component.ex
in lib/breakout_web/live
, and copy the search template over:
defmodule BreakoutWeb.SearchComponent do
use BreakoutWeb, :live_component
def render(assigns) do
~L"""
<form phx-change="suggest" phx-submit="search">
<input type="text" name="q" value="<%= @query %>" placeholder="Live dependency search" list="results" autocomplete="off"/>
<datalist id="results">
<%= for {app, _vsn} <- @results do %>
<option value="<%= app %>"><%= app %></option>
<% end %>
</datalist>
<button type="submit" phx-disable-with="Searching...">Go to Hexdocs</button>
</form>
"""
end
end
Now we can go back to page_live.html.leex
and replace the <form>
with the component:
<section class="phx-hero">
<h1><%= gettext "Welcome to %{name}!", name: "Phoenix" %></h1>
<p>Peace of mind from prototype to production</p>
<%= live_component @socket, BreakoutWeb.SearchComponent, results: @results, query: @query %>
</section>
You can refresh the page, an everything should work just like before.
Notice that we’re passing two values to the component: results
and query
. This is what the template needs to be able to render itself.
The form event callbacks for handling the suggest
and search
events also remain in PageLive
. The parent LiveView
always receives the events from the child components. In some cases, it might be useful to leave some events to be handled by the parent.
However, often you want part of the event logic to stay in the component.
There are two types of components: stateless and stateful. SearchComponent
is currently stateless. It simply re-renders every time the parent does. Stateful components, on the other hand, can handle their own events. Next, we are going to convert SearchComponent
to a stateful component, by moving the remaining search logic from PageLive
.
Decoupling with stateful components
We are going to move all handle_event/3
callbacks from PageLive
to SearchComponent
, including the search/1
private function. SearchComponent
should look like this:
defmodule BreakoutWeb.SearchComponent do
use BreakoutWeb, :live_component
def mount(socket) do
{:ok, assign(socket, query: "", results: %{})}
end
def render(assigns) do
~L"""
<form phx-change="suggest" phx-submit="search">
<input type="text" name="q" value="<%= @query %>" placeholder="Live dependency search" list="results" autocomplete="off"/>
<datalist id="results">
<%= for {app, _vsn} <- @results do %>
<option value="<%= app %>"><%= app %></option>
<% end %>
</datalist>
<button type="submit" phx-disable-with="Searching...">Go to Hexdocs</button>
</form>
"""
end
def handle_event("suggest", %{"q" => query}, socket) do
# contents omitted
end
def handle_event("search", %{"q" => query}, socket) do
# contents omitted
end
defp search(query) do
# contents omitted
end
end
We also added mount/1
, which is useful when initialising data, so it is a good place to set the query
and results
assigns.
PageLive
should now look almost empty:
defmodule BreakoutWeb.PageLive do
use BreakoutWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Stateful components need a unique ID when they are rendered. Let’s add one for SearchComponent
in page_live.html.leex
:
<%= live_component @socket, BreakoutWeb.SearchComponent, id: "index-search" %>
You can now refresh the page after these changes, and seemingly, everything looks okay. But as soon as you try to perform a search, the page will crash. You will see an exception message in IEx
, which will look similar to this:
[error] GenServer #PID<0.2891.0> terminating
** (UndefinedFunctionError) function BreakoutWeb.PageLive.handle_event/3 is undefined or private
(breakout 0.1.0) BreakoutWeb.PageLive.handle_event("suggest", %{"_target" => ["q"], "q" => "p"}, #Phoenix.LiveView.Socket<assigns: %{flash: %{}, live_action: :index, query: "", results: %{}}, changed: %{}, endpoint: BreakoutWeb.Endpoint, id: "phx-FljmLSM58Ui6nRhC", parent_pid: nil, root_pid: #PID<0.2891.0>, router: BreakoutWeb.Router, view: BreakoutWeb.PageLive, ...>)
As we mentioned before, the parent LiveView
handles the events of the components. We have to explicitly specify that they are going to be handled by the component, using the phx-target
attribute, like so:
<form phx-target="<%= @myself %>" phx-change="suggest" phx-submit="search">
The @myself
assign is available for stateful components and contains a reference to the current component. It is possible to target one or more other components too. This is an example taken from the official docs:
<a href="#" phx-click="close" phx-target="#modal, #sidebar">
This is targeting components with the IDs modal
and sidebar
.
Anyway, back to our search page. If you refresh your browser, everything should be now working as expected. SearchComponent
is now truly reusable, and you can drop it in any LiveView
template.
When you need multiple instances of the same component on the page, everything will still work, just remember to give each one a unique :id
when rendering.
We decoupled SearchComponent
from its parent, but we may still want to exchange events between the parent and its child components. Next, we’re going to modify our search
logic, and make the component notify the parent when a result has been selected. The parent can then pick up the result and perform the redirect.
Exchanging data between LiveViews and Components
Since components and their parent LiveView all run on the same process, it is easy for them to communicate with each other. Let’s look into child > parent communication first, by moving some of the search logic back to PageLive
.
Update the handle_event/3
callback for search
:
def handle_event("search", %{"q" => query}, socket) do
case search(query) do
%{^query => vsn} ->
Kernel.send(self(), {:search_submitted, {query, vsn}})
{:noreply, socket}
_ ->
{:noreply,
socket
|> put_flash(:error, "No dependencies found matching \"#{query}\"")
|> assign(results: %{}, query: query)}
end
end
Instead of redirecting, we’re now using Kernel.send/2
to send a message to the current process. The payload could be anything; here we’re sending a tuple with the event name and some other data. The event name will be useful for pattern-matching on your callbacks.
Now we can update PageLive
by adding this function:
def handle_info({:search_submitted, {query, vsn}}, socket) do
{:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")}
end
Keep in mind that we have now introduced a requirement that all LiveView
s using SearchComponent
should now implement handle_info/2
for the :search_submitted
event. If the callback is not implemented, you’ll get an error.
If you want, you can avoid this by using Phoenix.PubSub
, which is already configured and available to us. Replace the Kernel.send/2
part:
case search(query) do
%{^query => vsn} ->
Phoenix.PubSub.broadcast!(Breakout.PubSub, "search", {:search_submitted, {query, vsn}})
{:noreply, socket}
And update PageLive
to subscribe to the search
topic:
def mount(_params, _session, socket) do
Phoenix.PubSub.subscribe(Breakout.PubSub, "search")
{:ok, socket}
end
The handle_info/3
callback will still work like before.
The subscribe/2
is optional, so if no one is subscribed to the "search"
topic, nothing will happen on broadcast.
Sending messages from LiveView to components
You can use Phoenix.PubSub
in a similar fashion to talk to components from the LiveView
module. You can also use send_update/3
to directly update the assigns
of a specific child LiveComponent
. Let’s remove the redirect logic in PageLive
temporarily, and update it with this:
def handle_info({:search_submitted, {query, vsn}}, socket) do
send_update(BreakoutWeb.SearchComponent, id: "index-search", query: "")
{:noreply, socket}
end
This will set the @query
assign to ""
, which will clear the input after you click the button. You can use send_update/3
to update existing assigns or create new ones.
Conclusion
I hope this post was useful and gave you some ideas how to extract logic from LiveView
s and build flexible LiveComponent
s. The official documentation contains many more examples, so I recommend everyone to check it out next.
Also I’d like to thank everyone who replied to my tweet and send me direct messages with suggestions for LiveView
posts. If you think I missed something in this blog post / saw something wrong, or have ideas for future posts—feel free to get in touch via email or Twitter.
This work is licensed under the Creative Commons BY-SA 4.0 International License