How Phoenix LiveView works


Course Index page

Code


How does LiveView work?

In the previous lesson we got a taste of the LiveView’s magic! In this lesson we are going to see how LiveView really works and what happens behind the scenes when a user connects.

Let’s start with a simplified version of our dashboard, a view that renders only the Coinbase BTC-USD trades.

crypto_dashboard_live.ex

defmodule PoeticoinsWeb.CryptoDashboardLive do
  use PoeticoinsWeb, :live_view
  alias Poeticoins.Product

  def mount(_params, _session, socket) do
    IO.inspect(self(), label: "MOUNT")

    product = Product.new("coinbase", "BTC-USD")
    trade = Poeticoins.get_last_trade(product)

    # if socket.connected? do
    #   Poeticoins.subscribe_to_trades(product)
    # end

    socket = assign(socket, :trade, trade)
    {:ok, socket}
  end

  def render(assigns) do
    IO.inspect(self(), label: "RENDER")

    ~L"""
    <p><b>Product</b>:
      <%= @trade.product.exchange_name %> -
      <%= @trade.product.currency_pair %>
    </p>
    <p><b>Traded at</b>: <%= @trade.traded_at %></p>
    <p><b>Price</b>: <%= @trade.price %></p>
    <p><b>Volume</b>: <%= @trade.volume %></p>
    """
  end

  def handle_info({:new_trade, trade}, socket) do
    socket = assign(socket, :trade, trade)
    {:noreply, socket}
  end
end

By calling IO.inspect(self(), label: "...") in both mount/3 and render/1 we see when these callbacks are invoked and what is the process PID.

Let’s also temporarily comment the the Poeticoins.subscribe_to_trades(product) line in mount/3, in this way we’ll not get any new trade message, so we can better focus just on the first part of the life-cycle.

HTTP GET Request

Let’s start by doing a simple HTTP GET request to the live "/" route, with a tool like curl

$ curl -v "http://localhost:4000"

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" content="DCcYcUw3OzEsC2ISVAkUTDkgHydfAkZrakIC6mpGVj7YyFXaQUTbis48" csrf-param="_csrf_token"
    method-param="_method" name="csrf-token">

  <script defer phx-track-static type="text/javascript" src="/js/app.js"></script>
</head>

<body>

  <div data-phx-main="true"
    data-phx-session="SFMyNTY..."
    data-phx-static="SFMyNTY..."
    data-phx-view="CryptoDashboardLive" 
    id="phx-FlYt1v20d4jiJQBG">

    <main role="main" class="container">
      <p class="alert alert-info" role="alert" phx-click="lv:clear-flash" phx-value-key="info"></p>

      <p class="alert alert-danger" role="alert" phx-click="lv:clear-flash" phx-value-key="error"></p>

      <p><b>Product</b>:
        coinbase -
        BTC-USD
      </p>
      <p><b>Traded at</b>: 2021-01-01 18:05:18.124448Z</p>
      <p><b>Price</b>: 29315.3</p>
      <p><b>Volume</b>: 0.00673002</p>

    </main>
  </div>
</body>

</html>

We immediately notice that the app answers to our HTTP GET request with a fully rendered page! This means that we can support clients that do not necessarily run JavaScript, which makes LiveView also great for SEO.

HTTP GET request

We made a normal HTTP GET request, mount/3 is called to initialize the data to be rendered, then render/1 returns the rendered content.

On the terminal we see that both mount/3 and render/1 are called.

[info] GET /
[debug] Processing with Phoenix.LiveView.Plug.Elixir.PoeticoinsWeb.CryptoDashboardLive/2
  Parameters: %{}
  Pipelines: [:browser]
MOUNT: #PID<0.462.0>
RENDER: #PID<0.462.0>
[info] Sent 200 in 40ms

Stateful LiveView process

By opening the http://localhost:4000 page with a browser, we see that mount/3 and render/1 are called two times.

MOUNT: #PID<0.599.0>
RENDER: #PID<0.599.0>
[info] Sent 200 in 2ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 73µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "GQclPRUJPykADko2AgcBKDMPSSRUN2YSUbsGs1lb-gzixvqfUlyv5nPw", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/css/app.css", "1" => "http://localhost:4000/js/app.js"}, "vsn" => "2.0.0"}
MOUNT: #PID<0.612.0>
RENDER: #PID<0.612.0>

The first time is to answer to the HTTP GET request, with the fully rendered html page that we saw in the previous example. When the browser receives the HTML content, it loads the app.js application’s JS.

//app.js
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})

// connect if there are any LiveViews on the page
liveSocket.connect()

Running this script, the browser connects again to the server, this time opening a websocket connection, passing the csrf-token, rendered in the <meta> tag in the header. Once connected, the server starts a stateful LiveView process and calls for the second time mount/3 and render/1, pushing the new rendered page via the websocket connection.

Stateful LiveView process with WebSocket connection

This new stateful LiveView process runs as long as the user stays connected, keeping the state in memory, listening to events from the browser and sending rendered changes to the browser, every time we update the socket.assigns values.

