Phoenix LiveView LiveComponents

Course Index page

Code


We’ve just started to build our CryptoDashboardLive view, and we already have the feeling that the we are piling up a lot of code into the single CryptoDashboardLive module. It’s not just about the template in the render/1 function, we could easily move it into a .leex file. The risk is to find us with a single massive live view module which handles every aspect of our page, making che code hard to read and maintain. This page already has different parts with different responsibilities, for example: product cards where each one shows its product price, a toolbar with a dropdown to add products to the dashboard…

Components are a mechanism to compartmentalize state, markup, and events in LiveView. 

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html

We can move a part of the LiveView’s logic (and related template) into separate components.

LiveComponent lives in the same LiveView process. A LiveComponent can be stateless (which pretty much renders a template), or stateful (which keeps its own state and handles its own events).

We start by moving the product card, the part inside the for comprehension, into a LiveComponent.

`ProductComponent`

Stateless LiveComponent

Let’s start with the simplest one, a stateless ProductComponent.

First, we create a new lib/poeticoins_web/live/product_component.ex module where we define the PoeticoinsWeb.ProductComponent, which implements the Phoenix.LiveComponent behaviour.

There are three callbacks:

  • mount(socket), which is optional, it’s called only with the socket and it can be used to initialize the data.
  • update(assigns, socket), optional, called with the assigns passed to live_component
  • render(assigns), which returns the component’s LiveEEx template. If the template is too big, we can move it to a .html.leex file.

We start by implementing just the render(assigns) callback. We move the product card div (the one in the for comprehension in CryptoDashboardLive) into the ProductComponent.render(assigns) function.

Then, in CryptoDashboardLive.render(assigns), we render the component calling live_component/4, passing the :product and :trade assigns.

#lib/poeticoins_web/live/crypto_dashboard_live.ex
defmodule PoeticoinsWeb.CryptoDashboardLive do
  ...

  def render(assigns) do
  ~L"""
  ...
  <div class="product-components">
  <%= for product <- @products, trade = @trades[product] do%>
    <%= live_component @socket, PoeticoinsWeb.ProductComponent, 
          product: product, trade: trade %>
  <% end %>
  </div>
  ...
  """
  end
  ...
end

As expected, we get a working dashboard.

Now, what about mount/1 and update/2 callbacks?

mount/1 is an optional callback, and it’s called only with the socket. It can be used to initialize the assigns. In our component we don’t need to initialize any data, but we implement it so we can log when is called.

#lib/poeticoins_web/live/product_component.ex
def mount(socket) do
  IO.inspect(self(), label: "MOUNT")
  {:ok, socket}
end

update/2 is an optional callback. The first argument is assigns which is a map with the assigns passed to the component when calling live_component/4, in our case :product and :trade. In this callback we can process these assigns and add them to the socket. When we don’t implement this callback, the default behaviour is to merge the assigns passed when calling live_component/4 to the component’s socket assigns. Again, at the moment we don’t need to write this callback, but we implement it to log when it’s invoked and log the process id. We merge all the passed assigns to the socket.

#lib/poeticoins_web/live/product_component.ex
def update(assigns, socket) do
  IO.inspect(self(), label: "UPDATE")
  socket = assign(socket, assigns)
  {:ok, socket}
end

To have a complete understanding of the stateless component’s life-cycle, we also log to ProductComponent.render/1

#lib/poeticoins_web/live/product_component.ex
def render(assigns) do
  IO.inspect(self(), label: "RENDER")
  ~L"""
  ...
  """
end

and to CryptoDashboardLive.mount/3

#lib/poeticoins_web/live/crypto_dashboard_live.ex
defmodule PoeticoinsWeb.CryptoDashboardLive do
  ...

  def mount(_params, _session, socket) do
    IO.inspect(self(), label: "LIVEVIEW MOUNT")
    ...
  end
end

When we connect to the dashboard with a browser we immediately see the LIVEVIEW MOUNT log which prints the LiveView process ID #PID<0.475.0>. Then, when we add a product, LiveView renders the component, by calling mount/1 update/2 and render/1. Since the LiveView process subscribes to a PubSub topic to get new trades for that product, for each new trade the component is re-rendered callingmount/1,update/2andrender/1`.

Stateless component life-cycle

We notice that the PID is always the same, because components (both stateless and stateful) live inside the live view’s process.

Every time the :trades map in CryptoDashboardLive view is updated, ProductComponents in the for are re-rendered.

