Handling search form nicely with Phoenix LiveView

Mar 28, 2021 · 1356 words · 7 minutes read

This post assumes you know what is Elixir, Phoenix, and LiveView.

If you know LiveView well, you probably won’t learn much from this post, sorry!


Recently, I wanted to add a simple search form to one of my projects. It’s a pretty standard app using Elixir, Phoenix with LiveView and PostgreSQL. Deployed on Heroku, CI on GitHub. Nothing fancy, boring tools.

In this project, I’ve a list of pending code reviews and I wanted to quickly search through them.

Given this is for a small side project, my requirements were:

  • Not too complex, easy to test and implement
  • Fast
  • Easy to maintain and change
  • Must load results without any page reload (UX)
  • Must maintain its state in URL so that going directly to bookmarked URL with search query would work without any problem
  • 0 JavaScript, only LiveView
  • No new dependencies

Nothing too advanced right? So let’s build one in fresh new Phoenix app.

Initial setup

We will start with loading the list of pending code reviews.

defmodule FoozWeb.CodeReviewsLive.Index do
  use FoozWeb, :live_view
  alias Fooz.CodeReviews

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket |> load_code_reviews()}
  end

  @impl true
  def render(assigns) do
    ~L"""
      <h2>Code reviews</h2>
    """
  end

  def load_code_reviews(socket) do
    socket
    |> assign(:code_reviews, CodeReviews.get_all())
  end
end

I will not go into details in this post what’s exactly inside Fooz.CodeReviews, but let’s just say it returns an array of pending code reviews from whatever, be it GitHub or GitLab or something else.

Now that the data is loaded and available in assigns, we can display the list:

defmodule FoozWeb.CodeReviewsLive.Index do
  ...

  @impl true
  def render(assigns) do
    ~L"""
      <h2>Code reviews</h2>
      <%= render_code_reviews(assigns) %>
    """
  end

  def render_code_reviews(assigns) do
    ~L"""
    <ol>
      <%= for %{title: title} <- @code_reviews do %>
        <li><%= title %></li>
      <% end %>
    </ol>
    """
  end
end

In my case it looks something like this:

Code reviews list

The search form

We can now proceed to add a search form. It needs just one input field - query.

defmodule FoozWeb.CodeReviewsLive.Index do
  ...

  @impl true
  def render(assigns) do
    ~L"""
      <h2>Code reviews</h2>
      <%= render_search_form(assigns) %> <%# added %>
      <%= render_code_reviews(assigns) %>
    """
  end

  def render_search_form(assigns) do
    ~L"""
    <%= form_for :search, "#", fn f -> %>
      <%= label f, :search %>
      <%= text_input f, :query %>
      <%= submit "Search" %>
    <% end %>
    """
  end
end
Code reviews list with form

Right now, if you try to submit the form, it will throw an error:

no route found for POST / ...

That’s alright! Everything is fine! In standard non Phoenix.LiveView view, you would need to add an endpoint for POST and controller action to handle the form.

In LiveView, we’re going to add phx-submit event.

   def render_search_form(assigns) do
     ~L"""
-    <%= form_for :search, "#", fn f -> %>
+    <%= form_for :search, "#", [phx_submit: "search"], fn f -> %>
       <%= label f, :search %>
       <%= text_input f, :query %>
       <%= submit "Search" %>

And a handler for it:

  @impl true
  def handle_event("search", _, socket) do
    {:noreply, socket}
  end

Let’s pause here for a moment and think what should happen next.

One of out requirements is:

Must maintain its state in URL so that going directly to bookmarked URL with search query would work without any problem

This guides us in the design and what should happen next - somehow update the query param in URL from LiveView!

We can do that via push_patch/2.

From documentation:

When navigating to the current LiveView, handle_params/3 is immediately invoked to handle the change of params and URL state. Then the new state is pushed to the client, without reloading the whole page while also maintaining the current scroll position.

Fits like a glove and immedietely tells us what we need to do next, add handle_params/3.

Back to our event handler, we need to change it to:

  @impl true
  def handle_event("search", %{"search" => %{"query" => query}}, socket) do
    {:noreply, push_patch(socket, to: Routes.code_reviews_path(socket, :index, query: query))}
  end

If we forget handle_params/3 and try to submit the form, we’ll get an error something like this::

[error] GenServer #PID<0.1638.0> terminating
** (UndefinedFunctionError) function FoozWeb.CodeReviewsLive.Index.handle_params/3 is undefined or private
    (fooz 0.1.0) FoozWeb.CodeReviewsLive.Index.handle_params(%{"query" => ""}, "http://localhost:4000/code_reviews?query=query",

Our initial handle_params/3 doesn’t need to do anything yet.

  @impl true
  def handle_params(_params, _url, socket) do
    {:noreply, socket}
  end

How query param should be handled? At the moment, if user submits the form, they will get redirected and query param will be visible in the URL under name query. If they edit the input, nothing will change on a page. If they navigate back/forward in the browser, handle_params/3 will be called too but nothing will change on the page.

This means we should handle the query at least in the handle_params/3 and pass it to modified load_code_reviews/2:

  @impl true
  def handle_params(params, _url, socket) do
    query = params |> Map.get("query")

    {:noreply, socket |> load_code_reviews(query) }
  end

  def load_code_reviews(socket, query \\ nil) do
    socket
    |> assign(:code_reviews, CodeReviews.get_all(query))
  end
Working search gif

There are two last things we need to fix before we can call it Minimum Usable Product(MUP).

First one is immedietly visible - after submit, text input loses its state and user cannot easily modify their previous input. To fix that, we need to be able to pass input’s value from query param.

  def load_code_reviews(socket, query \\ nil) do
    socket
+   |> assign(:query, query)
    |> assign(:code_reviews, CodeReviews.get_all(query))
  end

and we need to pass the assign into the text input:


  def render_search_form(assigns) do
    ~L"""
    <%= form_for :search, "#", [phx_submit: "search"], fn f -> %>
      <%= label f, :search %>
