7 min read
On this page

LiveView Fundamentals

Phoenix LiveView is the reason a lot of teams pick Elixir in 2024 instead of reaching for the usual React-plus-API split. You write server-rendered HTML, Phoenix opens a WebSocket, and the server pushes diffs to the browser whenever your state changes. The page feels like a SPA. You never wrote a line of JavaScript to make that happen.

This is not "no JavaScript ever" — there are escape hatches when you need them — but for the dashboard, admin panel, multi-step form, real-time table, and 80% of CRUD-with-interactions work that swallows engineering time at most companies, LiveView replaces the entire frontend stack with a few hundred lines of Elixir.

What LiveView Actually Is

A LiveView is a stateful process. When a browser hits the page, Phoenix renders the initial HTML over HTTP (so it's fast and SEO-friendly), then the JavaScript client connects a WebSocket back to the server. Behind that socket, a GenServer-like process holds your assigns (the state) and re-runs render/1 whenever something changes. Phoenix computes a minimal diff and ships only the parts that actually changed.

Compare that to a typical React app: the browser downloads a megabyte of JS, runs a virtual DOM, and you build a JSON API on the side to feed it. Two codebases, two type systems, two deployment stories. With LiveView you have one codebase, and the "API" is just function calls inside your app.

A Minimal LiveView

Here's a counter, the "hello world" of LiveView. It demonstrates the three callbacks you'll write thousands of times: mount/3, handle_event/3, and render/1.

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("decrement", _params, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def render(assigns) do
    ~H"""
    <div class="counter">
      <button phx-click="decrement">-</button>
      <span><%= @count %></span>
      <button phx-click="increment">+</button>
    </div>
    """
  end
end

Mount that in your router with live "/counter", CounterLive and you have a working interactive page. No fetch calls, no JSON serialization, no client-side state management.

The Lifecycle

mount/3 runs twice: once during the initial HTTP render (so the page works without JavaScript and has good first-paint performance), and once again when the WebSocket connects. You can check connected?(socket) to skip expensive work on the first mount, or to subscribe to PubSub topics only when there's a live socket.

def mount(%{"id" => id}, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{id}")
  end

  post = Posts.get_post!(id)
  {:ok, assign(socket, post: post)}
end

handle_event/3 is your equivalent of an onClick handler. The first argument is the event name (whatever you put in phx-click, phx-submit, etc.), the second is a params map (form fields, target values, anything sent from the DOM), and the third is the socket. Return {:noreply, socket} to update state, or {:reply, payload, socket} to send data back to a JS hook.

render/1 returns a ~H sigil — Phoenix's HEEx template language. It's HTML with <%= %> for interpolation, <.component /> for function components, and compile-time validation that catches typos in your assigns.

Why It Beats the SPA Default

The pitch isn't "LiveView is faster than React." Sometimes it is, sometimes a hand-tuned React app wins. The pitch is operational: one codebase to deploy, one place to add a feature, no API contract to keep in sync between two teams.

Bleacher Report rebuilt their live scoring pages on LiveView and reported a roughly 8x reduction in servers needed. That number is specific to their workload (lots of concurrent connections, modest per-user state), but it captures the shape of LiveView's strength: cheap processes, cheap WebSocket connections, and pushing only diffs.

Discord doesn't use LiveView (they use Elixir on the backend with a native client), but the same BEAM properties — millions of lightweight processes, fast message passing — are what make LiveView's per-user process model work without falling over.

For a small team building an internal admin tool, a customer-facing dashboard, or any app where "real-time" is a feature rather than a marketing word, LiveView removes an entire category of complexity.

Comparing With React/Vue

The React mental model: state lives in the client, the server has an API, you wire them together with hooks and fetch calls. You think in terms of components, props, effects, and re-renders.

The LiveView mental model: state lives in a server process, the DOM is a projection of that state, events flow from DOM to server and diffs flow back. You think in terms of assigns and events.

# React: useState + useEffect + fetch
# LiveView: assign + handle_event + Repo.get
def handle_event("load_user", %{"id" => id}, socket) do
  user = Accounts.get_user!(id)
  {:noreply, assign(socket, user: user)}
end

That's the whole "load data on click" pattern. No loading state machine, no error boundaries, no race conditions from out-of-order responses. If Accounts.get_user!/1 raises, LiveView crashes the process and reconnects — the user sees a fresh page, not a half-broken UI.

The tradeoffs are real. LiveView assumes a persistent connection, so flaky networks degrade more than a fully client-side app. Animations and complex client interactions still need JS (use hooks). And you pay for state on the server, so a million idle users cost more memory than a million idle React clients. For most apps, none of these matter.

Assigns and Re-rendering

assign/2 and assign/3 put values onto the socket. When render/1 runs, it reads those assigns. Phoenix tracks which assigns each part of the template uses, so changing @count only re-renders the span containing it — not the whole page.

socket
|> assign(:user, user)
|> assign(:posts, posts)
|> assign(:loading, false)

There's also assign_new/3, which only assigns if the key isn't already set. Useful for parent-child LiveViews where the parent might pass data down.

def mount(_params, _session, socket) do
  {:ok, assign_new(socket, :current_user, fn -> nil end)}
end

handle_params and Live Navigation

handle_params/3 runs after mount/3 and any time the URL changes through push_patch/2 or <.link patch={...}>. This is what powers in-page navigation that doesn't tear down the LiveView process — you change the URL, the LiveView updates its assigns based on the new params, and the same socket keeps serving.

def mount(_params, _session, socket) do
  {:ok, assign(socket, posts: Posts.list_posts())}
end

def handle_params(%{"id" => id}, _uri, socket) do
  {:noreply, assign(socket, selected_post: Posts.get_post!(id))}
end

def handle_params(_params, _uri, socket) do
  {:noreply, assign(socket, selected_post: nil)}
end

Compare this with <.link navigate={...}>, which tears down the current LiveView and mounts a new one. Patch is for "I'm staying on the same page, just changing the view." Navigate is for "I'm going somewhere else, give me a fresh process." A list-detail page where clicking a row reveals it in a side panel is patch territory; clicking through to a different feature is navigate.

handle_info: Out-of-Band Messages

handle_info/2 is the BEAM's standard "another process sent me a message" callback, and LiveView exposes it directly. PubSub subscriptions deliver here. Timers via Process.send_after/3 or :timer.send_interval/3 deliver here. Any process that has the LiveView's pid can send/2 it a message.

def mount(_params, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "alerts")
    :timer.send_interval(30_000, self(), :refresh)
  end

  {:ok, assign(socket, alerts: [], last_refresh: nil)}
end

def handle_info({:alert, alert}, socket) do
  {:noreply, update(socket, :alerts, &[alert | &1])}
end

def handle_info(:refresh, socket) do
  {:noreply, assign(socket, last_refresh: DateTime.utc_now())}
end

This is the seam between LiveView and the rest of your Elixir app. A background job finishes, broadcasts on PubSub, and every LiveView listening on that topic updates in real time. There's no polling, no HTTP round-trip, no "did the server tell me yet" loop.

Function Components

Templates get unwieldy fast if every page is one giant ~H block. Function components let you carve out reusable pieces. They're plain functions that take an assigns map and return rendered HEEx.

defmodule MyAppWeb.UIComponents do
  use Phoenix.Component

  attr :status, :atom, required: true
  slot :inner_block, required: true

  def badge(assigns) do
    ~H"""
    <span class={["badge", "badge-#{@status}"]}>
      <%= render_slot(@inner_block) %>
    </span>
    """
  end
end

In the consuming template:

~H"""
<.badge status={:active}>Online</.badge>
<.badge status={:warning}>Idle</.badge>
"""

The attr and slot declarations give you compile-time validation — pass an unknown attribute or forget a required one and the compiler tells you. This is closer to TypeScript's prop checking than to React's runtime PropTypes.

When To Reach For Something Else

LiveView is wrong when you need offline support, when your app is mostly static content with a tiny interactive sliver, or when you have a native mobile app that needs the same backend (build a real API instead). It's also wrong if your team has zero Elixir experience and you're shipping next month — the learning curve is real, even if the destination is good.

It's right when you'd otherwise build a Rails-plus-React or Django-plus-Vue app, when real-time updates are part of the product, and when the team is small enough that maintaining two codebases is a meaningful cost.

Common Pitfalls

Putting expensive work in mount/3 without the connected?(socket) guard. The function runs twice; doing two database round-trips on every page load adds up.

Forgetting that render/1 is pure. If you need a side effect (logging, analytics), do it in handle_event/3 or handle_info/2, not in the template.

Holding huge data structures in assigns. Every assign change causes Phoenix to compute a diff. A 50,000-row list in @rows will make every interaction slow. Use stream/3 (covered in the next section) for large collections.

Reaching for JavaScript hooks before trying Phoenix.LiveView.JS. Most "I need JS for this" cases (showing a modal, toggling a class, dispatching focus) are one-liners with the JS module.

Key Takeaways

LiveView replaces the SPA-plus-API stack with a single Elixir module per page, where state lives on the server and the DOM updates over a WebSocket. The lifecycle is small: mount/3 to set up, handle_event/3 to react, render/1 to project state to HTML. It's the right default for interactive web apps where real-time matters and the team is small. It's the wrong choice for offline-first apps or apps where the web is one of several clients hitting a shared API.