Extracting LiveView Logic Into LiveComponents

post image
Photo by @xavi_cabrera on Unsplash

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 LiveComponents. 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. LiveComponents are useful for breaking down large LiveViews 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 LiveViews 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 LiveViews and build flexible LiveComponents. 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