Introduction

A lot of interactive UI doesn’t really need a client-side framework. Phoenix LiveView leans on that idea: it keeps state on the server, pushes minimal HTML diffs over a WebSocket, and lets you build interactive pages without writing JavaScript yourself.

Let’s build a small task manager to see the whole loop.

How LiveView works

Instead of shipping logic to the browser, the server does the work:

  • render the initial HTML on the server,
  • open a WebSocket automatically,
  • send user events (clicks, form submits) to the server,
  • update state there,
  • push back only the HTML that changed.

The browser stays thin, and there’s a single source of truth on the server.

A task manager LiveView

Here’s the core module (task_live.ex):

defmodule TaskManagerWeb.TaskLive do
  use Phoenix.LiveView

  alias TaskManager.Tasks

  def mount(_params, _session, socket) do
    tasks = Tasks.list_tasks()
    {:ok, assign(socket, tasks: tasks, new_task: "")}
  end

  def render(assigns) do
    ~H"""
    <div>
      <h2>My Tasks</h2>
      <ul>
        <li :for={task <- @tasks}>
          {task.title}
          <button phx-click="delete" phx-value-id={task.id}>Delete</button>
        </li>
      </ul>

      <form phx-submit="add_task">
        <input type="text" name="title" value={@new_task} placeholder="New task" />
        <button type="submit">Add</button>
      </form>
    </div>
    """
  end

  def handle_event("add_task", %{"title" => title}, socket) do
    {:ok, task} = Tasks.create_task(%{title: title})
    {:noreply, update(socket, :tasks, &[task | &1])}
  end

  def handle_event("delete", %{"id" => id}, socket) do
    task = Tasks.get_task!(id)
    {:ok, _} = Tasks.delete_task(task)
    tasks = Enum.reject(socket.assigns.tasks, &(&1.id == task.id))
    {:noreply, assign(socket, tasks: tasks)}
  end
end

The one detail worth stressing: ~H is HEEx, not EEx. Inside HTML attributes you interpolate with curly braces (phx-value-id={task.id}, value={@new_task}), not <%= %> — the old EEx form doesn’t compile inside an attribute. Loops read best as a :for attribute on the element rather than a <%= for %> block. Get those two right and the template just works.

Routing to it

In router.ex:

scope "/", TaskManagerWeb do
  pipe_through :browser

  live "/tasks", TaskLive
end

Visit /tasks and you have an interactive, real-time task list, with no custom JavaScript in sight.

What you get, and what to watch

The appeal is a single source of truth: state, events and rendering all live on the server, so there’s no client/server state to keep in sync and issues are easy to trace to one place.

A few things to keep in mind:

  • Connections are stateful. LiveView holds a WebSocket open per user. Elixir handles that well, but it’s a real resource to plan for.
  • Latency shows. Because rendering is server-side, round-trip latency affects how snappy the UI feels — host close to your users.
  • Not for offline. If the app must work offline, LiveView alone won’t cut it.

Wrapping up

LiveView is a good fit when the interactivity is real but modest: forms, lists, dashboards, live updates. You write Elixir, keep one source of truth, and skip the frontend build entirely. When you genuinely need rich offline or client-heavy behaviour, reach for a JS framework — but for a lot of pages, LiveView is less machinery for the same result.

Further reading

Have comments or want to discuss this topic?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht