Living the LiveView Loca!

Well, I have certainly been a slacker about keeping up with my writing. In the six weeks since my last post, I have continued to work hard on the Elixir/Phoenix back end for Bravauto, my mobile app startup. I guess I shouldn’t be too hard on myself about not writing another post though. I’ve found it hard to work on anything other than my app.

I am excited to report that the core back end for the app – along with a simple (mostly generated) web UI for it – is basically done! All the key concepts are in place at least. I want to spend some time tweaking the UI flow and making some UI elements make more sense. But then I plan to start working on the mobile UI in earnest.

I am still very conflicted on that front. I had originally planned to make a robust web UI in React and then translate that into a mobile UI in React Native. (Or more likely, do the RN version first since it’s important to be able to use this app on a mobile device.) Then I started reading about Progressive Web Apps and wondered if this would be enough to fill my needs. This would let me only have one React version and use it for both web and mobile. My React-teacher brother advised me to go that route, but I’ve also heard that there can be problems with PWAs, particularly on iOS.

More recently, I’ve been wondering if I need a full client-side app (i.e., React) at all, at least for this core MVP. It basically revolves around one form/show page, and a page listing these items (okay, and some settings pages). The server-rendered Phoenix UI I have works fine for all this. So maybe I should just do my planned UI tweaks, “hit it with the pretty stick,” and make it work as a PWA? While I’ve been pondering this, another interesting UI tool came along.

Enter LiveView

Last week, Phoenix LiveView became publicly available! This amazing library for building stateful UIs in Phoenix has been hotly anticipated (at least in Elixir/Phoenix circles) since Chris McCord’s Keynote at ElixirConf 2018. I’m pretty sure it will make quite a stir in the web development world.

In a nutshell, LiveView uses Phoenix Channels (built on Web Sockets) to push dynamic updates to a web page. It has a JavaScript library used to talk to the server through the socket and to effect the necessary page updates. But the developer using LiveView isn’t expected to do anything with this JavaScript other than wire it up and load it. In other words, with LiveView you do not write JavaScript to make dynamic pages. Rather you work entirely in Elixir/Phoenix and HTML.

I had been pretty excited to try this out since hearing about it. And coincidentally, last week I wrapped up the last major architecture story for the core of my app. I turned my attention to bug fixes and improving the user experience. The top bug? “Add timeout for VIN polling.”

My app has a page where the user can enter the VIN of a vehicle they are looking into buying. Once submitted, the app needs to make an API call to get the vehicle year, make, model, and trim style. This could take a bit of time, so as I have done many times in the past, I wired this up to show the user an animated GIF “spinner” and poll the server via Ajax until the vehicle information was ready.

So, the bug here was that I had written this in “happy-path” style: the page starts polling and will only stop when it gets the success response. And to make things worse, the API code did nothing on errors, leaving the poller waiting for something that would never happen. The simple fix was to just make the JavaScript polling code give up and show an error message after some period of time. Rather than do that, however, I thought this would be a perfect chance to try out LiveView!

Goodbye Ajax

In the Ajax-polling version, the flow went like this:

  • User loads page to start a vehicle evaluation, which has a form for entering the VIN
  • That form POSTs to the back end, which kicks off an Elixir Task.async to make the API call and redirects the user to a page with a GIF spinner
  • The spinner page also has some JavaScript to poll the server, checking if the vehicle data has been retrieved
  • Once it has, the user is redirected to a page with the vehicle data and a form to start entering their evaluation info

The first thing I had to do was add LiveView support to my app. You can follow the instructions in the GitHub README or in this Elixir School post. (NB: that post incorrectly lists the mix task for generating a secret as mix phx.secret but it is actually mix phx.gen.secret.) Remember to cd assets && npm install after adding the JavaScript dependency. With the library installed, I set about ripping and tearing. (NB: not actually a useful link and might ruin your day.)

Once I had the library installed in my app, I needed to define my LiveView module:

defmodule BravautoWeb.VinLookupView do
  use Phoenix.LiveView

  def render(assigns) do
    BravautoWeb.VehicleEvaluationView.render("vin_live_form.html", assigns)

  def mount(_session, socket) do
    {:ok, assign(socket, status: "Enter VIN to start")}

  def handle_event("vin_lookup", %{"vin" => vin}, socket) do
    if valid_vin?(vin) do
      # kick off API call here
      {:noreply, assign(socket, status: "Hang on while we look that vehicle up")}
      {:noreply, assign(socket, status: "Please enter a valid VIN")}

