Building real-time search with Phoenix LiveView

Mike Andrianov
Oct 28 · 5 min read
Illustrated by myself :)

Nowadays it’s very important to have some interactivity on our web sites.

I personally don’t like to use search form without real-time feedback. Type something, click search button, wait… Don’t find anything. Okay. Try again. Change text inside the input, click search button again. And wait. After a few iterations I notice, that I try to reduce the count of searches because I don’t want to wait and see again a loading page. As a software developer, I use js frameworks such as React when interactivity is needed. But let’s be honest: after adding them your application becomes much harder to develop and maintain. Also, development time is increasing as well as the complexity of a project. That’s why I used to think twice before deciding to add one of them when a real-time feature is needed for a small project. But thanks to Phoenix LiveView you shouldn’t think twice before adding interactivity! With this library, you can have dynamic behavior with server-rendered HTML, which is updated with web sockets under the hood.

That’s incredible how easy to create an interactive real-time application: it’s almost the same as to create an ordinary static page! I used LiveView for the interactive search for my project, called Heroku Lighthouse. This project collects all Heroku applications and their custom domains in one place and could be very helpful when you should interact with a big amount of them at once and can’t remember exactly which domain belongs to which application.

The search field helps to find necessary applications by name or custom domain.

Let me show you how easy to add this feature to your phoenix application!


Firstly we need to add LiveView to our mix.exs:

def deps
[
...
{:phoenix_live_view, "~> 0.3.0"}
]
end

Next, generate secret by using mix phx.gen.secret and update config.exs:

use Mix.Config...
config :heroku_lighthouse, HerokuLighthouseWeb.Endpoint,
pubsub: [name: HerokuLighthouse.PubSub, adapter: Phoenix.PubSub.PG2],
live_view: [
signing_salt: "<secret goes here>"
]

In my case, I’ve also added config for the pubsub because it will be used later.

Add some code to the lib/heroku_lighthouse_web.ex:

defmodule HerokuLighthouseWeb do
def controller do
quote do
...
import Phoenix.LiveView.Controller
end
end
def view do
quote do
...
import Phoenix.LiveView,
only: [
live_render: 2,
live_render: 3,
live_link: 1,
live_link: 2
]
end
def router do
quote do
...
import Phoenix.LiveView.Router
end
end
end

Okay, we almost did with preparations. Just need to add some code to the assets/js/app.js:

import {Socket} from "phoenix"
import LiveSocket from "phoenix_live_view"
let liveSocket = new LiveSocket("/live", Socket)
liveSocket.connect()

and to assets/package.json:

"dependencies": {
...
"phoenix_live_view": "file:../deps/phoenix_live_view"
},
...

Optionally for a live page reload add this to the config/dev.exs:

config :heroku_lighthouse, HerokuLighthouseWeb.Endpoint,
live_reload: [
patterns: [
...
~r{lib/heroku_lighthouse_web/live/.*(ex)$}
]
]

Now we are ready to build our page with LiveView.

Firstly let’s add a new route to the router.ex:

scope "/", HerokuLighthouseWeb do
pipe_through [:browser, :authenticate_user]
get "/dashboard", DashboardController, :index
end

It’s time to create a view and controller.

lib/heroku_lighthouse_web/views/dashboard_view.ex looks like this:

defmodule HerokuLighthouseWeb.DashboardView do
use HerokuLighthouseWeb, :view
end

And this is dashboard_controller.ex:

defmodule HerokuLighthouseWeb.DashboardController do
use HerokuLighthouseWeb, :controller
alias HerokuLighthouse.Dashboard
alias Phoenix.LiveView
alias HerokuLighthouseWeb.DashboardLive.Index
def action(conn, _) do
apply(__MODULE__, action_name(conn), [conn, conn.params, conn.assigns.current_user])
end
def index(conn, _params, user) do
LiveView.Controller.live_render(conn, Index, session: %{user: user})
end
end

Nothing special here. live_render function renders our live view HerokuLighthouseWeb.DashboardLive.Index. It’s time to create it lib/heroku_lighthouse_web/live/dashboard_live/index.ex:

defmodule HerokuLighthouseWeb.DashboardLive.Index do
use Phoenix.LiveView
alias HerokuLighthouse.Dashboard
alias HerokuLighthouseWeb.DashboardView
def mount(session, socket) do
apps_by_team = Dashboard.cached_grouped_apps(user)
{:ok, assign(socket, apps_by_team: apps_by_team, current_user: session[:user])}
end
def render(assigns) do
DashboardView.render("index.html", assigns)
end
end