Let’s see what happens at each render when we have many products. After adding three different products to the dashboard, taking a look at the exchanged WebSocket messages we immediately see that every time the LiveView process receives a new trade, it updates the :trades map and all the dynamic values inside the for loop are sent to the browser (for every trade).

`for` comprehension dynamic values

This happens because LiveView doesn’t perform change tracking inside comprehensions. But LiveView makes an optimization, it understands what is static and what is dynamic inside a comprehension, so it only sends the dynamic values to the client. Still, if we have many products it could be an issue to receive all the products’ dynamic values inside the for comprehension every time something changes, especially if each product receives many trades per second. A way to handle this problem is to use stateful components.

Stateful LiveComponent

To make the ProductComponent stateful, we just need to pass a unique :id when calling live_component. For example, in an app where we use Ecto with a database, the unique :id could be the id of the item we want to render with the component, like a user, a chat group, or a message.

In our case, we can make the product components stateful setting the component :id to the Product.t() struct, which is unique since we render only one ProductComponent for each Product.t().

We can decide where we want to keep the data, like productstrades etc. We can keep everything in LiveView for example, or letting the component get and manage its own data. We go with the latter.

<!-- lib/poeticoins_web/live/crypto_dashboard_live.ex -->

<div class="product-components">
<%= for product <- @products do%>
  <%= live_component @socket, PoeticoinsWeb.ProductComponent, id: product %>
<% end %>

This time, with the comprehension we enumerate just the @products list and render a ProductComponent only setting the :id to product. We don’t pass any trade.

Let’s move to the ProductComponent and see how to get the trades.

The life-cycle of a stateful component is a bit different from the stateless one.

  1. preload(list_of_assigns)optional, useful to efficiently preload data.
  2. mount(socket)optional, to initialize the state.
  3. update(assigns, socket)optional, to update the socket state based on updated assigns.
  4. render(assigns), to render the component.

In the first render, all the four callbacks are invoked. Then, in all the other renders only preload/1update/2 and render/1 are called.

We are not going to use preload/1, but it’s pretty useful when we need to load data from the database in an efficient way. Let’s consider the case where we have many components and each component needs to get some data querying the database. Sometime, if we have many components, making one query per component could be inefficient. A better way is to load the data for all the components with just a single query. In preload(list_of_assigns) we can do exactly that. We receive a list_of_assigns, where each element is a component’s assigns map, so we can make a single database query returning a *list of updated assigns.

For example, we can implement preload/1 just to log the list_of_assigns

#lib/poeticoins_web/live/product_component.ex
def preload(list_of_assigns) do
  list_of_assigns
  |> IO.inspect(label: "PRELOAD")
end

