Code
Links
Until this moment we only dealt with a passive view, which receives updates from the server, but that doesn’t provide any user interaction.
In this lesson we are going to see how to handle user interactions in our view, going through many examples on how to use bindings, show live flash messages, button clicks and form events.
A simple Clear button
Let’s start with a super simple example, a clear button that clears the trades table. It’s not really useful but it gives us an example simple enough to start understanding how user interaction works in LiveView.
Let’s add a <button phx-click="clear">Clear</button>
at the top of our template. What distinguishes this tag from a normal one is the phx-click
binding. When this button is clicked it sends a "clear"
event to the server via the WebSocket. In our CryptoDashboardLive
LiveView we handle the event implementing the handle_event("clear", _event, socket)
callback, where the first argument is the event name.
defmodule PoeticoinsWeb.CryptoDashboardLive do
...
def render(assigns) do
~L"""
<button phx-click="clear">Clear</button>
...
"""
end
def handle_event("clear", _params, socket) do
{:noreply, assign(socket, :trades, %{})}
end
end
When we click the Clear button, the browser sends a click event message, which is handled by the handle_event/3
on the server. In the handle_event/3
callback we clear the :trades
assigning a new empty map to the socket
. LiveView re-renders the view and pushes the changes down to the browser.
Click and Values
Using the phx-value-*
attribute, we can send values along with a click event. Let’s see a different example where our :trades
map and :products
list are empty, and we want to add a product in the table just clicking a button. For simplicity let’s think to have just one button to add the Coinbase BTC-USD product, which sends a "add-product"
event, with "exchange"
and "pair"
values.
<button phx-click="add-product"
phx-value-exchange="coinbase"
phx-value-pair="BTC-USD"
>
Coinbase BTC-USD
</button>
Here we use the phx-value-exchange
and phx-value-pair
attributes, which is a way to send the %{"exchange" => "coinbase", "pair" => "BTC-USD"}
parameters map to the handle_event/3
callback.
defmodule PoeticoinsWeb.CryptoDashboardLive do
def mount(_params, _session, socket) do
socket = assign(socket, trades: %{}, products: [])
{:ok, socket}
end
def render(assigns) do
~L"""
<button phx-click="add-product"
phx-value-exchange="coinbase"
phx-value-pair="BTC-USD"
>Add Coinbase BTC-USD</button>
...
"""
end
def handle_event("add-product", %{"exchange" => exchange, "pair" => pair}=_params, socket) do
product = Product.new(exchange, pair)
socket = add_product(socket, product)
{:noreply, socket}
end
end
For simplicity we don’t validate the "exchange"
and "pair"
values that come from the browser, but we should check that the resulting product is one of the allowed ones.
defp add_product(socket, product) do
Poeticoins.subscribe_to_trades(product)
socket
|> update(:products, fn products -> products ++ [product] end)
|> update(:trades, fn trades ->
trade = Poeticoins.get_last_trade(product)
Map.put(trades, product, trade)
end)
end
To add the product, I prefer to write the logic in a separate add_product(socket, product)
function, which:
- adds a product to the
:products
list - subscribes to the PubSub topic to get new trades
- populates the
:trades
map with the most recent trade for that product
When clicking this new Add Coinbase BTC-USD button, the exchange and pair values are sent along with the click event
And the server replies with a phx_reply
message with the view updates in the payload, and since the LiveView process is now subscribed to get new trades, it starts sending down the diff
messages for each received trade.
There is a problem though, if we click the button multiple times, the product is added multiple times, and even worse it means that the LiveView process subscribes multiple times to the same topic, which means receiving multiple times the same trade message.
We can avoid it by simply write a maybe_add_product(socket, product)
function which checks that the product isn’t already in the list, before calling add_product/2
def handle_event("add-product", %{"exchange" => exchange, "pair" => pair}=_params, socket) do
product = Product.new(exchange, pair)
socket = maybe_add_product(socket, product)
{:noreply, socket}
end
defp maybe_add_product(socket, product) do
if product not in socket.assigns.products do
add_product(socket, product)
else
socket
end
end
Form events
With the previous examples we got an idea on how user interaction is handled by LiveView, but a button for each product isn’t really practical or nice to see. It’s far better to have a <form>
, with a <select>
dropdown which lists all the available products. When we submit this form, a form event is pushed to the server, with the form data, and handled by the handle_event/3
callback.
defmodule PoeticoinsWeb.CryptoDashboardLive do
def render(assigns) do
~L"""
<form action="#" phx-submit="add-product">
<select name="product_id">
<option selected disabled>Add a Crypto Product</option>
<%= for product <- Poeticoins.available_products() do %>
<option value="<%= to_string(product) %>">
<%= product.exchange_name %> - <%= product.currency_pair %>
</option>
<% end %>
</select>
<button type="submit">Add product</button>
</form>
<table>...</table>
"""
end
def handle_event("add-product", %{"product_id" => product_id}, socket) do
[exchange_name, currency_pair] = String.split(product_id, ":")
product = Product.new(exchange_name, currency_pair)
socket = maybe_add_product(socket, product)
{:noreply, socket}
end
...
end
At the top of the template we write the form to insert a new product in the table. In the form we use the phx-submit
binding to send an add-product
form event when the form is submitted.
In this project we don’t use Ecto, but if you use it along with changesets, you can take advantage of the form_for
function like in a regular view.
In the <select>
tag, each option has a unique value
, a unique product id string that represents the product. The Product
module implements the String.Chars
protocol, so with to_string/1
a product like %Product{exchange_name: "coinbase", currency_pair: "BTC-USD"}
is converted to the “coinbase:BTC-USD” string.
The handle_event/3
clause, where we handle the add-product
event, it’s pretty similar to the previous one. In this case, instead of passing the exchange
and pair
, we pass a product_id
parameter. We need to split the string to get the exchange_name
and currency_pair
to then get the Product.t()
we want to add. Again, for simplicity we didn’t add any validation on the data coming from the user, but we should check if the product_id
is a valid one.
Let’s try it on the browser.
Live Flash Messages
If we try to add the same product again, as expected, it does nothing without providing any feedback. Let’s see how to show flash messages.
defp maybe_add_product(socket, product) do
if product not in socket.assigns.products do
socket
|> add_product(product)
|> put_flash(
:info,
"#{product.exchange_name} - #{product.currency_pair} added successfully"
)
else
socket
|> put_flash(:error, "The product was already added")
end
end
In maybe_add_product/2
we simply call the put_flash/2
to show, in case of success, an :info
message, and an :error
message when the product is already added.
phx-disable-with
By adding the phx-disable-with="Loading..."
attribute to the submit button, we tell LiveView to disable the button and show a "Loading..."
text, while the server processes the event.
<button type="submit" phx-disable-with="Loading...">
Add product
</button>
Since we are running this app locally, it’s really fast and it’s hard to see the loading label. However, we can enable the LiveView latency simulator on the browser, opening the inspector and calling on the console
> liveSocket.enableLatencySim(1000);
which enables a simulation of 1 second of latency.
After this experiment we can disable the latency simulator.
> liveSocket.disableLatencySim()
Looking at the exchanged WebSocket messages, we see that this time the type
of event is form
and that the form data are passed in the value
key. LiveView then re-renders the view and pushes the changes down to the browser with a phx_reply
message.
Don’t load data inside a LiveView template
Inside the form, with a for
comprehension, we list the <option>
tags for the products that are returned by the Poeticoins.available_products()
function. We need to be careful when calling functions to get data inside a LiveView template, because LiveView doesn’t know anything about our function and it’s only able to track the changes through the socket.assigns
. In general, a good practice is to never load data inside a template. Instead we should bring data via the assigns
so that LiveView can easily track the changes. In this case, Poeticoins.available_products()
is a pure function and returns always the same list of products; the rendered <option>
tags are fixed and we don’t need to re-render them, so we can use this function inside the template.
Form phx-change
Let’s write a another <form>
, this time with a phx-change="filter-products"
binding.
def mount(_params, _session, socket) do
socket = assign(socket, trades: %{}, products: [], filter_products: & &1)
{:ok, socket}
end
def render(assigns) do
~L"""
...
<form action="#" phx-change="filter-products">
<input type="text" name="search">
</form>
...
<%= for product <- @products, @filter_products.(product), trade = @trades[product] do%>
...
<% end %>
"""
end
With the phx-change
binding, the form
will be submitted with a filter-products
event name, every time the <input>
value changes. We use the filter-products
event to filter the products in the table, based on what the user writes in the search
<input>
field.
def handle_event("filter-products", %{"search" => search}, socket) do
socket =
assign(socket, :filter_products, fn product ->
String.downcase(product.exchange_name) =~ String.downcase(search) or
String.downcase(product.currency_pair) =~ String.downcase(search)
end)
{:noreply, socket}
end
The filter-products
event is handled by the handle_event/3
callback, which assigns a filtering function used to filter the products in the for comprehension.
As soon as we write a letter in the input field, a filter-products
event is pushed to the server. If we write three letters like btc, this will fire 3 events. In this cases it makes sense to rate-limit the firing of this event, by adding the phx-debounce
binding to the <input>
tag.
<form action="#" phx-change="filter-products">
<input phx-debounce="300" type="text" name="search">
</form>
By adding phx-debounce="300"
LiveView will wait 300ms before sending the event.
Other events
There are many other useful events, like Key events, Focus/Blur events, JS Hooks etc., in the advanced section of this course we’ll see in detail how JS Hooks, but we can’t see and use all of them in this application. I strongly advise to start from what learnt in this lesson and take a look at the official LiveView documentation to explore the other type of events.