Phoenix LiveView Basics
LiveView, in the words of the official documentation, "provides rich, real-time user experiences with server-rendered HTML". In plain English: you can build interactive UIs, the kind you would usually need React or Vue for, without writing any JavaScript yourself. With Phoenix 1.8 and LiveView 1.1, this approach has matured into a production-ready way to ship interactive apps quickly.
| Feel free to skip this chapter if real-time UIs are not on your radar yet. You can come back any time, nothing later in the book depends on it. |
Under the hood, LiveView keeps a WebSocket connection open between the browser and the server. There is still a small amount of JavaScript, bundled by Phoenix itself, but you do not have to write or maintain it.
A small but important detail: the first response is always plain server-rendered HTML. Users see the full page immediately, search engines see it, and only afterwards does LiveView upgrade the page to an interactive session over the WebSocket. Best of both worlds.
In this chapter, we build four progressively richer examples: a light switch, a ticking clock, a counter, and an airport code search.
Light Switch
The light switch is the "Hello World!" of LiveView.
| Credit to Pragmatic Studio’s excellent LiveView course (https://online.pragmaticstudio.com/courses/liveview) for this idea. |
Create a fresh Phoenix application:
$ mix phx.new demo --no-ecto (1)
[...]
Fetch and install dependencies? [Yn] Y (2)
[...]
$ cd demo
Start your Phoenix app with:
$ mix phx.server
| 1 | Phoenix 1.8 removed the --live flag: LiveView is always
available in new projects. You only need --no-ecto because we do not
want a database for this example. Phoenix ships Tailwind CSS and
daisyUI out of the box, so we have nice buttons without adding
anything. |
| 2 | Say Y. The first install takes a moment, subsequent ones are
cached. |
Our goal: a page at /light that shows the status of a virtual light
bulb and lets the user toggle it on and off, with no page reload and
no JavaScript in sight.
Step 1: add a LiveView route
scope "/", DemoWeb do
pipe_through :browser
get "/", PageController, :home
live "/light", LightLive (1)
end
| 1 | live "/light", LightLive registers a LiveView-powered route. No
controller needed, the LiveView module handles both the initial render
and subsequent interactions. |
Step 2: create the LiveView module
LiveView modules are conventionally placed under lib/demo_web/live/.
defmodule DemoWeb.LightLive do
use DemoWeb, :live_view
def render(assigns) do (1)
~H"""
<h1 class="text-2xl font-bold mb-4">The light is off.</h1>
"""
end
end
| 1 | render/1 returns a HEEx template via the ~H sigil. HEEx
replaces the old .eex sigil ~L that was used in early LiveView
versions; you should always use ~H today. |
Open http://localhost:4000/light in your browser.
Step 3: move the word "off" into an assign
Hard-coding the word "off" is no fun. We want the template to display a
variable we can change later. To do that we need mount/3, the function
LiveView calls when a session starts:
defmodule DemoWeb.LightLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do (1)
{:ok, assign(socket, :light_bulb_status, "off")} (2)
end
def render(assigns) do
~H"""
<h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1> (3)
"""
end
end
| 1 | mount/3 runs once when the LiveView starts. For this example we
only care about the third argument, socket, which is the LiveView
equivalent of conn. |
| 2 | assign/3 stores values on the socket. The template can read them
back with @name. |
| 3 | HEEx interpolates {@light_bulb_status} into the HTML. That {…}
syntax replaces the older <%= @light_bulb_status %> and is the
recommended form in LiveView 1.1. |
The page still reads "The light is off" but now off is dynamic. Good.
Step 4: add an "On" button
We want a button that turns the light on. In LiveView, any DOM event
can be wired back to the server with a phx-* attribute:
def render(assigns) do
~H"""
<h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1>
<button type="button" phx-click="on" class="btn btn-success"> (1)
On
</button>
"""
end
| 1 | phx-click="on" tells LiveView: when the user clicks this button,
send an event named "on" to the server. |
The button appears, but clicking it does nothing yet. LiveView needs a
handle_event/3 to know what to do with the "on" message.
defmodule DemoWeb.LightLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :light_bulb_status, "off")}
end
def render(assigns) do
~H"""
<h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1>
<button type="button" phx-click="on" class="btn btn-success">
On
</button>
"""
end
def handle_event("on", _params, socket) do (1)
{:noreply, assign(socket, :light_bulb_status, "on")} (2)
end
end
| 1 | The first argument pattern-matches the event name sent by the
browser; _params swallows any extra payload we do not need. |
| 2 | Update the assign and return the new socket. The return tuple is
{:noreply, socket} because we are not redirecting. |
Click the button and the headline changes to "The light is on" instantly, no page reload.
Step 5: add an "Off" button
Mirror the on flow for the opposite direction:
defmodule DemoWeb.LightLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :light_bulb_status, "off")}
end
def render(assigns) do
~H"""
<h1 class="text-2xl font-bold mb-4">The light is {@light_bulb_status}.</h1>
<button
type="button"
phx-click="on"
class="btn btn-success mr-2"
disabled={@light_bulb_status == "on"}> (1)
On
</button>
<button
type="button"
phx-click="off"
class="btn btn-neutral"
disabled={@light_bulb_status == "off"}>
Off
</button>
"""
end
def handle_event("on", _params, socket) do
{:noreply, assign(socket, :light_bulb_status, "on")}
end
def handle_event("off", _params, socket) do
{:noreply, assign(socket, :light_bulb_status, "off")}
end
end
| 1 | HEEx treats {expr} inside an attribute as an Elixir expression.
The button is disabled when it does not apply to the current state, a
small UX polish that LiveView makes trivial to express. |
Click away. The light turns on and off, the button enables/disables appropriately, and we have not written a single line of JavaScript.
Clock
Events from the user are only half the story. LiveView can also push updates on its own schedule. Here is a server-driven clock: the browser receives a new time every second without asking.
$ mix phx.new clock --no-ecto
[...]
$ cd clock
scope "/", ClockWeb do
pipe_through :browser
get "/", PageController, :home
live "/clock", ClockLive (1)
end
| 1 | The clock will live at http://localhost:4000/clock. |
defmodule ClockWeb.ClockLive do
use ClockWeb, :live_view
def mount(_params, _session, socket) do
if connected?(socket) do (1)
:timer.send_interval(1000, self(), :tick) (2)
end
{:ok, assign_current_time(socket)} (3)
end
def render(assigns) do
~H"""
<h1 class="text-4xl font-mono">{@now}</h1>
"""
end
def handle_info(:tick, socket) do (4)
{:noreply, assign_current_time(socket)}
end
defp assign_current_time(socket) do
now =
Time.utc_now() (5)
|> Time.to_string()
|> String.split(".") (6)
|> hd()
assign(socket, now: now) (7)
end
end
| 1 | mount/3 runs twice: first on the initial HTTP request (so users
without JavaScript still see a page), and again after the WebSocket
connects. connected?/1 is true only on the second call. We start the
timer there so we do not schedule two timers per page load. |
| 2 | The Erlang :timer module sends :tick to the current process
every 1000 ms. |
| 3 | Seed the socket with a current time so the initial render is not blank. |
| 4 | handle_info/2 receives messages sent to the LiveView process.
Here it is the :tick atom from the timer. |
| 5 | Time.utc_now/0 returns the server time. |
| 6 | String.split("…", ".") |> hd() drops the microseconds tail so the
display is 12:34:56, not 12:34:56.789012. |
| 7 | Remember to return the socket, otherwise the template will not see the new value. |
Start the server with mix phx.server and open
http://localhost:4000/clock. The time ticks once per second.
Counter
A counter is a classic interactive widget. It starts at 0 and a button increases it by one.
$ mix phx.new demo --no-ecto
[...]
$ cd demo
scope "/", DemoWeb do
pipe_through :browser
get "/", PageController, :home
live "/counter", CounterLive (1)
end
| 1 | The counter will be available at http://localhost:4000/counter. |
Create lib/demo_web/live/counter_live.ex:
defmodule DemoWeb.CounterLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :counter, 0)} (1)
end
def render(assigns) do
~H"""
<h1 class="text-2xl font-bold mb-4">Current count: {@counter}</h1>
<button phx-click="inc" class="btn btn-primary mr-2">+1</button> (2)
<button phx-click="reset" class="btn">Reset</button>
"""
end
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :counter, &(&1 + 1))} (3)
end
def handle_event("reset", _params, socket) do
{:noreply, assign(socket, :counter, 0)}
end
end
| 1 | Start the counter at 0. |
| 2 | Two buttons, two events ("inc" and "reset"). |
| 3 | update/3 is a shortcut: "update this assign by passing it through
this function." The capture &(&1 + 1) is shorthand for
fn n → n + 1 end. |
Open http://localhost:4000/counter and click the buttons.
assign vs update
In the counter example we used update/3:
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :counter, &(&1 + 1))}
end
The same thing with assign/3 looks like:
def handle_event("inc", _params, socket) do
counter = socket.assigns.counter + 1
{:noreply, assign(socket, :counter, counter)}
end
Both work. Reach for update/3 when the new value depends on the old
one, and assign/3 when you are setting a value outright.
Airport Code Search
Our last example is an airport code search. User types a code prefix, the list of matching airports updates live.
$ mix phx.new travelagent --no-ecto
$ cd travelagent
Route:
scope "/", TravelagentWeb do
pipe_through :browser
get "/", PageController, :home
live "/search", SearchLive
end
A small module that pretends to be our airports database:
defmodule Travelagent.Airports do
def search_by_code(""), do: [] (1)
def search_by_code(code) do (2)
list_airports()
|> Enum.filter(&String.starts_with?(&1.code, code))
end
def list_airports do (3)
[
%{name: "Berlin Brandenburg", code: "BER"},
%{name: "Bremen", code: "BRE"},
%{name: "Köln/Bonn", code: "CGN"},
%{name: "Dortmund", code: "DTM"},
%{name: "Dresden", code: "DRS"},
%{name: "Düsseldorf", code: "DUS"},
%{name: "Frankfurt", code: "FRA"},
%{name: "Frankfurt-Hahn", code: "HHN"},
%{name: "Hamburg", code: "HAM"},
%{name: "Hannover", code: "HAJ"},
%{name: "Leipzig Halle", code: "LEJ"},
%{name: "München", code: "MUC"},
%{name: "Münster Osnabrück", code: "FMO"},
%{name: "Nürnberg", code: "NUE"},
%{name: "Paderborn Lippstadt", code: "PAD"},
%{name: "Stuttgart", code: "STR"}
]
end
end
| 1 | An empty query produces an empty list, saves us a branch later. |
| 2 | search_by_code/1 returns only the airports whose codes start with
the given prefix. |
| 3 | Hard-coded list for demo purposes. A real app would read from a database. |
This time the template lives in its own .heex file alongside the
LiveView module. LiveView automatically picks up
<module_name>.html.heex next to the module file:
<div class="max-w-2xl mx-auto p-4">
<form phx-submit="airport_code_search" class="mb-6 space-y-2">
<label for="airport_code" class="label">Airport Code</label>
<input
id="airport_code"
type="text"
name="airport_code"
value={@airport_code}
placeholder="e.g. FRA"
autofocus
autocomplete="off"
class="input input-bordered w-full" /> (1)
<button type="submit" class="btn btn-primary">Search</button>
</form>
<%= unless @airports == [] do %> (2)
<h2 class="text-xl font-semibold mb-2">Search Results</h2>
<table class="table table-zebra">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr :for={airport <- @airports}> (3)
<td class="font-mono">{airport.code}</td>
<td>{airport.name}</td>
</tr>
</tbody>
</table>
<% end %>
</div>
| 1 | autofocus puts the cursor in the field on page load;
autocomplete="off" stops the browser from pre-filling values that
would fight our LiveView updates. |
| 2 | unless + end still works in HEEx but feels clunky. We could
also have written <%= if @airports != [], do: … %>. |
| 3 | LiveView 1.1 supports the :for attribute directly on HTML tags.
It replaces the older <%= for airport ← @airports do %>…<% end %>
block and is the recommended way to loop in HEEx. |
Finally, the LiveView module:
defmodule TravelagentWeb.SearchLive do
use TravelagentWeb, :live_view
alias Travelagent.Airports (1)
def mount(_params, _session, socket) do
socket =
socket
|> assign(:airport_code, "") (2)
|> assign(:airports, [])
{:ok, socket}
end
def handle_event(
"airport_code_search",
%{"airport_code" => airport_code},
socket
) do
airport_code = String.upcase(airport_code) (3)
socket =
socket
|> assign(:airport_code, airport_code) (4)
|> assign(:airports, Airports.search_by_code(airport_code))
{:noreply, socket}
end
end
| 1 | alias shortens Travelagent.Airports to Airports in this
module. |
| 2 | Start with empty inputs so the template has something to show. |
| 3 | Normalise the query to upper-case so fra matches FRA. |
| 4 | Send the (normalised) text back to the input via the assign. Now the user sees the code they are actually searching for. |
Open http://localhost:4000/search and try it out.
Autocomplete
Pressing the Search button for every query is 2005. Let us update the result list on every keystroke instead. The only change is in the template:
<div class="max-w-2xl mx-auto p-4">
<form phx-change="airport_code_search" class="mb-6 space-y-2"> (1)
<label for="airport_code" class="label">Airport Code</label>
<input
id="airport_code"
type="text"
name="airport_code"
value={@airport_code}
placeholder="e.g. FRA"
autofocus
autocomplete="off"
class="input input-bordered w-full" />
</form>
<%= unless @airports == [] do %>
<h2 class="text-xl font-semibold mb-2">Search Results</h2>
<table class="table table-zebra">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr :for={airport <- @airports}>
<td class="font-mono">{airport.code}</td>
<td>{airport.name}</td>
</tr>
</tbody>
</table>
<% end %>
</div>
| 1 | Swap phx-submit for phx-change. Every keystroke now fires the
airport_code_search event, and because our handler already accepts
the full form data, there is nothing else to change. |
Open http://localhost:4000/search and start typing. The list updates as you type.
Forms
The airport search already used a form (phx-submit / phx-change),
but we only ever passed one field around and we read it with
value={@airport_code} by hand. Real forms have multiple fields,
per-field errors, and need to survive a failed submit without
losing the user’s input. LiveView has a dedicated helper for that:
Phoenix.Component.to_form/2.
The pattern is:
-
Build a form struct with
to_form(params, as: "profile"). -
Render it with
<.form for={@form}>and reference fields as@form[:name]. -
Handle
phx-changeto re-validate on every keystroke, andphx-submitto actually save.
Route:
scope "/", DemoWeb do
pipe_through :browser
get "/", PageController, :home
live "/profile", ProfileLive (1)
end
| 1 | The profile route points at the new ProfileLive module. |
The LiveView itself. This example uses a plain map for validation
so we do not pull Ecto into the project; with Ecto you would wrap
the same shape in a changeset.
defmodule DemoWeb.ProfileLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
form = to_form(%{"name" => "", "email" => ""}, as: "profile") (1)
{:ok, assign(socket, form: form, errors: %{}, saved: nil)}
end
def render(assigns) do
~H"""
<div class="max-w-md mx-auto p-4 space-y-4">
<h1 class="text-2xl font-bold">Edit profile</h1>
<.form
for={@form}
phx-change="validate"
phx-submit="save"
class="space-y-4"
>
<div>
<label for={@form[:name].id} class="label">Name</label> (2)
<input
type="text"
name={@form[:name].name}
id={@form[:name].id}
value={@form[:name].value}
class="input input-bordered w-full"
/>
<p :if={@errors[:name]} class="text-error text-sm">
{@errors[:name]}
</p>
</div>
<div>
<label for={@form[:email].id} class="label">Email</label>
<input
type="text"
name={@form[:email].name}
id={@form[:email].id}
value={@form[:email].value}
class="input input-bordered w-full"
/>
<p :if={@errors[:email]} class="text-error text-sm">
{@errors[:email]}
</p>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</.form>
<p :if={@saved} class="text-success">
Saved: {@saved["name"]} <{@saved["email"]}>
</p>
</div>
"""
end
def handle_event("validate", %{"profile" => params}, socket) do (3)
{:noreply,
assign(socket,
form: to_form(params, as: "profile"),
errors: validate(params),
saved: nil
)}
end
def handle_event("save", %{"profile" => params}, socket) do (4)
case validate(params) do
errors when errors == %{} ->
{:noreply,
assign(socket,
form: to_form(params, as: "profile"),
errors: %{},
saved: params
)}
errors ->
{:noreply,
assign(socket,
form: to_form(params, as: "profile"),
errors: errors,
saved: nil
)}
end
end
defp validate(params) do
%{}
|> maybe_put_error(:name, params, &(String.length(&1) < 2), "at least 2 characters")
|> maybe_put_error(:email, params, &(not String.contains?(&1, "@")), "must contain @")
end
defp maybe_put_error(errors, field, params, pred?, message) do
value = params[Atom.to_string(field)] || ""
if pred?.(value), do: Map.put(errors, field, message), else: errors
end
end
| 1 | to_form/2 turns a params map (or an Ecto changeset) into a
Phoenix.HTML.Form struct. The as: option is the form name and
becomes the top-level key in the event payload ("profile" ⇒ %{…}). |
| 2 | @form[:name] returns a field struct with .id, .name, and
.value pre-computed. No more hand-wiring name="profile[name]". |
| 3 | phx-change fires on every keystroke. Re-assigning form: with
the latest params keeps what the user typed; re-computing errors
keeps the UI in sync. |
| 4 | phx-submit runs once on submit. In a real app this would
insert a row via Ecto or an Ash action. |
Type an invalid Name ("A") and an invalid Email ("no-at-sign"):
errors appear live, driven by phx-change:
The <.form> component also ships with built-in CSRF protection
(it injects a hidden token when the form targets a controller). For
LiveView forms you rarely need to think about it — LiveView carries
the socket’s CSRF token implicitly.
|
With Ecto or Ash
Two common sources for to_form/2:
-
Ecto.Changeset.
to_form(changeset)— field errors come straight from the changeset, so@form[:email].errorsreplaces the manual@errors[:email]map above. Phoenix’s built-in generators use this shape. -
AshPhoenix.Form.
AshPhoenix.Form.for_create(App.Blog.Post, :create, as: "post") |> to_form()— Ash actions plug directly into LiveView forms. Field validation, relationship handling, and typed arguments all come along automatically. See https://hexdocs.pm/ash_phoenix for the full DSL.
Streams
By now you might have noticed that every LiveView example keeps the
full state of the page in socket.assigns: @light_bulb_status,
@counter, the whole @airports list. For short lists this is fine
— LiveView’s diffing only sends the bits that changed over the wire.
When the list grows to hundreds or thousands of items, re-rendering and re-diffing the whole assign on every update starts to hurt. Streams are LiveView 1.0’s answer: the server never holds the full list, it just emits insert / update / delete operations and the browser reconciles the DOM.
The pattern:
-
Seed the stream in
mount/3withstream/3. -
Render it inside an
id=…container withphx-update="stream". -
Use
stream_insert/4,stream_delete/3, and friends in yourhandle_event/3callbacks.
Route and LiveView:
scope "/", DemoWeb do
pipe_through :browser
live "/todos", TodoLive
end
defmodule DemoWeb.TodoLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
seed = [
%{id: 1, text: "Read the LiveView streams docs"},
%{id: 2, text: "Fix the colour picker"},
%{id: 3, text: "Ship the landing page"}
]
{:ok,
socket
|> assign(:form, to_form(%{"text" => ""}, as: "todo"))
|> assign(:next_id, 4)
|> stream(:todos, seed)} (1)
end
def render(assigns) do
~H"""
<div class="max-w-md mx-auto p-4 space-y-4">
<h1 class="text-2xl font-bold">Todos</h1>
<.form for={@form} phx-submit="add" class="flex gap-2">
<input
type="text"
name="todo[text]"
value=""
placeholder="What needs doing?"
class="input input-bordered flex-1"
/>
<button type="submit" class="btn btn-primary">Add</button>
</.form>
<ul id="todos" phx-update="stream" class="space-y-1"> (2)
<li
:for={{dom_id, todo} <- @streams.todos} (3)
id={dom_id}
class="flex items-center justify-between p-2 border rounded"
>
<span>{todo.text}</span>
<button
type="button"
phx-click="remove"
phx-value-id={todo.id}
class="btn btn-sm btn-ghost"
>
✕
</button>
</li>
</ul>
</div>
"""
end
def handle_event("add", %{"todo" => %{"text" => text}}, socket)
when text != "" do
todo = %{id: socket.assigns.next_id, text: text}
{:noreply,
socket
|> stream_insert(:todos, todo, at: 0) (4)
|> assign(:next_id, socket.assigns.next_id + 1)}
end
def handle_event("add", _params, socket), do: {:noreply, socket}
def handle_event("remove", %{"id" => id}, socket) do
{:noreply, stream_delete(socket, :todos, %{id: String.to_integer(id)})} (5)
end
end
| 1 | stream(:todos, seed) names the stream and seeds it with the
initial items. Each item must have an :id key so LiveView can
track it in the DOM. |
| 2 | phx-update="stream" is the magic attribute. Without it the
template would still render, but LiveView would treat the list as
ordinary assigns and you would lose the memory win. |
| 3 | Iterating over @streams.todos yields {dom_id, todo} tuples.
The dom_id is what LiveView uses to target DOM nodes for updates;
put it on the element’s id attribute. |
| 4 | stream_insert(socket, :todos, todo, at: 0) prepends. Pass
at: -1 to append, or omit at: for append-to-end. |
| 5 | stream_delete(socket, :todos, %{id: …}) removes the matching
item. The struct-or-map you pass only needs to expose the same key
as the seed — here :id. |
Add a todo, remove one. The page updates instantly, and — critically
— socket.assigns.streams.todos does not grow with each insert.
LiveView keeps only the delta it needs to ship to the client.
For datasets large enough that you do not want to load them
all into memory at once, combine stream/3 with
Phoenix.LiveView.stream_configure/3 and a paged read. That is
outside this chapter; the
official
stream/3 docs are excellent.
|