Elements vs Phoenix LiveView

Markdown

Phoenix LiveView is an open-source library, released under the MIT License, for building interactive pages in Phoenix, a web framework written in Elixir that runs on the Erlang virtual machine. It ships by default in new Phoenix apps (README). Its docs define it in one line: "LiveViews are processes that receive events, update their state, and render updates to a page as diffs" (welcome). Each LiveView is first rendered as a regular HTTP response. The JavaScript client then connects over a WebSocket, and mount/3 runs again "inside a spawned LiveView process" on the server, which holds the page's state as socket assigns for as long as the connection lasts (Phoenix.LiveView). A click on an element marked phx-click sends an event over the socket to a handle_event callback on the server; the callback updates the assigns, and LiveView re-renders the template and sends the changed parts to the browser as diffs. Templates are written in HEEx, an HTML extension of Elixir's EEx. For UI state that should not reach the server, such as opening a dropdown, LiveView provides JS commands, "which execute directly on the client without reaching the server", and custom client-side code goes in JavaScript hooks attached with phx-hook (JS interop).

That puts LiveView in the server-rendered, stateful UI category: the server owns the page while it is open, and the browser displays what the server sends.

Elements plays in the same territory, interactive and real-time web apps, on the other model. Elements is an integrated app environment, built for people and their agents. At its center is a project server that runs while you work, and around it the same system comes with a package installer, a test runner, a bundled Postgres database and a deploy command for any Ubuntu server you reach over SSH. It ships with a default framework, @elements/app, whose pages are written in Elements HTML, a language extension to HTML plus a runtime that runs in the browser. Elements is built on TypeScript and HTML, and its apps run on Node.js.

In Elements, each page arrives as complete HTML, and the same page is fully reactive in the browser, with no compromise. Event handlers run in the browser, so the screen moves the moment a user acts, with no round trip. When the page needs the server, through an @rpc call, a channel message or a LiveTable row change, the reply is data, and the browser renders it. The server never sends HTML after the first response. Elements' LiveView type is unrelated to Phoenix LiveView: it is a view of a LiveTable, a set of database rows the browser holds and keeps in sync.

Two Models, Side by Side

Elements Phoenix LiveView
First response Complete HTML, rendered on the server Complete HTML, rendered on the server
Where page state lives after load In the browser In a server process, one per connected LiveView
Where an event handler runs In the browser On the server, in handle_event
What the server sends after load Data: @rpc replies, channel messages, row changes HTML diffs
Rendering updates The browser runtime patches the DOM The server re-renders, the client applies the diff
Writes before the server answers Optimistic by default for LiveTable writes Loading classes, JS commands or hooks
Client-side code TypeScript in the same .ehtml file as the markup JS commands, or JavaScript hooks
What LiveView means A LiveTable view: rows held in the browser A server process that renders a page
Languages TypeScript and HTML, server and browser Elixir and HEEx on the server, JavaScript for hooks

Elements LiveView Is a Set of Rows, Not a Server Process

Elements' LiveView<T> and Phoenix LiveView are unrelated, and they work differently. In Phoenix, a LiveView is a server process that renders a page. In Elements, LiveView<T> is one request's rows from a LiveTable. A route opens it with table.view(), the rows render into the first response, and from then on the browser holds them. Inserts, updates and deletes from any browser arrive as row data, and each watching page patches the affected row in place. The Elements manual puts it directly: "A LiveView is data: an iterable of rows, read like an array, that templates render with e:for" (LiveTable).

The same holds across Elements HTML. An .ehtml file is standard HTML plus TypeScript expressions in {...}, typed templates declared with template constructors, and a few directives: e:if, e:for and e:switch (html). The Elements compiler type checks it with the rest of the app and emits JavaScript, and Elements ships its runtime to the browser with the page. Every page renders on the server, so the first response is complete HTML; the runtime then attaches, and from there event handlers run in the browser, inputs bind two-way, and the runtime patches the DOM in place when data changes. No UI is streamed from the server.

Event Handlers Run in the Browser

LiveView's own first example is a thermostat with a button that raises the temperature. In Phoenix LiveView, the click is an event the server handles (welcome):

lib/my_app_web/live/thermostat_live.exPhoenix LiveView
def render(assigns) do ~H""" Current temperature: {@temperature}°F <button phx-click="inc_temperature">+</button> """ end def handle_event("inc_temperature", _params, socket) do {:noreply, update(socket, :temperature, &(&1 + 1))} end

