• Elixir
  • Phoenix
  • LiveView
  • Google Maps API
Publish date:

6 min read

Create an address autocomplete using Google API, Elixir and Phoenix Liveview Part 2

In the first post we saw how we can create a functionality that goes through each technology in the stack:

  • An Elixir service module address_autocomplete.ex
  • An JSON API endpoint /api/address/query
  • A LiveView that displays our UI autocomplete_live.ex
  • A JS Hook to add the interactivity needed address-autocomplete-hook.js

See the next diagram for illustration purposes:

Diagram of the architecture of the first implementation

You can find the full code changes here

The objective

The objective was to showcase how we can put together all of this available tools that come with each Phoenix application but in reality half of the steps weren't necessary at all!

Let me clarify that.

In this concrete example going for the path of adding a JavaScript library via a hook and talking to an API endpoint to get the predictions it was an overkill. It's not necessary but it was intentional for teaching purposes but I think it can be misleading because I don't want others to think that this is the only way to implement a feature like this one. With that being said let's explore how we can reduce the scope of tools needed for this functionality.

The refactor

The truth is that most of the times we can accomplish a lot of things with LiveView alone without the need to implement a JS library or even a JS framework.

The LiveView team provides us with some components we can use to implement our own autocomplete.

Let's take a look at the input component it comes with every Phoenix app, it gives us a good start to handle user input, not only we can specify the type of the input and placeholder properties as we can do with the raw HTML option but we can provide a field value that will be used internally to set the value and name of the input.

<.input
    type="text"
    phx-change="search"
    phx-debounce={300}
    field={@form[:search_text]}
    placeholder="Search address"
  />

It's a good practice to wrap each input component inside a form component.

<.form for={@form}>
  <.input
    type="text"
    phx-change="search"
    phx-debounce={300}
    field={@form[:search_text]}
    placeholder="Search address"
  />
</.form>

Next whe need to add the form assign to the socket, because we only want to keep track of the search text, the following will do it:

@impl true
def mount(_params, _session, socket) do
  {:ok, assign(
    socket, 
    result: nil, 
    form: to_form(%{"search_text" => ""})
  )}
end

If we take a closer look to the rendered input, we use a phoenix binding called phx-change, this binding will generate an event called search each time the user changes the input, NO MORE VANILLA JAVASCRIPT!

Let's handle that event:

@impl true
def handle_event("search", %{"search_text" => search_text}, socket) do
  options =
    search_text
    |> AddressAutocomplete.predictions()
    |> Enum.map(fn prediction -> {prediction.description, prediction.place_id} end)

  {:noreply, assign(socket, options: options)}
end

In the code above we are extracting the value of the search input and assigning it to the search_text variable so we can use it to get the predictions directly from the AddressAutocomplete module instead of hitting the API endpoint make the code simpler and more effective.

The last step is to render the options in our view but first we must add an empty value for the options assign on mount to avoid errors.

 @impl true
def mount(_params, _session, socket) do
  {:ok, assign(socket, result: nil, options: [], form: to_form(%{"search_text" => ""}))}
end

Update your view and add the following underneath the input field, this will render options if we have some and allow the user to click them.

<div class="mt-12">
  <.form for={@form}>
    <.input
      type="text"
      phx-change="search"
      phx-debounce={300}
      field={@form[:search_text]}
      placeholder="Search address"
    />
    <div :if={@options != []} class="mt-1 border border-gray-100 rounded-md shadow-sm">
      <div
        :for={{label, value} <- @options}
        class="text-gray-700 text-sm p-2 hover:bg-indigo-500 hover:text-white hover:cursor-pointer"
        phx-click="address-selected"
        phx-value-place_id={value}
      >
        <span><%= label %></span>
      </div>
    </div>
  </.form>
</div>

In case you noticed, I used the phx-click binding to generate an address-selected event when the user selects an address, if you recall from the previous post, that is the same event the autocomplete used to let the server know about the selected address, no need to refactor that function! In order to make it work we specified the name of the value using the binding phx-value-[name-of-your-value] providing the place_id as the value.

