For years, the dream of building a real-time, highly interactive web application came with a heavy tax: the complexity of the “Modern Frontend Stack.” If you wanted a page to update instantly without a refresh, you typically had to build a JSON API in a backend language (like Elixir, Ruby, or Python) and then manage a complex state machine in a JavaScript framework (like React, Vue, or Next.js).
This “separation of concerns” often felt more like a “duplication of effort.” You would define your data structures twice, your validation logic twice, and spend half your time debugging the “plumbing” between the client and the server. Then came Phoenix LiveView.
Phoenix LiveView is a game-changer for Elixir developers. It allows you to build rich, real-time user experiences using server-rendered HTML. It handles the state on the server, communicates changes over a persistent WebSocket (Phoenix Channels), and intelligently updates the DOM. The result? You write less JavaScript, maintain a single source of truth, and deliver incredibly fast applications.
In this comprehensive guide, we will dive deep into the world of Phoenix LiveView. Whether you are a beginner looking to understand the basics or an intermediate developer ready to tackle PubSub and JS Hooks, this guide will provide you with the tools to build world-class real-time applications.
Why Phoenix LiveView? The Problem It Solves
In traditional web development, there is a massive gap between the “Request-Response” cycle and the “Real-Time” experience. JavaScript frameworks tried to bridge this gap by moving the UI logic to the browser. However, this introduced new problems: heavy bundle sizes, complex state management (Redux, anyone?), and SEO challenges.
Phoenix LiveView takes a different approach. It leverages the Erlang VM (BEAM), which was designed from the ground up for concurrency and massive numbers of simultaneous connections. LiveView keeps a tiny process running on the server for every connected user. When something changes—either because the user clicked a button or because a database record was updated—LiveView calculates the difference (the “diff”) and sends only the changed HTML over the wire.
The Key Advantages:
- Reduced Complexity: No need for a separate REST or GraphQL API for your frontend.
- Maintainability: Your business logic stays in one place (Elixir).
- Performance: LiveView sends tiny binary packets over WebSockets, which is often faster than re-rendering an entire React component tree.
- Reliability: Built on the fault-tolerant Erlang VM, your “Live Views” are resilient to crashes.
Core Concepts: How LiveView Works
Before we write code, we need to understand the lifecycle of a LiveView. It isn’t a standard controller; it’s a stateful process.
1. The Initial Mount
When a user visits a LiveView URL, Phoenix does two things. First, it performs a standard HTTP request and returns a fully rendered HTML page. This is great for SEO and ensures the user sees content immediately. Second, the browser opens a WebSocket connection to the server and “upgrades” the page to a stateful LiveView.
2. The Render Loop
LiveView relies on a function called render/1 (or a separate .heex template). This function takes the current “socket assigns” (the state) and turns it into HTML. Whenever the state changes, LiveView automatically re-runs this function, finds what changed, and pushes the updates to the browser.
3. The Socket Assigns
Think of socket.assigns as your state bucket. Everything the UI needs to know—user data, counter values, list items—lives here. You never modify the socket directly; you use the assign(socket, key, value) function.
Step 1: Setting Up Your Phoenix Project
To get started, you need Elixir and Phoenix installed. If you haven’t done this yet, ensure you have Erlang/OTP and Elixir ready on your machine. Create a new Phoenix project with the following command:
# Create a new Phoenix project
mix phx.new my_app
cd my_app
# Setup the database
mix setup
Phoenix includes LiveView by default in modern versions (1.6+). We will create a simple “Counter” to see the mechanics in action.
Step 2: Building Your First LiveView (The Counter)
Let’s create a file at lib/my_app_web/live/counter_live.ex. This will be a simple page with a number and two buttons.
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
# The mount function initializes the state
def mount(_params, _session, socket) do
# We assign a starting value of 0 to our 'count' key
{:ok, assign(socket, count: 0)}
end
# The render function defines the UI
def render(assigns) do
~H"""
<div class="counter-container">
<h1>The Count is: <%= @count %></h1>
<button phx-click="increment" class="btn-up">+</button>
<button phx-click="decrement" class="btn-down">-</button>
</div>
"""
end
# Handle the "increment" event from the browser
def handle_event("increment", _params, socket) do
{:noreply, assign(socket, count: socket.assigns.count + 1)}
end
# Handle the "decrement" event from the browser
def handle_event("decrement", _params, socket) do
{:noreply, assign(socket, count: socket.assigns.count - 1)}
end
end
What’s happening here?
- mount/3: This is the entry point. We set the initial state
count: 0. - phx-click: This is a “binding.” When the user clicks the button, the browser sends an event named “increment” over the WebSocket to the server.
- handle_event/3: The server receives the event, updates the state using
assign/3, and LiveView automatically triggers a re-render of the@countvalue in the HTML.
To see this in action, add the route to your lib/my_app_web/router.ex:
scope "/", MyAppWeb do
pipe_through :browser
live "/counter", CounterLive
end
Step 3: A Real-World Scenario (Interactive Search)
Counters are great for learning, but real-world apps need to handle data and user input. Let’s build a real-time search feature that filters a list of “Products” as the user types.
First, imagine we have a simple Data module that returns a list of items:
defmodule MyApp.Catalog do
@products ["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape"]
def search_products(query) do
if query == "" do
@products
else
Enum.filter(@products, fn p ->
String.contains?(String.downcase(p), String.downcase(query))
end)
end
end
end
Now, let’s create our LiveView for searching:
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
alias MyApp.Catalog
def mount(_params, _session, socket) do
# Initialize with an empty query and the full list
{:ok, assign(socket, query: "", results: Catalog.search_products(""))}
end
def render(assigns) do
~H"""
<div>
<form phx-change="suggest" phx-submit="search">
<input type="text" name="q" value={@query} placeholder="Search products..." autocomplete="off" />
</form>
<ul>
<%= for item <- @results do %>
<li><%= item %></li>
<% end %>
</ul>
</div>
"""
end
# phx-change fires every time the user types a character
def handle_event("suggest", %{"q" => query}, socket) do
results = Catalog.search_products(query)
{:noreply, assign(socket, query: query, results: results)}
end
def handle_event("search", %{"q" => query}, socket) do
# Logic for a final submit (e.g., navigating to a detail page)
{:noreply, socket}
end
end
By using phx-change, we eliminate the need for complex AJAX calls or debouncing logic in JavaScript. LiveView handles the communication, and the UI updates as fast as the server can process the filter—which, in Elixir, is incredibly fast.
Step 4: Real-Time Updates with Phoenix PubSub
The examples above are “private” to the user. But what if we want to build a shared experience, like a notification system or a live dashboard? For this, we use Phoenix PubSub (Publish-Subscribe).
PubSub allows different processes (different users’ LiveViews) to talk to each other. When an event happens in one process, it can “broadcast” a message to a “topic.” Any other process “subscribed” to that topic will receive the message.
Example: A Live Shared Announcement
Let’s say we want a banner that updates for every single user currently on the site whenever an admin posts an announcement.
defmodule MyAppWeb.NotificationLive do
use MyAppWeb, :live_view
@topic "site_announcements"
def mount(_params, _session, socket) do
# Subscribe this specific process to the topic
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, @topic)
end
{:ok, assign(socket, announcement: "Welcome to our store!")}
end
# This function handles messages sent via PubSub
def handle_info(%{event: "new_announcement", payload: msg}, socket) do
{:noreply, assign(socket, announcement: msg)}
end
def render(assigns) do
~H"""
<div class="banner">
<strong>Notice:</strong> <%= @announcement %>
</div>
"""
end
end
To trigger this update from anywhere in your application (like an admin panel or a background job), you would run:
Phoenix.PubSub.broadcast(
MyApp.PubSub,
"site_announcements",
%{event: "new_announcement", payload: "Flash Sale! 50% off all items!"}
)
Instantly, every user’s browser would update to show the new banner. No page refresh, no polling, just instant real-time synchronization.
Step 5: When You Need JavaScript (LiveView Hooks)
While the goal of LiveView is to minimize JavaScript, there are times when you absolutely need it—for things like integrating a charting library (Chart.js), handling local animations, or accessing browser APIs (like Geolocation or the Clipboard).
LiveView provides Hooks for this. A Hook is a small JavaScript object that connects a specific DOM element to your LiveView process.
Example: A “Copy to Clipboard” Button
In your assets/js/app.js, define the hook:
let Hooks = {}
Hooks.CopyToClipboard = {
mounted() {
this.el.addEventListener("click", e => {
const text = this.el.dataset.clipboardText
navigator.clipboard.writeText(text).then(() => {
alert("Copied to clipboard!")
})
})
}
}
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
Now, in your LiveView template, you can apply this hook to any element:
<button id="copy-btn" phx-hook="CopyToClipboard" data-clipboard-text="https://myapp.com/invite">
Copy Invite Link
</button>
Note that we must provide a unique id. This allows LiveView and JavaScript to stay in sync even when the DOM changes.
Common Mistakes and How to Fix Them
Even though LiveView simplifies development, there are a few traps beginners often fall into:
-
Forgetting the ID on LiveComponents: If you use
live_component, you must provide a unique ID. Without it, LiveView cannot track the state of that specific component, leading to weird UI bugs. - Overloading the Socket: Don’t store large blobs of data (like a 5MB JSON object) directly in your assigns. This data is stored in the server’s memory for every user. Instead, store IDs and fetch the data you need, or use Temporary Assigns for large lists.
-
Blocking the Process: LiveView is a process. If you perform a very slow database query or an external API call inside
handle_event, the UI will feel “frozen” for that user until the task completes. UseTask.asyncor handle long-running tasks asynchronously. -
Not Handling Disconnects: WebSockets can drop (e.g., the user goes into a tunnel). LiveView handles reconnection automatically, but you should ensure your UI gracefully indicates when it’s “Connecting…” using the
.phx-loadingCSS classes.
SEO and Accessibility Best Practices
Because LiveView renders the initial state as static HTML, it is already “SEO-friendly” out of the box. However, you should follow these tips to maximize your rankings and usability:
- Use
live_sessionfor Navigation: Grouping routes in alive_sessionallows LiveView to navigate between pages without closing the WebSocket. This makes transitions feel instantaneous. - Update Page Titles Dynamically: Use
page_titleassigns to ensure that as users navigate your LiveView app, the browser tab title updates correctly for search engine indexing. - Aria Attributes: When using
phx-click, ensure you are using semantic HTML (like<button>) or addingrole="button"with appropriate ARIA labels so screen readers can interpret the interactivity.
Summary and Key Takeaways
Phoenix LiveView represents a paradigm shift in web development. By keeping the state on the server and using the power of Elixir’s concurrency, it allows developers to build real-time apps with a fraction of the code and complexity of traditional SPA frameworks.
The core lessons:
- LiveView is stateful: It’s a server process that stays alive as long as the user is on the page.
- Assigns are everything: The UI is a direct reflection of
socket.assigns. - PubSub is for sharing: Use it to broadcast updates to multiple users simultaneously.
- JavaScript is a fallback: Use Hooks only when the browser needs to perform a local action that Elixir can’t do.
Frequently Asked Questions (FAQ)
1. Is LiveView better than React?
It’s not necessarily “better,” but it is “different.” If you have a small team and want to move fast without managing two separate codebases (Frontend and Backend), LiveView is often a much more productive choice. However, for offline-first apps or highly complex local UI interactions (like a photo editor), React might still be preferred.
2. How does LiveView handle thousands of users?
Excellently. Each LiveView process is very lightweight (a few kilobytes of memory). A single modern server can often handle tens of thousands of simultaneous LiveView connections. Because LiveView only sends “diffs” over the wire, it is also very efficient with bandwidth.
3. Can I use LiveView with a mobile app?
LiveView is designed for the web. However, you can use LiveView Native, an emerging technology that allows you to use the same LiveView logic to power native iOS and Android applications. Alternatively, you can use a mobile-friendly web view.
4. What happens if the WebSocket connection is lost?
Phoenix LiveView has built-in heartbeats and reconnection logic. If a user loses their internet connection, the client-side script will attempt to reconnect. Once reconnected, the LiveView will “mount” again. You can use CSS classes like .phx-disconnected to show a warning to the user.
5. Do I need to know Erlang to use LiveView?
No. While Elixir runs on the Erlang VM, you can build extremely sophisticated applications using only Elixir and the Phoenix Framework. Knowing a bit about how the BEAM handles processes is helpful but not required to get started.