The button sends "inc_temperature" over the socket, the server process updates its state, re-renders, and sends the diff back. The number changes when that reply lands.

In Elements, the click runs in the browser and the page repaints at once:

app/pages/thermostat/template.ehtmlElements
<html ( private temperature: number = 70, )> <p>Current temperature: {temperature}°F</p> <button onclick={() => temperature++}>+</button> </html>

The page still arrives from the server with 70 already in it. After that, the state belongs to the page, and changing it is a function call in the browser.

Typing, Filtering and Toggling Need No Round Trip

LiveView's docs describe what happens when every input event goes to the server. In their form example, "because we are using server-side rendering, we are debouncing/throttling form changes to the server," and a server reply to an earlier keystroke can arrive after the user has typed more, an example of "how client and server state can evolve and differ for periods of times, due to the latency (distance) between them" (syncing changes). It also ships a latency simulator to "emulate how slow clients will interact with your application" (README), and a phx-loading class for when "the view is not connected to the server" (syncing changes).

In Elements, those events never leave the browser. A bound input updates its field as the user types, and anything that reads the field repaints with it. Here is a task list that filters as you type, toggles a task with a checkbox and adds new ones. The tasks are a LiveTable, declared once on the server:

app/shared/services/tasks.tsElements
import { LiveTable } from "@elements/app"; export interface Task { id: string; title: string; done: boolean; createdAt: Date; } export let tasks = new LiveTable<Task>();

The route passes the page a view of the table, new html({ tasks: tasks.view() }), and the page reads and writes it:

app/pages/tasks/template.ehtmlElements
import { LiveView } from "@elements/app"; import { Task } from "#app/shared/services/tasks"; function onAdd(tasks: LiveView<Task>, form: { title: string }) { tasks.insert({ title: form.title, done: false, createdAt: new Date(), }, () => form.title = ""); } function onToggle(tasks: LiveView<Task>, task: Task) { tasks.update({ ...task, done: !task.done, }); } <html ( tasks: LiveView<Task>, private search: string = "", private form: { title: string } = { title: "" }, )> <input value={search} placeholder="filter"> <ul> <li e:for={task of tasks.filter((t) => t.title.includes(search))}> <input type="checkbox" checked={task.done} onchange={() => onToggle(tasks, task)}> {task.title} </li> </ul> <form onsubmit={() => onAdd(tasks, form)}> <input value={form.title}> <button>add</button> </form> </html>

The filter is an array method over rows the browser already holds, so each keystroke repaints the list locally, with nothing to debounce. The page arrives with the tasks in it, and the compiler checks every field the template reads against Task.

Writes Paint Before the Server Answers

An optimistic write shows its result on screen before the server confirms it. In LiveView, the server renders every result, so the client fills the wait. Each element that pushes an event gets a loading class such as phx-click-loading, kept "until an acknowledgement is received on the client for the pushed event," and JS commands can hide or restyle elements in the meantime; for anything more, a hook takes control of the element in JavaScript (syncing changes). Those change how the page looks while it waits; the server's new state still arrives as a diff.

In Elements, the new state is on screen at once. insert, update and delete on a LiveView apply in the browser immediately, the server saves the change, and a failed write reverts it on screen (mutations). In the task list above, a new task and a ticked checkbox appear the moment the user acts, and every other browser watching the table patches the same row as the change arrives. The same insert runs on the server, so a test exercises the write the page makes.

The Server Keeps Subscriptions, Not Page State

LiveView's state lives on the server by design: "Socket assigns are stateful values kept on the server side," which its docs contrast with "the common stateless HTTP pattern" (Phoenix.LiveView). Each connected LiveView is a process holding that state, and the docs describe the costs that follow. Endpoint configuration includes hibernate_after, the idle time "before compressing its own memory and state." Streams exist to manage "large collections on the client without keeping the resources on the server." For each LiveView in the root of a template, mount/3 "is invoked twice: once to do the initial page load and again to establish the live socket." And when a deploy or a crash drops the connection, "your LiveView may still have state that will be lost in this transition" (deployments).

In Elements, a route runs once per request and renders the page. After that, the page's state, a half-typed form, a filter, the rows on screen, lives in the browser tab. What the server keeps for an open page is its subscriptions: the LiveTable views and channel listeners the page holds, which tell the server which row changes and messages to forward (channel).