-     <%= text_input f, :query %>
+     <%= text_input f, :query, value: @query %>
      <%= submit "Search" %>
    <% end %>
    """
  end

The second thing is probably not immediately obvious but let’s take a look what exactly is happening when user navigates to URL with query by adding simple IO.inspect/1 to load_code_reviews/2:

  def load_code_reviews(socket, query \\ nil) do
    IO.inspect({ "Search query", query })

    socket
    |> assign(:query, query)
    |> assign(:code_reviews, CodeReviews.get_all(query))
  end
[info] GET /code_reviews
[debug] Processing with Phoenix.LiveView.Plug.index/2
  Parameters: %{"query" => "search"}
  Pipelines: [:browser]
# Called 1st time
{"Search query", nil}
# Called 2nd time
{"Search query", "search"}
[info] Sent 200 in 6ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 52µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "dRo-N3pBUHtdPFprTlQDLRAsKwEuAWoJGPXv9wcL1I546ggirNlqycR_", "_mounts" => "0", "vsn" => "2.0.0"}
[info] CONNECTED TO Phoenix.LiveView.Socket in 41µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "VXgFNA1sdAUFDyJyHFcnaVYldzcxFG8wg2cuNZG2izM-ddC-4G0GfvWf", "_mounts" => "0", "vsn" => "2.0.0"}
# Called 3rd time
{"Search query", nil}
# Called 4th time
{"Search query", "search"}

What is going on here?!?! Why is load_code_reviews/2 called so many times. It looks a bit wasteful to me…

This happens because we’ve got a combo of load_code_reviews/2 calls. It’s called from handle_params/3 and mount/3. handle_params/3 is always called immediately after rendering. It means we can get rid of the call in mount/3.

  @impl true
  def mount(_params, _session, socket) do
-   {:ok, socket |> load_code_reviews()}
+   {:ok, socket}
  end

  # Query is no longer optional argument
- def load_code_reviews(socket, query \\ nil) do
+ def load_code_reviews(socket, query) do

Updated logs looks like this:

[info] GET /code_reviews
[debug] Processing with Phoenix.LiveView.Plug.index/2
  Parameters: %{"query" => "handle"}
  Pipelines: [:browser]
# Called 1st time
{"Search query", "handle"}
[info] Sent 200 in 7ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 85µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "BztfdQsCRg8oLV4RSl8zIBE9KCkzDWEv5q94H4u8DX1N2lWds_oYdoYy", "_mounts" => "0", "vsn" => "2.0.0"}
[info] CONNECTED TO Phoenix.LiveView.Socket in 31µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "Sx8iFCZ_QkJZLVYZPUYzIAE6H0EVM1pmyUDUeIqu5X9FEuWdcXX1BQb0", "_mounts" => "0", "vsn" => "2.0.0"}
# Called 2nd time
{"Search query", "handle"}

Why there 2 calls, I’ll defer to awesome Phoenix LiveView documentation. If you’re still confused - search on Elixir Forum.

Now one last thing…

Automatic results update

We don’t really need to wait for user submit, depending on your preference, you may want to filter results immediately as user starts typing. With LiveView, we can do it easily, we need to subscribe to form change event:

  def render_search_form(assigns) do
    ~L"""
-   <%= form_for :search, "#", [phx_submit: "search"], fn f -> %>
+   <%= form_for :search, "#", [phx_submit: "search", phx_change: "search"], fn f -> %>
      <%= label f, :search %>
      <%= text_input f, :query, value: @query, autocomplete: "off" %>
      <%= submit "Search" %>
    <% end %>
    """
  end

Final result

Code reviews final result with live updates

In case you have any comments or questions - feel free to contact me at email.