If you want the input options div to be closed after the user selects an option like in the original example, just reset the options assign to an empty list as follows:

@impl true
def handle_event("address-selected", %{"place_id" => place_id}, socket) do
  result = AddressAutocomplete.get_place(place_id)

  {:noreply, assign(socket, result: result, options: [])}
end

Here's the full LiveView:

defmodule PhoenixRecipesWeb.AutocompleteLive do
  use PhoenixRecipesWeb, :live_view

  alias PhoenixRecipes.AddressAutocomplete

  @impl true
  def render(assigns) do
    ~H"""
    <div class="h-full w-2xl">
      <h1 class="text-2xl font-bold text-gray-800">Autocomplete!</h1>

      <div
        id="autocomplete"
        class="autocomplete mt-4"
        phx-hook="AddressAutocompleteHook"
        phx-update="ignore"
      >
        <input
          class="w-full rounded-md border-0 py-2 pl-3 pr-12 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 sm:text-sm sm:leading-6"
          placeholder="Search address"
        />
        <ul
          class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
          role="listbox"
        >
        </ul>
      </div>

      <div class="mt-12">
        <.form for={@form}>
          <.input
            type="text"
            phx-change="search"
            phx-debounce={300}
            field={@form[:search_text]}
            placeholder="Search address"
          />
          <div :if={@options != []} class="mt-1 border border-gray-100 rounded-md shadow-sm">
            <div
              :for={{label, value} <- @options}
              class="text-gray-700 text-sm p-2 hover:bg-indigo-500 hover:text-white hover:cursor-pointer"
              phx-click="address-selected"
              phx-value-place_id={value}
            >
              <span><%= label %></span>
            </div>
          </div>
        </.form>
      </div>

      <div :if={@result} class="mt-12 border rounded-lg overflow-x-auto overflow-y-auto h-96">
        <pre>
          <%= inspect(@result, pretty: true)%>
        </pre>
      </div>
    </div>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, result: nil, options: [], form: to_form(%{"search_text" => ""}))}
  end

  @impl true
  def handle_event("address-selected", %{"place_id" => place_id}, socket) do
    result = AddressAutocomplete.get_place(place_id)

    {:noreply, assign(socket, result: result, options: [])}
  end

  @impl true
  def handle_event("search", %{"search_text" => search_text}, socket) do
    options =
      search_text
      |> AddressAutocomplete.predictions()
      |> Enum.map(fn prediction -> {prediction.description, prediction.place_id} end)

    {:noreply, assign(socket, options: options)}
  end
end

Final result:

Video of Autocomplete V2

You may have noticed that with this implementation if you delete the search text from the input an error is thrown by the server. This is because an empty string is sent to the server and Google is telling us that's not a valid search. Let's fix it, we can add a condition to only run the predictions function if there are more than 2 characters to making it backwards compatible with what we had previously.

@impl true
def handle_event("search", %{"search_text" => search_text}, socket) do
  options =
    if String.length(search_text) > 2 do
      search_text
      |> AddressAutocomplete.predictions()
      |> Enum.map(fn prediction -> {prediction.description, prediction.place_id} end)
    else
      []
    end

  {:noreply, assign(socket, options: options)}
end

Conclusion

By refactoring our original implementation, we’ve simplified the autocomplete feature by leveraging the full potential of Phoenix LiveView. This approach eliminates the need for additional JavaScript libraries or separate API endpoints, resulting in a cleaner, more efficient, and maintainable solution.

While the initial implementation was valuable for exploring multiple layers of a Phoenix application, this simpler version demonstrates how much can be accomplished using LiveView alone. Whether you’re building a simple autocomplete or tackling more complex UI challenges, LiveView continues to prove itself as a powerful tool for creating real-time, interactive applications with minimal complexity.

I hope this walkthrough helps you better understand how to harness the power of LiveView and to clarify some concepts. As always, feel free to share your thoughts or ask questions—I’d love to hear them. I hope you enjoy it!

Special thanks to the reddit users that engaged with my post and asked the questions!

Share this post