The advice given is to put these modules in a “live” directory at the top-level of your web app. The vin_live_form.html is an Eex template, but with the .leex extension instead. It looks like this:

<h1>Start a vehicle evaluation</h1>
<form phx-submit="vin_lookup">
  <label for="vin"><%= @status %></label>
  <input id="vin" name="vin" type="text">
  <div><button phx-disable-with="Searching...">Search</button></div>

Then, the controller’s /new action renders our LiveView instead of a template:

LiveView.Controller.live_render(conn, BravautoWeb.VinLookupView, session: %{})

So now the flow works like this:

  • User loads page to start a vehicle evaluation, which has a form for entering the VIN, but this form has been rendered by the LiveView render function. This is the initial, stateless render.
  • The LiveView library code opens a socket with the server, which calls the mount function in our view. This initiates the stateful connection and the reply updates the status value in the assigns, which we are using for the VIN field label.
  • The user enters a VIN and hits submit. Instead of being an HTTP POST however, the phx-submit attribute wires it up to send a message over the socket.
  • The vin_lookup value of phx-submit maps the form to the handle_event function in our view that is expecting that event. The VIN is passed as a param.
  • If the VIN is invalid, we update the status with an error message.
  • If the VIN is valid, we kick off the API call and update the view to let the user know we are working on it.

I’ve stripped out a bunch of logic checking if we already know about the vehicle and handling the result of the API lookup. But suffice to say that once we have the vehicle data, we can redirect the user to the evaluation form:

    url = Routes.vehicle_evaluation_path(BravautoWeb.Endpoint, :new, vid:
    {:stop, redirect(socket, to: url)}

I ran into one current limitation here: LiveView cannot set things in the web session. I was trying to figure this out and managed to get an answer straight from the horse’s mouth. Chris McCord himself told me in the Phoenix LiveView Slack channel that this is a tricky problem that they are punting on for now.

So instead I just put the vehicle ID on the url as a query param. I want to keep the ID the user is working on in the session, however, so I made a new version of the /new action that takes the query param, puts it in the session and then redirects without the query param to the original action.

Testing LiveViews is wonderfully straightforward. You can write compact, simple tests around your view events:

defmodule BravautoWeb.VinLookupViewTest do
  use BravautoWeb.ConnCase

  import Phoenix.LiveViewTest
  alias BravautoWeb.Endpoint
  alias BravautoWeb.VinLookupView

  test "default view renders VIN lookup form" do
    {:ok, _view, html} = mount_disconnected(Endpoint, VinLookupView, session: %{})
    assert html =~ ~r/phx-submit="vin_lookup"/i
    assert html =~ ~r/button.*search/i

  test "vin_lookup event with invalid VIN renders message" do
    {:ok, view, _html} = mount(Endpoint, VinLookupView, session: %{})
    result = render_submit(view, :vin_lookup, %{vin: ""})
    assert result =~ "Please enter a valid VIN"

  test "vin_lookup event with valid VIN but no vehicle queues lookup and renders message" do
    {:ok, view, _html} = mount(Endpoint, VinLookupView, session: %{})
    result = render_submit(view, :vin_lookup, %{vin: "1FBHE31H5SHB27779"})
    assert result =~ "Hang on while we look that vehicle up"

Paradigm Shifts

Converting my Ajax poller to a LiveView was really pretty simple. Mapping out the changes in flow from one to the other was more mental work than wiring up the view. It certainly is an interesting new tool and something of a game-changer, but also won’t work for all use cases (NB: link is to the source file since docs are not online yet; line number may change as code is updated.) It’s also already caused some somewhat heated discussions.

I also figured out that I can no longer use Task.async for the background work because I need to return the “working on it” message before starting the background work. At least I couldn’t think of any way to do this. I’m still working on it, but I’m thinking I will spawn one of Elixir’s sweet lightweight processes and have the VIN lookup code send it a message when it is done.

I’m not sure if using LiveView in a PWA is feasible either. It certainly wouldn’t make sense with a React/React Native UI. I would be creating an API version of my web layer to talk to such a heavyweight client app anyway. But for now, this was an interesting way to play with LiveView, while also dealing with the poller bug. To be fair, I haven’t actually fixed the bug until I finish replacing the Task with a process. Maybe that’s my next post? Until then, keep living the LiveView loca!

Send me a comment!

(Comments are manually added, so be patient.)

© 2024 Ben Munat. All rights reserved.

Powered by Hydejack v9.1.2