Using Select2 with Phoenix LiveView

IMPORTANT: It’s now possible to do LiveView JS interop with hooks, which make everything much simpler and smoother! I’ve published an updated version of the article: Phoenix LiveView JavaScript Hooks and Select2.

LiveView doesn’t support JavaScript interop at the moment (but it’s a planned feature). So, let’s see how we can come up with a workaround to make LiveView playing together with a JavaScript library like Select2.

Select2 working with LiveView
Select2 working with LiveView

Important: remember that LiveView is still in beta and that the workarounds we experiment in this article may not be ideal!

LiveView example with select

Let’s start by focusing on the select element and consider this LiveView example

LiveView with select
LiveView with select
defmodule DemoWeb.CountriesLive do
  use Phoenix.LiveView

  @countries Countries.all() |> Enum.sort_by(&(&1.name))

  def render(assigns) do
    ~L"""

    <%= unless @count do %>
      <button phx-click="show_count">Show Count</button>
    <% else %>
      <label><%= @count %></label>
    <% end %>

    <form phx-change="country_selected">

      <select name="country" id="select_countries">
        <option value="">None</option>
        <%= for c <- @countries do %>
          <option value="<%= c.alpha2 %>"
            <%= selected_attr(@country, c) %>
          >
            <%= c.name %>
          </option>
        <% end %>
      </select>

    </form>



    <%= if @country do %>
      <h3><%= @country.name %></h3>
      <!-- Google Maps iframe -->
      <iframe src="http://maps.google.com/maps?q=<%= @country.name %>&output=embed"
        width="360" height="270" frameborder="0" style="border:0"></iframe>

    <% end %>
    """
  end

  def mount(_session, socket) do
    socket =
      socket
      |> assign(:countries, @countries)
      |> assign(:country, nil)
      |> assign(:count, nil)

    {:ok, socket}
  end

  def handle_event("country_selected", %{"country" => ""}, socket),
    do: {:noreply, assign(socket,:country, nil)}


  def handle_event("country_selected", %{"country" => code}, socket) do
    country = Countries.filter_by(:alpha2, code) |> List.first()
    {:noreply, assign(socket,:country, country)}
  end

  def handle_event("show_count", _, socket),
    do: {:noreply, assign(socket, :count, Enum.count(@countries))}

  defp selected_attr(country, country),
    do: "selected=\"selected\""
  defp selected_attr(_, _), do: ""


end

I’ve used countries which is a handy Elixir library to easily get the list of all countries with other useful informations.

LiveView renders a select element with a list of countries. When we select a country, the front-end sends a country_selected event to the server.

Using the browser’s inspector, we can see the messages between front-end and the Phoenix server. When we select a country, the browser sends a JSON message similar to this one

["1", "2", "lv:phx-bq7Z8gNV", "event", 
  {type: "form", event: "country_selected", value: "country=IT"}
]

The server handles the event with the handle_event("country_selected", %{"country" => code}, socket) function, re-rendering the view and showing a Google maps iframe for the specific country.

The server sends back to the browser just the parts that need to be updated.

Message sent by the server
Message sent by the server

We can see the iframe HTML code as part of the changes. Another important change is made to the select options

<%= for c <- @countries do %>
  <option value="<%= c.alpha2 %>"
    <%= selected_attr(@country, c) %>
  >
    <%= c.name %>
  </option>
<% end %>

The selected_attr(selected_country, country) function adds a selected attribute just for the option element of the selected country.

When we select a new country, the server receives a country_selected event and sends back the updated view to the browser, which then patches the option elements.

LiveView adds selected attribute
LiveView adds selected attribute

Select2 and JavaScript events

We can make the country list a bit nicer, moving from the classic select element to the JavaScript Select2 library, which has a search box we can use to filter the countries.

Let’s start by adding jquery and select2 to the assets/package.json file of our Phoenix project, and download them running npm install in the assets directory.

{
  ...
  "dependencies": {
    "phoenix": "../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view",

    "jquery": "3.4.1",
    "select2": "4.0.7"
  },
  ...
}

Then we import jQuery and Select2 in the assets/js/app.js file, trying to initialize Select2 for the select element with id select_countries.

// assets/js/app.js

// liveSocket code...

// OUR CODE
import jQuery from "jquery"
import select2 from "select2"

window.jQuery = jQuery

jQuery(document).ready(function($){
	
	function initSelect2() {
		$("#select_num").select2()
	}
	initSelect2()
})

When the document is loaded we call the initSelect2 function, which should initialize select2. But… it doesn’t seem to work.

While refreshing the page, we see the new dropdown just for a fraction of a second – then LiveView re-renders the normal select.

It works only when we try to initialize it manually using the browser’s console