and in CryptoDashboardLive.mount/3 set :products to `Poeticoins.available_products(), to have a product component for each supported product in the app.

#lib/poeticoins_web/live/crypto_dashboard_live.ex
defmodule PoeticoinsWeb.CryptoDashboardLive do
  ...

  def mount(_params, _session, socket) do
    socket = assign(socket, 
      trades: %{}, 
      products: Poeticoins.available_products()
    )
    ...
  end
end

When connecting with the browser, we see that preload/1 is invoked with a list of all the assigns, which are maps where the :id is a Product.t struct.

`preload/1`

But the LiveView process crashes because the product component tries to use @product and @trade in the template returned by render/1.

When ProductComponent is first rendered, it needs to get the most recent trade from the Historical. In the update(assigns, socket) callback we get the product from the assigns map using the :id key. We assign :product, then we get and assign the most recent :trade.

#lib/poeticoins_web/live/product_component.ex
def update(assigns, socket) do
    product = assigns.id

    socket =
      assign(socket,
        product: product,
        trade: Poeticoins.get_last_trade(product)
      )

    {:ok, socket}
end

In this way, every time a ProductComponent is rendered, by calling live_component(@socket, PoeticoinsWeb.ProductComponent, id: product), the update/2 callback gets the most recent trade from the historical and assigns it to the socket. Now we can use both @product and @trade in the template.

But what happens when the trade returned by the historical is nil? We can handle this case by implementing a second ProductComponent.render/1 clause.

The first clause pattern matches the assigns making sure it has a non-nil trade in the map. The second handles all the other cases.

#lib/poeticoins_web/live/product_component.ex

def render(%{trade: trade} = assigns) 
    when not is_nil(trade) 
do
  ...
end

def render(assigns) do
  ~L"""
  <div class="product-component">
    <div class="currency-container">
      <img class="icon" src="<%= crypto_icon(@socket, @product) %>" />
      <div class="crypto-name">
        <%= crypto_name(@product) %>
      </div>
    </div>

    <div class="price-container">
      <ul class="fiat-symbols">
        <%= for fiat <- fiat_symbols() do %>
          <li class="
          <%= if fiat_symbol(@product) == fiat, do: "active" %>
            "><%= fiat %></li>
        <% end %>
    </ul>

      <div class="price">
        ... <%= fiat_character(@product) %>
      </div>
    </div>

    <div class="exchange-name">
      <%= @product.exchange_name %>
    </div>

    <div class="trade-time">

    </div>
  </div>
  """
end

Let’s restart the server and see the result by adding a product with low trading volume, like Bitstamp ltceur.

`nil` trade

Now, if we add some products to our dashboard we immediately see that they are not updated. The first render works correctly, but the components aren’t updated. When a product is added to the :products list in CryptoDashboardLive, the add_product function calls Poeticoins.subscribe_to_trades/1 to subscribe to the PubSub topic to get new trade messages. The component lives in the same live view process, it can handle its own events, but it can’t receive messages from PubSub. Only the CryptoDashboardLive module, which implements the Phoenix.LiveView behaviour, can receive messages and handle them with the handle_info/2 callback. But there is a way to asynchronously send updated data from the view to the component, using the send_update/3 function.

#lib/poeticoins_web/live/crypto_dashboard_live.ex
defmodule PoeticoinsWeb.CryptoDashboardLive do
  ...

  def handle_info({:new_trade, trade}, socket) do
    send_update(
      PoeticoinsWeb.ProductComponent, 
      id: trade.product, 
      trade: trade
    )
    {:noreply, socket}
  end


end

When we send an update to the component, the component’s preload/1update/2 and render/1 callbacks are invoked. At the moment, every time the ProductComponent.update/2 callback is invoked, it loads the trade from the historical. We need to handle the case where it receives the trade in the assigns from the view via send_update/3.

#lib/poeticoins_web/live/product_component.ex
def update(%{trade: trade} = _assigns, socket) when not is_nil(trade) do
  socket = assign(socket, :trade, trade)
  {:ok, socket}
end

def update(assigns, socket) do
  product = assigns.id

  socket =
    assign(socket,
      product: product,
      trade: Poeticoins.get_last_trade(product)
    )

  {:ok, socket}
end

In this way, update gets the trade from the historical only in the first render. Only the component matching the :id is updated!

`send_update/3`

Fantastic, the product component is now updated correctly.

The big difference here is that the CryptoDashboardLive.render/1 callback is called only when the @products list is updated.

When a new trade is received, the view updates directly the specific component sending only the updated data of that component to the browser. To appreciate how efficient now the update is, let’s add few other products to the dashboard and take a look at the exchanged messages.

message with only the updated component's data

We see that only the updated component’s data is sent over the wire!

Let’s see now how events are handled in a stateful component. We add an X button in ProductComponent template, which uses the phx-click binding sending the "remove-product" event. By default, this event is sent to the CryptoDashboardLive view. If we want to send the event to the component, we just need to add the phx-target="<%= @myself %>". In this way the event is sent to @myself, the component. But in this case we need to remove the a product from the @products list in the view, so it’s useful to handle this event in CryptoDashboardLive.

#lib/poeticoins_web/live/product_component.ex
defmodule PoeticoinsWeb.ProductComponent do

  def render(%{trade: trade} = assigns) when not is_nil(trade) do
    ~L"""
    <div class="product-component">
      <button class="remove" 
              phx-click="remove-product" 
              phx-value-product-id="<%= to_string(@product) %>"
      >X</button>
      ...
    """
  end

end
#lib/poeticoins_web/live/crypto_dashboard_live.ex
defmodule PoeticoinsWeb.CryptoDashboardLive do
  ...

  def handle_event("remove-product", %{"product-id" => product_id} = _params, socket) do
    product = product_from_string(product_id)
    socket = update(socket, :products, &List.delete(&1, product))
    {:noreply, socket}
  end

  defp product_from_string(product_id) do
    [exchange_name, currency_pair] = String.split(product_id, ":")
    Product.new(exchange_name, currency_pair)
  end

end

When clicking on X, a "remove-product" event, along with the "product-id" string value, is sent to the CryptoDashboardLive view. The view handles the event with the handle_event/3 callback, removing the product from the :products list. Then LiveView re-renders the view and it tells the browser to remove the component.

Remove product