Learn Phoenix LiveView is the comprehensive tutorial that teaches you everything you need to build a complex, realistic, fully-featured web app with Phoenix LiveView. Click here to learn more!
A classic job interview question is: “What happens when you type a URL into your browser’s address bar and hit Enter?” In my last post, I explained how it works in a Phoenix app: Phoenix initializes a %Plug.Conn{}
representing the incoming HTTP request, then passes it through a chain of functions returning a new %Plug.Conn{}
with the response.
For a static page rendered by a controller, that’s all. But if the page is a LiveView (arguably Phoenix’s killer feature), we’re only just beginning. In this post I’ll continue the story and explain how pressing “Enter” in your address bar can make Phoenix open a websocket and mount a LiveView.
For illustrative purposes, let’s scaffold a LiveView in the same Example
app from the previous post. Generate a set of LiveViews for a resource called Product
:
$ mix phx.gen.live Store Product products name:string
Follow the generator’s instructions by adding routes to the router:
defmodule ExampleWeb.Router do
use ExampleWeb, :router
…
scope "/", ExampleWeb do
pipe_through :browser
get "/", PageController, :home
resources "/users", UserController
+
+ live "/products", ProductLive.Index, :index
+ live "/products/new", ProductLive.Index, :new
+ live "/products/:id/edit", ProductLive.Index, :edit
+
+ live "/products/:id", ProductLive.Show, :show
+ live "/products/:id/show/edit", ProductLive.Show, :edit
end
…
Then run the new migration file:
$ mix ecto.migrate
08:33:06.318 [info] == Running 20240929072955 Example.Repo.Migrations.CreateProducts.change/0 forward
08:33:06.319 [info] create table products
08:33:06.343 [info] == Migrated 20240929072955 in 0.0s
Start the server and visit localhost:4000/products. It renders the ProductLive.Index
LiveView:
Because this page is a LiveView, a websocket is now open between browser and server. LiveView lets us add rich, interactive UI without any custom Javascript:
So how does it work?
When you hit “Enter” in the address bar, your browser has no idea that the page will be served by LiveView. How could it know? It makes an HTTP GET
request like it would for any other URL.
Initially, Phoenix handles this like any other HTTP request: it initializes a %Plug.Conn{}
then passes it through a list of plugs as defined in the Endpoint
and Router
. The path GET /products
matches this route defined by live/3
:
# lib/example_web/router.ex
defmodule ExampleWeb.Router do
…
scope "/", ExampleWeb do
pipe_through :browser
…
live "/products", ProductLive.Index, :index
…
So the %Plug.Conn{}
is passed through the :browser
pipeline. Overall, the %Plug.Conn{}
gets passed through the same chain of functions as before:
conn
|> Plug.Static.call()
|> Phoenix.LiveDashboard.RequestLogger.call()
|> Phoenix.LiveReloader.call()
|> Phoenix.CodeReloader.call()
|> Phoenix.Ecto.CheckRepoStatus.call()
|> Plug.RequestId.call()
|> Plug.Telemetry.call()
|> Plug.Parsers.call()
|> Plug.MethodOverride.call()key
|> Plug.Head.call()
|> Plug.Session.call()
|> Phoenix.Controller.accepts()
|> Plug.Conn.fetch_session()
|> Phoenix.LiveView.Router.fetch_live_flash()
|> Phoenix.Controller.put_root_layout()
|> Phoenix.Controller.protect_from_forgery()
|> Phoenix.Controller.put_secure_browser_headers()
See my previous post if you’re not sure how this work.
The client is still waiting for an HTTP response, but this time it won’t be rendered by a controller. It’ll be rendered by ProductLive.Index
.
No spam. Unsubscribe any time.
First off, Phoenix mounts the LiveView by calling its mount/3
function:
# lib/example_web/live/product_live/index.ex
defmodule ExampleWeb.ProductLive.Index do
use ExampleWeb, :live_view
…
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :products, Store.list_products())}
end
…
The argument socket
is an instance of %Phoenix.LiveView.Socket{}
, representing the websocket connection between client and server. (The actual websocket isn’t open yet, but we’ll discuss that in a moment.)
We no longer have access to the %Plug.Conn{}
, but the %Plug.Conn{}
‘s data will still be used when returning the HTTP response. For example, any HTTP headers set by the Endpoint
or a router pipeline will be included in the response returned by the LiveView.
mount/3
must return a tuple {:ok, socket}
, where socket
is an updated %Phoenix.LiveView.Socket{}
containing whatever data is necessary to render the initial LiveView. In this case, we’re loading all products then streaming them, but the details don’t matter here.
After mount/3
, LiveView calls handle_params/3
to make additional updates to the socket if needed. (The distinction between mount/3
and handle_params/3
doesn’t matter for the purposes of this post):
# lib/example_web/live/product_live/index.ex
defmodule ExampleWeb.ProductLive.Index do
…
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
…
Finally, LiveView must render its template, written in HEEx (HTML + Embedded Elixir). For ProductLive.Index
the template is defined at lib/example_web/live/product_live/index.html.heex
:
<.header>
Listing Products
<:actions>
<.link patch={~p"/products/new"}>
<.button>New Product</.button>
</.link>
</:actions>
</.header>
…
Equivalently, we could have defined a function render/1
which renders HEEx with a ~H
sigil:
# lib/example_web/live/product_live/index.ex
defmodule ExampleWeb.ProductLive.Index do
…
# This function, if it existed, would be exactly equivalent to defining the
# template in a separate .html.heex file:
@impl true
def render(assigns) do
~H"""
<.header>
Listing Products
<:actions>
<.link patch={~p"/products/new"}>
<.button>New Product</.button>
</.link>
</:actions>
</.header>
…
"""
end
The HTML rendered by this HEEx is sent as the response to the original HTTP request. See for yourself: open your browser’s network inspector, refresh the page, and look for the GET
request to /products
. You’ll see it received a 200
response with the rendered HTML:
We still haven’t opened the websocket that makes LiveView work. But we’ve rendered a static page of HTML like so:
HTTP is a request-response protocol. The browser sends a request, the server responds, then it’s over. If the browser wants more it needs to send another request. And if the server has something new to say, it can’t initiate the conversation, and can only wait for the browser’s next request.
Websockets work differently. Unlike HTTP, a websocket allows full duplex communication, where either side can send a message to the other at any time. Once a websocket is open between browser and server, it stays open until one side closes it (e.g. user closes the browser tab).
Websockets are how LiveView works its magic. Once the initial HTTP request has rendered its first response, it opens a websocket connection over which all future communication happens.
Back in the network inspector, you can see a websocket connection has been opened to the URL ws://localhost:4000/live/websocket
:
Since we’re in dev
mode, there’s also a websocket open to the path /phoenix/live_reload/socket/websocket
, which is used for code reloading (i.e. automatically updating your app when you change the code.) It’s not opened in tests or production, and we won’t discuss it further here.
Look again at the HTML that was rendered for the original GET request. It contains a <script>
tag:
Without getting into the full details of Phoenix asset compilation: this tag loads your app’s compiled app.js
file, which includes the Javascript from assets/js/app.js
in your repo. Among other things, that Javascript opens a websocket connection:
// assets/app.js
…
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
…
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
…
// connect if there are any LiveViews on the page
liveSocket.connect()
Back in endpoint.ex
, you can see that in addition to all the plug
s, the server calls socket/3
with first argument "/live"
:
# lib/example_web/endpoint.ex
defmodule ExampleWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :example
…
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
…
This defines a websocket endpoint at /live/websocket
, which is what our browser is currently connected to as seen in the network inspector. If it’s not possible to open a websocket (true for around 2-3% of servers according to Elixir creator José Valim), it also provides an endpoint /live/longpoll
so that the client can fall back to long polling.
If you stick some debugging code in ProductLive.Index.mount/3
, you might notice something interesting:
# lib/slax_web/live/produce_live/index.ex
defmodule SlaxWeb.ProductLive.Index do
…
@impl true
def mount(_params, _session, socket) do
+ IO.puts("mounting")
{:ok, stream(socket, :products, Store.list_products())}
end
@impl true
def handle_params(params, _url, socket) do
+ IO.puts("handling params")
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
…
Refresh the page and look in your server logs:
[info] GET /productsproducts
mounting
…
handling params
…
[info] Sent 200 in 13ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 13µs
…
mounting
…
handling params
…
As you can see, mount/3
and handle_params/3
are called twice when the page loads. (The template is rendered twice too, as you can see if you e.g. stick <% IO.inspect("rendering") %>
somewhere in the index.html.heex
template.) They’re called once for the initial HTTP request, then again after the websocket has connected.
You can check the difference by calling connected?/1
:
@impl true
def mount(_params, _session, socket) do
- IO.puts("mounting")
+ IO.puts("mounting (connected: #{connected?(socket)})")
{:ok, stream(socket, :products, Store.list_products())}
end
@impl true
def handle_params(params, _url, socket) do
- IO.puts("handling params")
+ IO.puts("handling params (connected: #{connected?(socket)})")
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
Refresh the page and check the logs again.
[info] GET /products
mounting (connected: false)
…
handling params (connected: false)
…
[info] Sent 200 in 13ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 13µs
…
mounting (connected: true)
…
handling params (connected: true)
By rendering the initial page of static HTML, LiveView gives the user a visual response as quickly as possible. The HTML also contains the <script>
tag to load the Javascript that opens the websocket. Then once the websocket is open, LiveView calls mount/3
again with the connected socket
and re-renders. This lets you perform any additional initialization that requires a connected socket.
With a websocket now open, LiveView waits for an event such as user interaction:
And as you interact with the page, you can see messages being sent over the websocket in both directions.
LiveView keeps the websocket open when possible. If, for example, you navigate to a new page with push_navigate
, there’s no HTTP request: LiveView re-uses the existing websocket[1], and only calls mount/3
once, with a connected socket
for the new page’s LiveView. Re-using the websocket saves resources as it avoids the overhead of a new HTTP request.
So that’s how a LiveView mounts. The above diagram doesn’t come close to including the full LiveView lifecycle - many things can happen after mounting, such as navigation with navigate
or patch
, events triggered by user interaction or pushed from hooks, messages handled with handle_info/3
and more. But you can learn about all of that and more from my course at PhoenixLiveView.com.
No spam. Unsubscribe any time.