//browser console
jQuery("#select_countries").select2()

In the LiveView code above, I’ve also added a Show Count button – when pressed, LiveView renders a label with the number of countries and the select2 dropdown is replaced with a normal select.

LiveView patches the DOM, replacing select2 with select
LiveView patches the DOM, replacing select2 with select

So… What’s happening here?

Every time an event is processed on the server, the view is re-rendered and all the changes are sent back to the browser. Then, the LiveView JavaScript library on the front-end, with the help of morphdom, patches the DOM to make the page equal to the one rendered on the server.

When we run jQuery("#select_countries").select2(), the Select2 library changes the HTML on the page, adding the Select2 dropdown. Then, clicking on the Show Count button, the HTML is changed to match the one rendered on the server.

Hopefully we can use the phx:update JavaScript event, which is dispatched every time LiveView updates the DOM – we can initialize select2 each time this event is dispatched.

// assets/js/app.js

jQuery(document).ready(function($){

  function initSelect2() {
    $("#select_countries").select2()
  }
  
  $(document).on("phx:update",initSelect2);
  
})

After refreshing the page, we see the Select2 dropdown; clicking the Show Count button doesn’t replace it with the normal select element. Great, it works 👍

We have to be careful though:phx:update is a generic event that is dispatched for any kind of LiveView update, not necessarily related to the select tag.

It’s better then to initialize Select2 only when it really needs to

jQuery(document).ready(function($){

  function initSelect2(selector) {
    if (!isInitialized()) {
      $(selector).select2()
    }
    
    function isInitialized() {
      return $(selector).hasClass("select2-hidden-accessible")
    }
  }

  $(document).on("phx:update", (e) => {
    initSelect2("#select_countries")
  });
})

To understand when Select2 is initialized, we use the fact that the library adds a select2-hidden-accessible class to the original select tag. The isInitialized() function returns false when it doesn’t find this class, meaning that Select2 has to be re-initialized.

Send events to the server

Unfortunately the new dropdown still doesn’t work properly – when we select a country, nothing happens. We need to find a way to send a country_selected event to the server, when a country is chosen.

phoenix_live_view.js is the LiveView JavaScript front-end library, it’s really clear and well documented. We can easily find a pushWithReply(event, payload) function, which is a method of the View class.

At the beginning of our assets/js/app.js file, we initialize LiveSocket

// assets/js/app.js
import {LiveSocket, debug} from "phoenix_live_view"
let logger =  function(kind, msg, data) {
  // console.log(`${kind}: ${msg}`, data)
}

let liveSocket = new LiveSocket("/live", {logger: logger})
liveSocket.connect()
window.liveSocket = liveSocket

With window.liveSocket = liveSocket we can access to it from the browser’s console.

We see that liveSocket has a views object – We can access to a view instance using the view id. We find this id on the div tag that wraps the LiveView’s HTML.

div with view id
div with view id
Browser console - liveSocket.views
Browser console – liveSocket.views

Let’s now try to send an event to the server. We use jQuery to programmatically get the view id. The div tag is the parent of the only form we have in the page

> var id = jQuery("form").parent().attr("id")
> id
"phx-kfRcGIP7"

With this id we can now have access to the view instance and use call the pushWithReply(event, payload) method. Remember the message the browser sent to the server when selecting a country?

[ ..., "event", { 
  type: "form", 
  event: "country_selected", 
  value: "country=IT"
}]

This is all we need to pass to pushWithReply(). The "event" string is the first argument, and the second argument

{ 
  type: "form", 
  event: "country_selected", 
  value: "country=IT"
}

is the payload.

liveSocket.views[id]
.pushWithReply("event", {
  type: "form", 
  event: "country_selected", 
  value: "country=IT"
})
pushWithReply() sends an event to the server
pushWithReply() sends an event to the server

It works! Now we just need to automatize it, calling this function when a Select2 option is selected.

//initSelect2 function

$(selector)
.select2()
.on("select2:select", (e)=>{
  let viewId = $("form").parent().attr("id"),
    countryCode = e.params.data.id;
  
  liveSocket.views[viewId]
  .pushWithReply("event", {
    type: "form",
    event: "country_selected",
    value: `country=${countryCode}`
  })
  
})

We listen to the select2:select event and we get the country code from e.params.data.id. We then push the event to the server passing the country=${countryCode} value.

Select2 working with LiveView

Considerations

When I find myself fighting with a tool to get the result I want, I tend to ask myself: is it the right tool for this job?

In a way, it’s very exciting to do experiments while looking for workarounds like these – it helps to better understand a tool and find temporary fixes.

But if we really need to use a JavaScript library (especially if it’s much more complex than Select2), it’s worth considering to just use Phoenix Channels, while waiting for JS interop in LiveView.