When a live view is rendered, the mount callback is invoked. In my case, I retrieve data from the Heroku API, cache it, save the result to the apps_by_team variable and assign data to the socket. After that render is invoked. It returns plain HTML to the browser. A client connects to the live view through the socket and keeps a persisted connection.

Next, we need to create a template with a search field.
lib/heroku_lighthouse_web/templates/dashboard/index.html.leex:

<form phx-change="search" class="search-form">
<%= text_input :search_field, :query, placeholder: "Search for application name, web url or custom domain", autofocus: true, "phx-debounce": "300" %>
</form>
<%= for {team, apps} <- @apps_by_team do %>
<h2><%= team.name %></h4>
<table>
<thead>
<tr>
<th>Name</th>
<th>Web URL</th>
<th>Domains</th>
</tr>
</thead>
<tbody>
<%= for app <- apps do %>
<tr>
<td><%= app.name %></td>
<td><%= app.web_url %></td>
<td>
<%= for domain <- app.domains do %>
<div><%= domain %></div>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

Form’s attribute phx-change="search" handles input changes and sends an event to the server with values from all inputs inside the form. So each time input is updated, the server will receive those changes. To reduce requests to the server I’ve also added "phx-debounce": "300" to the search input.
Currently, the server can’t handle these events. Let’s fix it.

lib/heroku_lighthouse_web/live/dashboard_live/index.ex:

defmodule HerokuLighthouseWeb.DashboardLive.Index do
...
def handle_event("search", %{"search_field" => %{"query" => query}}, socket) do
filtered_apps = Dashboard.filter_grouped_apps(socket.assigns.current_user, query)
{:noreply, assign(socket, :apps_by_team, filtered_apps)}
end
end

When an event comes to the server, I simply filter applications by value from the search input and assign them to the apps_by_team. Phoenix live view notices that value has changed and re-renders part of the DOM with newly filtered applications.

As you can see, with LiveView we can easily add some interactivity. When a new user comes to the Heroku Lighthouse and he has a bunch of applications on Heroku, first page render can take some time. Let’s move this operation to the background, render page fast and update it after all fetching all data. We can do it by sending messages to HerokuLighthouseWeb.DashboardLive.Index.

lib/heroku_lighthouse/dashboard/dashboard.ex:

defmodule HerokuLighthouse.Dashboard do
...
def cached_grouped_apps(user) do
Cachex.get!(:cache_warehouse, "user_#{user.id}_apps") || fetch_apps_async(user)
end
defp fetch_apps_async(user) do
Phoenix.PubSub.broadcast(HerokuLighthouse.PubSub, "dashboard:#{user.id}", :fetching_apps)
Task.start_link(fn ->
fetch_and_cache_apps(user)
Phoenix.PubSub.broadcast(HerokuLighthouse.PubSub, "dashboard:#{user.id}", :apps_fetched)
end)
[]
end

If we don’t have user’s applications inside cache, then we publish event with the topic "dashboard:#{user.id}" and message fetching_apps and start to fetch applications asynchronously. After applications fetching, a new message :apps_fetched will be published. Now we need to handle these messages in the live view.

lib/heroku_lighthouse_web/live/dashboard_live/index.ex:

defmodule HerokuLighthouseWeb.DashboardLive.Index do
...
def mount(session, socket) do
user = session[:user]
is_fetching = Map.get(socket.assigns, :fetching_apps, false)
Phoenix.PubSub.subscribe(HerokuLighthouse.PubSub, "dashboard:#{user.id}")
apps_by_team = Dashboard.cached_grouped_apps(user) {
:ok,
assign(
socket,
apps_by_team: apps_by_team,
current_user: session[:user],
fetching_apps: is_fetching
)
}
end
def handle_info(:fetching_apps, socket) do
{:noreply, assign(socket, fetching_apps: true)}
end
def handle_info(:apps_fetched, socket) do
apps = Dashboard.cached_grouped_apps(socket.assigns.current_user)
{:noreply, assign(socket, apps_by_team: apps, fetching_apps: false)}
end
end

The final step is to update the template.

lib/heroku_lighthouse_web/templates/dashboard/index.html.leex:

<%= if @fetching_apps do %>
<h2> Fetching apps...</h2>
<% else %>
<form phx-change="search" class="search-form">
<%= text_input :search_field, :query, "phx-debounce": "300", placeholder: "Search for application name, web url or custom domain", autofocus: true %>
</form>
<% end %>

The page will be updated automatically as soon, as all data is fetched from the Heroku!

That’s all for today!
Thank you for reading my post, hope it was useful for you!

Mike Andrianov

Written by

http://mikeandrianov.github.io

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade