Phoenix Live View Debounce

By Tiziano

Phoenix Live View is still in beta but a lot of people are building interesting stuff with it. At Simplificator we are also trying to use it for both external clients and internal projects.

One nice use case of the phoenix live view is to build a dynamic search form that performs some searches while typing.

As showed by Chris McChord in his examples this is easy to achieve with a live view:

def mount(_session, socket) do {:ok, assign(socket, query: nil, results: nil, loading: false)}
end def handle_event("search", %{"query" => query}, socket) do send(self(), {:search, query}) socket = socket |> assign(:query, query) |> assign(:loading, true) {:noreply, socket}
end def handle_info({:search, query}, socket) do socket = socket |> assign(:loading, false) |> assign(results: search_results(query)) {:noreply, socket}
end

The idea is to react on every key stroke by sending a message to the same process. This message is of the form {:search, query} and asynchronously performs the necessary query (for example on the database).

This is a pragmatic approach, but usually we want to avoid to perform too many unnecessary queries while typing. The possibility to set a minimum amount of time between two successive queries is called debounce and it is a very common feature in most javascript framework.

The debounce feature is not available at the moment in phoenix live view but will be implemented in the near future, as you can see from this discussion. Luckily this feature is very easy to implement.

Debounce

The biggest difference from the previous code snippet is that instead of sending a message to the current process, the message to perform the query is sent after a certain "debounce time" and the query term is stored in the state of the process (the socket in case of the phoenix live view).

This is achieved using Process.send_after/3 function which returns a timer reference. Then the timer reference is stored in the state (socket) of the live view along with a loading flag and the search term.

In case a new query term arrives within the "debounce time", the new query term is stored in the state. Once the "debounce time" is reached, the :search message is triggered, and the query performed.

Here you can see the full code snippet:

# new query term arrives within the debounce time
def handle_event("search", %{"query" => q}, %{assigns: %{loading: true}} = socket) do socket = socket |> assign(:query, q) {:noreply, socket}
end def handle_event("search", %{"query" => q}, %{assigns: %{loading: false}} = socket) do # debounce time of 300 ms timer_ref = Process.send_after(self(), :search, 300) socket = socket |> assign(:query, q) |> assign(:timer_ref, timer_ref) |> assign(:loading, true) {:noreply, socket}
end def handle_event("search-final", %{"query" => q}, socket) do Process.cancel_timer(socket.assigns.timer_ref) socket = socket |> assign(:query, q) send(self(), :search) {:noreply, socket}
end def handle_info(:search, socket) do socket = socket |> assign(:loading, false) |> assign(:results, search_results(socket.assigns.query)) {:noreply, socket}
end 

As you see the timer reference is used to cancel the future search request when the user will click on the search button and "commits" to a search; any future request is canceled (if any) and an immediate search performed.