Bindings, Click and Form events, Debounce, Live Flash messages

Course Index page

Code


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 event

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
Click button to add a product

When clicking this new Add Coinbase BTC-USD button, the exchange and pair values are sent along with the click event

click event with values

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.

`<form>` add product

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.

`:info` flash message
`:error` flash message

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.

Loading

After this experiment we can disable the latency simulator.

> liveSocket.disableLatencySim()
form event WebSocket message

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.

`phx-change` message

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.

Using `phx-debounce`

Other events

There are many other useful events, like Key eventsFocus/Blur eventsJS 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.