To better understand what happens over the WebSocket connection, we can use the browser inspector to see the exchanged messages.

In the inspector, going under the Network tab and refreshing the page, we see a list of requests

Inspector, HTTP GET request

First we see the GET request to localhost and the full html in the server response.

Then, the browser loads the app.js javascript and connects to LiveView via WebSocket. Once the websocket connection is established, the browser immediately sends a phx_join message.

`phx_join`

LiveView replies with a phx_reply message containing the rendered view. In this message we don’t find the simple view’s html, instead we find the dynamic values and the static parts of our template. The static parts are kept in the browser’s memory and only the dynamic changes are sent to the browser from LiveView.

[
  ...
  "phx_reply",
  {"response":
  ...
    {
    "0": "coinbase",
    "1": "BTC-USD",
    "2": "2021-01-01 21:48:39.473797Z",
    "3": "29265.33",
    "4": "0.35729453",
    "s": [
      "<p><b>Product</b>:\n  ",
      " -\n  ",
      "\n</p>\n<p><b>Traded at</b>: ",
      "</p>\n<p><b>Price</b>: ",
      "</p>\n<p><b>Volume</b>: ",
      "</p>\n"
      ]
    }
  }
]

To properly render this view, the LiveView JS code running on the browser, simply interpolates the dynamic values with the static parts:

"<p><b>Product</b>:\n  " + "coinbase" + " -\n  " +
"BTC-USD" + "\n</p>\n<p><b>Traded at</b>: " + 
"2021-01-01 21:48:39.473797Z" + ...

Ok, but what about the updates?

Let’s remove the comments in mount/3, so that the LiveView process subscribes to get new trades and we can see what happens when socket.assigns is updated.

def mount(_params, _session, socket) do
  IO.inspect(self(), label: "MOUNT")

  product = Product.new("coinbase", "BTC-USD")
  trade = Poeticoins.get_last_trade(product)

  if socket.connected? do
    Poeticoins.subscribe_to_trades(product)
  end

  socket = assign(socket, :trade, trade)
  {:ok, socket}
end

We now have all the elements to understand why we used the if socket.connected? condition. socket.connected? is false during the initial HTTP GET request, in this case we don’t want to subscribe to get new trades because the process is not meant to live after the response; we just want to render the view with the most recent trade and close the connection.

When the browser connects, through a WebSocket, to a stateful LiveView, socket.connected? is true and it’s now time to subscribe the LiveView process to the PubSub topic.

def handle_info({:new_trade, trade}, socket) do
  IO.inspect(self(), label: "NEW TRADE")
  socket = assign(socket, :trade, trade)
  {:noreply, socket}
end

handle_info/2 is called every time the process receives a {:new_trade, trade} message. We print the PID and assign/3 the :trade in the socket. The view gets re-rendered, calling render/1, and the changes are pushed to the browser.

By refreshing the page on the browser we see that, as expected, the view is now correctly updated every time there is a new trade. With the browser inspector we can see the messages sent by LiveView process running on the server.

After the initial phx_join and phx_reply, we find diff messages, which are the changes sent from the server every time a new trade is received.

`diff` message

LiveView is able to track the changes and send only the changed values to the browser.

Looking into one of the diff messages, we don’t see any static part, only the dynamic values that changed in socket.assigns. In this way the payload is super compact: we just have the trade.traded_at (position number 2 in the diff message), the trade.price (position number 3) and trade.volume (position number 4).

Each dynamic part has it’s own position number and LiveView uses this positions to know which element needs to be updated in the DOM. The JS code running on the browser applies these changes using a library called Morphdom.

In the terminal we see that each new trade is handled by handle_info/2 which updates the socket, then render/1 is called to re-render the view and send the changes to the browser.

Same process for each connection

We also see that the LiveView process, which serves our browser, is always the same (one process for each connected user). It’s a single stateful LiveView process, that keeps its state in memory and tracks the changes, as long as we stay connected.

Wrap up

HTTP request

Our browser initially connects to the server making a simple HTTP GET request to the live route. The server calls mount/3 and render/1 callbacks to initialize the data and answer with a fully rendered HTML page. The browser loads the html and the application javascript in app.js, and it connects via WebSocket to the server. Once connected, the server spawns a stateful LiveView process which stays alive as long as we are connected. In mount/3 the LiveView process subscribes to get trade messages.

Stateful LiveView process

The browser sends a phx_join message and LiveView answers with a phx_reply message in which there is the rendered view, with dynamic and static parts. Each dynamic part has a position.

*diff* message

Every time the LiveView process receives a new trade from PubSub, it assign/3 the new trade to the socket and LiveView re-renders the view calling render/1. Only the dynamic values that change are sent to the browser with a diff message, in this way the payload stays super compact. The LiveView JS code running in the browser takes these new values and patches the DOM using the Morphdom library.

Patching the DOM

At the moment this view is passive, it only receives new values from the server without any user interaction. In the coming lessons we’ll see how to send events from the browser to the server using buttons, bindings and forms.