LiveView

Phoenix LiveView — real-time server-rendered interactive views with mount, handle_event, handle_info, and HEEx templates

LiveView provides real-time server-rendered views without writing JavaScript. The server holds state and pushes diffs to the client over a persistent WebSocket connection.

Basic LiveView

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

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

  @impl true
  def handle_event("increment", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  @impl true
  def handle_event("decrement", _, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <h1>Count: <%= @count %></h1>
      <button phx-click="decrement">-</button>
      <button phx-click="increment">+</button>
    </div>
    """
  end
end

Mount

mount/3 runs when the LiveView connects. It receives params, session, and socket:

@impl true
def mount(%{"id" => id}, _session, socket) do
  if connected?(socket) do
    # Only runs on WebSocket connect, not initial HTTP render
    Phoenix.PubSub.subscribe(MyApp.PubSub, "posts:#{id}")
  end

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

# Redirect on mount
def mount(_params, _session, socket) do
  {:ok, redirect(socket, to: ~p"/login")}
end

# Force disconnect
def mount(_params, _session, socket) do
  {:error, :unauthorized}
end

handle_event

Handle client-side DOM events:

@impl true
def handle_event("save", %{"post" => post_params}, socket) do
  case Blog.create_post(post_params) do
    {:ok, post} ->
      {:noreply,
        socket
        |> put_flash(:info, "Post created!")
        |> push_navigate(to: ~p"/posts/#{post}")}

    {:error, changeset} ->
      {:noreply, assign(socket, changeset: changeset)}
  end
end

# With JS commands
def handle_event("delete", %{"id" => id}, socket) do
  {:ok, _} = Blog.delete_post(Blog.get_post!(id))
  {:noreply, stream(socket, :posts, Blog.list_posts(), reset: true)}
end

handle_info

Handle Elixir messages (from PubSub, Process.send, etc.):

@impl true
def handle_info({:post_updated, post}, socket) do
  {:noreply, assign(socket, post: post)}
end

# Usage: broadcast from a context
def update_post(post, attrs) do
  {:ok, post} = Repo.update(Post.changeset(post, attrs))
  Phoenix.PubSub.broadcast(MyApp.PubSub, "posts:#{post.id}", {:post_updated, post})
  {:ok, post}
end

Assigns

# Set one or more assigns
assign(socket, title: "Home", user: user)

# Update an assign with a function
update(socket, :count, &(&1 + 1))

# assign_new — set only if not present (useful in layouts)
assign_new(socket, :page_title, fn -> "Default Title" end)

# Access in templates
@title
@user.name

Binding reference

Attribute Event Description
phx-click "increment" Click event
phx-submit "save" Form submission
phx-change "validate" Form/input change
phx-blur "lost_focus" Input loses focus
phx-focus "gained_focus" Input gains focus
phx-keydown "keydown" Key pressed
phx-keyup "keyup" Key released
phx-window-keydown "keydown" Global keydown listener

Event parameters

<!-- Pass value -->
<button phx-click="delete" phx-value-id="42">Delete</button>

<!-- Pass multiple values -->
<div phx-click="select" phx-value-type="post" phx-value-id="42">

<!-- Key filters -->
<div phx-window-keydown="close" phx-key="Escape">

Debounce and throttle

<!-- Debounce: wait 300ms after last event -->
<input phx-change="search" phx-debounce="300" />

<!-- Debounce until blur -->
<input phx-change="validate" phx-debounce="blur" />

<!-- Throttle: fire at most every 300ms -->
<input phx-keyup="suggest" phx-throttle="300" />

Streams

Streams efficiently handle large collections by tracking only changes:

# In mount
{:ok, stream(socket, :posts, Blog.list_posts())}

# Append a new item
{:noreply, stream_insert(socket, :posts, new_post)}

# Prepend
{:noreply, stream_insert(socket, :posts, new_post, at: 0)}

# Update an item
{:noreply, stream_insert(socket, :posts, updated_post)}

# Delete an item
{:noreply, stream_delete(socket, :posts, post)}

# Reset the whole stream
{:noreply, stream(socket, :posts, Blog.list_posts(), reset: true)}

Stream template

<ul id="posts" phx-update="stream">
  <li :for={{id, post} <- @streams.posts} id={id}>
    <%= post.title %>
  </li>
</ul>
# Push a patch (same LiveView, params change)
{:noreply, push_patch(socket, to: ~p"/posts?page=#{page}")}

# Navigate to a different LiveView
{:noreply, push_navigate(socket, to: ~p"/posts/#{post.id}")}

# Redirect (non-LiveView)
{:noreply, redirect(socket, to: ~p"/login")}

# External redirect
{:noreply, redirect(socket, external: "https://example.com")}

LiveView navigation vs redirect

Function Use when
push_patch Change URL params within the same LiveView
push_navigate Go to a different LiveView (preserves session)
redirect Go to a non-LiveView page
redirect external: Go to an external URL

LiveView life cycle

HTTP request → mount/3 → render/1 → HTML response
WebSocket connect → mount/3 → render/1 → DOM diff
Event → handle_event/3 → render/1 → DOM diff
Message → handle_info/2 → render/1 → DOM diff
Params change → handle_params/3 → render/1 → DOM diff

handle_params

React to URL parameter changes (for push_patch and browser navigation):

@impl true
def handle_params(params, _url, socket) do
  page = String.to_integer(params["page"] || "1")
  {:noreply, assign(socket, page: page, posts: Blog.list_posts(page: page))}
end

JS commands

Use phx-hook or the JS module for client-side interactions:

<!-- Toggle a class -->
<button phx-click={JS.toggle_class("hidden", to: "#details")}>Toggle</button>

<!-- Add/remove classes -->
<button phx-click={JS.add_class("active", to: "#tab")}>Activate</button>
<button phx-click={JS.remove_class("active", to: "#tab")}>Deactivate</button>

<!-- Show/hide elements -->
<button phx-click={JS.show(to: "#modal")}>Open</button>
<button phx-click={JS.hide(to: "#modal")}>Close</button>

<!-- Dispatch a custom event -->
<button phx-click={JS.dispatch("my_event", to: "#target")}>Go</button>

<!-- Focus an input -->
<button phx-click={JS.focus(to: "#search")}>Focus</button>

<!-- Chain commands -->
<button phx-click={JS.toggle_class("hidden", to: "#details") |> JS.focus(to: "#search")}>
  Toggle & Focus
</button>

JavaScript hooks

For complex client-side behavior, use phx-hook:

// assets/js/hooks/clock.js
export const ClockHook = {
  mounted() {
    this.interval = setInterval(() => {
      this.pushEvent("tick", {});
    }, 1000);
  },
  destroyed() {
    clearInterval(this.interval);
  }
};
# In the LiveView template
<div id="clock" phx-hook="ClockHook">
  <%= @time %>
</div>

Temporary assigns

Mark assigns that reset to a default value after each render, reducing payload size:

def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:page_title, "Posts")
    |> assign(:posts, Blog.list_posts())

  {:ok, socket, temporary_assigns: [posts: []]}
end