Code
Links
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.
A 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.
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 thesocket
and it can be used to initialize the data.update(assigns, socket)
, optional, called with theassigns
passed tolive_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 calling
mount/1,
update/2and
render/1`.
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, ProductComponent
s 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).
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 products
, trades
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.
preload(list_of_assigns)
, optional, useful to efficiently preload data.mount(socket)
, optional, to initialize the state.update(assigns, socket)
, optional, to update the socket state based on updatedassigns
.render(assigns)
, to render the component.
In the first render, all the four callbacks are invoked. Then, in all the other renders only preload/1
, update/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.
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.
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/1
, update/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!
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.
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.