Typed Function Calls, End to End

In LiveView, the template and the server meet at a string: phx-click="inc_temperature" in HEEx names an event, and a handle_event("inc_temperature", ...) clause on the server matches it (bindings). Client-side code beyond JS commands is a JavaScript hook object, written in an asset file or colocated in a <script> tag in the template, and attached to an element by name with phx-hook (JS interop).

In Elements, every boundary is a TypeScript function call. An event handler is a function in the same .ehtml file as the markup. A call to the server is an @rpc function with typed parameters and a typed return value; the compiler turns the browser call into a network request and keeps the function body out of the browser bundle (rpc). Calling sql(), tx() or session.login() from code that runs in the browser is a compile error at the call site, so a query cannot reach the browser by accident. A misspelled handler, a wrong argument type or a field the template reads that does not exist is a build error with a file and a line.

For an Agent, the Build Is the Feedback

What an AI agent such as Claude Code, Codex or Cursor needs most from its tools is correction: learning what it got wrong the moment it gets it wrong. A framework is worth its abstractions at the plumbing every app needs, and Elements ships that plumbing: routing, @rpc for typed server calls, sessions, sql() and the database driver, LiveTable for live rows. Past it, each further concept (a process lifecycle, a set of phx- bindings, a hook API) is one more thing an agent has to learn and can get wrong. Elements stops at the plumbing: a page is HTML and CSS, behavior is TypeScript, and data is SQL.

Elements puts its engineering into correction instead. The project server holds the build graph and build state in memory; on every save, the build state is updated almost instantly, and it builds in milliseconds and answers agents and humans in microseconds (build). elements build -json returns every build error in one answer: type checking across pages, templates and server code, failing tests, migration errors and program analysis. Tests run inside the build against the real Postgres, each in a transaction that rolls back (tests), and a release does not go live until they pass. Elements also ships with a manual baked into the binary.

One Command to Deploy

Phoenix's deployment guide has you load secrets from environment variables, compile assets, run migrations and start the server, and it covers Elixir releases, a sample Dockerfile and hosting platforms as separate approaches (deployment). elements deploy ships an Elements app over SSH to any Ubuntu server you reach over SSH, from any provider. It sets up the machine, runs the tests there, applies pending migrations once, and swaps in the new release in one step, leaving the previous release serving if any step fails (what a deploy does). A built-in load balancer issues and renews TLS certificates. The bundled Postgres suits a prototype or a single-server app; for several machines, point DB_HOST at one Postgres, on a server you run or with a managed provider (setup). Only changed files transfer, so after a first deploy of a few seconds, later deploys often land in under a second.

Questions

Is the Elements LiveView type the same as Phoenix LiveView?

No, the two are unrelated. In Phoenix, a LiveView is a server process that holds a page's state and sends HTML diffs over a WebSocket. In Elements, LiveView<T> is a view of a LiveTable: one request's database rows, rendered into the first response, then held in the browser and kept in sync as row data. The server sends rows and row changes, never HTML.

Does Elements render UI on the server and push HTML updates like Phoenix LiveView?

No. Elements renders each page on the server once, as complete HTML, and the Elements runtime makes that page reactive in the browser. After the first response, the server never renders an update or sends HTML. Calls to the server, through @rpc functions, channels and LiveTable, return data, and the browser renders it.

Are Elements pages server-side rendered?

Yes, always. Every page's first response is complete HTML, so search engines and first-time visitors see content immediately. The same page is then fully reactive in the browser: event handlers run there, inputs bind two-way, and the runtime patches the DOM in place when data changes.

Do Elements event handlers run on the server?

No. Event handlers run in the browser, so a click, a keystroke or a filter repaints the page with no round trip. A handler reaches the server only when it calls an @rpc function or writes through a LiveView, and LiveView writes appear on screen before the server confirms them.

Does Elements use WebSockets like Phoenix LiveView?

Yes, to carry data. @rpc calls, channel messages and LiveTable row changes travel over a WebSocket. Phoenix LiveView uses its socket to send browser events to a server process and HTML diffs back.

Can I write an Elements app in Elixir?

No. Elements is built on TypeScript and HTML, and its apps run on Node.js. Server code, browser code and Elements HTML templates are checked together by one build.