Live View
Build reactive, real-time interfaces without writing JavaScript. Live View renders components on the server and pushes updates over WebSockets — the client morphs the DOM in place, so focus and widget state survive every update.
See it in action
A Field Desk tutorial with the live widget on the page — nested components, uploads, tabs, hooks.
How It Works
Live View is a server-rendered component system that maintains state on the server. When users interact with the page, events are sent over WebSockets, the server updates the component state, re-renders the template, and sends back a compact patch with just the changed lines. The client then morphs the live region's DOM to match — nodes are updated in place, not replaced (see How Patches Reach the DOM).
Creating a Live View Component
Step 1: Create the Template
Create a template in app/views/live/ (.html.slv; .slv, .sliv, and .html.erb also resolve). State is interpolated with standard ERB tags.
<div class="counter-component">
<h2>Count: <%= count %></h2>
<button soli-click="decrement">-</button>
<button soli-click="increment">+</button>
</div>
Step 2: Register the Route
Register your Live View component in config/routes.sl using the router_live function.
# Register LiveView components
router_live("counter", "live#counter");
router_live("metrics", "live#metrics");
Step 3: Create the Controller
Create a controller that handles events. The handler receives an event hash with event (name), params, and state. Return the new state as a hash.
# Counter component handler
def counter(event_data: Any) -> Any {
event = event_data["event"] # Event name (e.g., "increment")
state = event_data["state"] # Current component state
count = state["count"] || 0
if event == "increment"
{ "count": count + 1 }
elsif event == "decrement"
{ "count": count - 1 }
else
state # Unchanged for unknown events
end
}
Available Directives
soli-click
Triggers on element click. <button soli-click="save">
soli-submit
Triggers on form submission. Named fields become params. <form soli-submit="create">
soli-change
Triggers on input value change. <select soli-change="filter">
soli-keydown
Triggers on key press. <input soli-keydown="search">
soli-keyup
Triggers on key release. <input soli-keyup="validate">
soli-focus
Triggers when element gains focus. <input soli-focus="highlight">
soli-blur
Triggers when element loses focus. <input soli-blur="validate">
soli-value-*
Binds input value to state. <input soli-value-name>
soli-target
Specifies target component for updates. <div soli-target="results">
soli-window-keydown
Window-level key press. Put on any element in the view. <div soli-window-keydown="hotkey">
soli-window-keyup
Window-level key release. <div soli-window-keyup="hotkey_up">
soli-href
Full-page navigation (leaves the socket). Meta/ctrl-click still opens a new tab. <a soli-href="/posts">
soli-patch
In-socket navigation: updates the URL and sends event == "patch" with path/query. <a soli-patch="/posts/1?tab=comments">
soli-live
Swap the page-root LiveView to another component socket without a full load. Optional href updates the URL. <a soli-live="/live/socket/comments" href="/comments">
soli-debounce
Delay the event by N milliseconds; only the last fire is sent. <input soli-change="search" soli-debounce="300">
soli-throttle
Send at most once every N milliseconds. <input type="range" soli-change="volume" soli-throttle="100">
soli-click-away
Fires when a click lands outside this element. <div soli-click-away="close">
soli-disable-with
Swap the label and disable the control while the event is in flight. Also adds soli-loading / soli-click-loading. <button soli-click="save" soli-disable-with="Saving…">
soli-hook
Attach a named client hook. Register it on SoliLiveView.hooks before connect. <div soli-hook="Chart" soli-ignore>
soli-upload
File input: POST /live/upload, then fire the handler with file/files (base64 data, same shape as attach_upload). <input type="file" soli-upload="attached">
soli-upload-max
Per-file size cap in bytes (default 8 MiB). <input soli-upload="attached" soli-upload-max="2000000">
Two more attributes control how the DOM morph treats an element (they trigger nothing on the server):
soli-key
Identity for list items: a reordered element with the same key keeps its DOM node (and its focus/widget state) instead of being rebuilt. Falls back to id. <li soli-key="<%= item["id"] %>">
soli-ignore
Marks a subtree as client-owned: its attributes stay server-driven, but its children are never touched by a patch. Put Alpine islands, charts, and other widgets here. <div soli-ignore>
State Management
State is stored on the server, and each state key is available in the template as a plain ERB variable. The server maintains state between events.
<!-- Simple variable -->
<span>Hello, <%= username %></span>
<!-- Conditional rendering -->
<% if logged_in %>
<a href="/logout">Sign Out</a>
<% else %>
<a href="/login">Sign In</a>
<% end %>
<!-- Iteration -->
<% for item in items %>
<li><%= item["name"] %></li>
<% end %>
Client Setup
The client is served by the soli binary itself at /live/client.js — no file to vendor, always in sync with the server's patch protocol. Include it only on pages that mount a live component — it is ~7 KB gzipped (~30 KB raw) and auto-connects every [data-liveview-url] element on DOMContentLoaded.
<!-- Include the Live View client (built into the binary, ~7 KB gzipped) -->
<script src="/live/client.js"></script>
<!-- Mount a Live View component (auto-connects on page load) -->
<div data-live-root data-liveview-url="/live/socket/counter"></div>
By default the instance key is session:component. Two tabs of the same browser session share that instance; a click in one patches the others. Put data-live-room="name" on the mount for a public board that every visitor joins — the client sends ?room=name and the server keys room:name:component instead of the session cookie. That also covers WebSocket upgrades that arrive with no Cookie header (each would otherwise mint a unique sess- id and look like a different session).
<div data-live-root data-live-room="field-desk" data-liveview-url="/live/socket/desk"></div>
Rooms are opt-in per component. Declare them in config/routes.sl:
live_rooms("desk") # one component
live_rooms("desk", "board") # or several
An undeclared component ignores ?room= and gets its ordinary per-session instance. This is a security boundary, not bookkeeping: a room instance is shared by everyone who names it — same state, same rendered HTML, same events — so without the declaration anyone who guessed a room name received a component's live markup and the right to drive it.
To control connection timing yourself (e.g. after a client-side navigation that doesn't re-fire DOMContentLoaded), add data-liveview-manual to skip auto-connect and call live() by hand:
<div data-live-root data-liveview-manual data-liveview-url="/live/socket/counter"></div>
<script>
window.live("wss://example.com/live/socket/counter", { rootElement: document.querySelector("[data-live-root]") });
</script>
How Patches Reach the DOM
When state changes, the server re-renders the template, diffs the result against the previous render, and ships just the changed lines over the socket. The client keeps a shadow copy of the exact HTML it last received, applies the patch to it, then morphs the live region's real DOM to match:
- Nodes are mutated in place — attributes synced, text updated — instead of torn down and rebuilt, so
document.activeElement, caret/selection position, and scroll state survive. - Form fields follow a "user wins" rule. A focused field is never clobbered (typing that round-trips through
soli-changecan't lose in-flight keystrokes), and an unfocused field only changes when the server actually changes the renderedvalueattribute (checkboxes and selects behave the same forchecked/selected). - Keyed lists keep identity. Items with
soli-key(or anid) keep their DOM node across reorders. soli-ignoresubtrees are never touched — the home for Alpine widgets, charts, maps.- The server owns everything else. DOM your own JS inserts outside a
soli-ignoresubtree is removed on the next patch, and<script>tags patched into a live region never execute.
If the client ever fails to apply a patch (lost shadow, version skew), it asks the server to replay the last full render — recovery is automatic and keeps server-side state intact.
Route Configuration
Register the Live View WebSocket endpoint in your routes.
# Live View component endpoint
router_live("counter", "live#counter");
Lifecycle Events
Two synthetic events are dispatched by the server in addition to user-driven directives:
connect— fired once, immediately after the WebSocket is established and before any client events. Use it to seed initial state and (optionally) start a tick timer.tick— fired on a recurring interval requested by the handler (see below). Use it for server-pushed updates like dashboards or live charts.
When a Handler Fails
A handler that raises does not silently fall back to some other behaviour: the server logs the failure and pushes an error to the client, leaving the view's state untouched. The client sees the message itself only under --dev; in production it gets a generic LiveView handler error, so an exception's text (which may carry paths or query fragments) stays server-side.
Returning nothing at all is legal and means no state change — the view re-renders from the current state, which normally produces an empty patch:
def toggled(event) {
Audit.create({ "action": event["event"] })
# no return — state is unchanged, nothing is patched
}
High-Rate Updates with Ticks
For real-time dashboards, monitoring, and live data feeds, a handler can opt into a per-instance recurring tick.
Return the wrapped form { "state": {...}, "tick_interval": <ms> } from any handler invocation —
typically from connect — and the server will fire tick events on that interval.
def metrics_dashboard(event_data: Any) -> Any {
event = event_data["event"]
if event == "connect"
# Set tick interval in milliseconds (50ms = 20 updates/sec)
{
"state": { "cpu": 0, "memory": 0, "requests": 0 },
"tick_interval": 50
}
elsif event == "tick"
# Server pushes fresh data on each tick
{
"state": {
"time": datetime_now(),
"cpu": system_cpu_usage(),
"memory": system_memory_mb(),
"requests": request_counter
}
}
else
# Unknown event — leave state and tick interval unchanged
event_data["state"]
end
}
<div class="dashboard">
<div class="metric">
<span class="label">Server Time</span>
<span class="value"><%= time %></span>
</div>
<div class="metric">
<span class="label">CPU</span>
<span class="value"><%= cpu %>%</span>
</div>
<div class="metric">
<span class="label">Memory</span>
<span class="value"><%= memory %> MB</span>
</div>
<div class="metric">
<span class="label">Requests/sec</span>
<span class="value"><%= requests %></span>
</div>
</div>
Live Demo: Server Clock
This is a tick-driven component running live against this site's live#metrics handler.
The server fires a tick event every 50ms, recomputes the time, and pushes back a patch.
Connecting...
tick_interval Semantics
The handler may return either shape on any invocation:
- Bare:
{ ...state }— the whole hash is the new state. Equivalent totick_intervalabsent. - Wrapped:
{ "state": {...}, "tick_interval": N }—stateis the new state;tick_intervalcontrols the timer.
| Returned value | Effect |
|---|---|
tick_interval absent | Leave the running tick alone |
0 | Stop the tick |
> 0 | Start (or replace) the tick at this interval, in milliseconds |
If a tick fires while the previous handler call is still running, the tick is dropped (rather than queued) so a slow handler doesn't snowball. Ticks stop automatically when the WebSocket closes.
Recommended Intervals
- 1000ms - Good for dashboards, status pages
- 100ms - Good for live charts, activity feeds
- 50ms (20/s) - Good for real-time monitoring
- 16ms (60/s) - Maximum rate, use sparingly for animations
Reactive Live Queries
A tick polls on a timer; a live query pushes only when the data changes. Call Model.live_where(filter) inside a handler instead of where(filter).all(): it runs the same query and subscribes this LiveView to the collection. When any request later writes to that collection, the framework re-runs the handler and pushes a patch — no polling, no manual pub/sub.
# app/controllers/live_controller.sl
def posts_board(event_data: Any) -> Any {
# Re-queries on connect and on every change to the collection. The render is
# diffed server-side, so an unrelated write yields an empty diff and no frame.
{ "posts": Post.live_where({ "published": true }) }
}
Nothing else is required: writing a Post from an ordinary controller (Post.create(...), post.save(), post.destroy()) wakes every board viewing it. live_where returns the same instances as where(...).all(), so a template that iterated the old result needs no changes. Outside a LiveView render it is where(...).all() — the subscription is a no-op — so it's a safe drop-in.
- Per-row matching. A flat-equality hash filter (
live_where({"published": true})) is remembered as its field→value map, so a write only wakes subscribers the changed row satisfies — publishing a draft won't re-render a board filtered topublished: true. The string form (live_where("doc.views > @n", ...)), deletes, and transaction commits can't be decomposed, so they wake conservatively; the diff gate still drops any frame with no visible change. - Single-process. Subscriptions live in server memory, like LiveView instances themselves. A write in one process doesn't wake subscribers in another — multi-process deployments need an external bus.
- Transaction-aware. Writes inside a
transaction { }block wake subscribers on commit, not per statement, so viewers never see uncommitted rows; a rolled-back transaction wakes no one.
Streams
A full re-render diffs the whole component; for an append-only or large list (a chat log, feed, leaderboard) that re-diffs every old row on each new one. A stream instead sends targeted DOM ops that the client applies directly to a container — no list re-render. Render the container empty (its items stay out of the diff, so patches never fight the streamed nodes), give it a stable id, and return a stream sub-hash from the handler:
def feed(event_data) {
if event_data["event"] == "new_message" {
msg = event_data["params"]
return {
"stream": {
"container": "messages",
"ops": [
{ "op": "append", "id": "msg-#{msg["id"]}", "html": "<li id=\"msg-#{msg["id"]}\">#{h(msg["text"])}</li>" }
]
}
}
}
{ "count": 0 } # connect: initial state
}
The returned hash may carry state and stream together, or stream alone (state untouched). Ops: append/prepend (id, html), insert (id, html, before?), remove (id), reset. container is hoisted on the stream hash and applies to every op. Rows should carry a stable id so re-adds de-dupe and remove can find them. Streamed nodes live outside the diff shadow, so a reconnect re-mounts from the empty render — re-drive the stream on connect if the list must survive one.
JS Commands and Navigation
A handler can push a small, eval-free list of client commands alongside (or instead of) a re-render. Unknown ops are skipped. There is no eval path.
if event == "flash" {
{
"state": { "ok": true },
"js": [
{ "op": "add_class", "to": "#flash", "class": "show" },
{ "op": "focus", "to": "#q" }
]
}
}
if event == "saved" {
{ "redirect": "/posts/#{post.id}" }
}
op |
Fields | Effect |
|---|---|---|
add_class / remove_class / toggle_class | to, class | Space-separated class names |
set_attr / remove_attr | to, name, value? | Attribute write / delete |
focus | to | Focus the element |
dispatch | to, event, detail? | CustomEvent on the target |
navigate | url | window.location (full load) |
patch | url | history.pushState + popstate |
to is a CSS selector, or window / document / body. The wrapped handler return may carry any combination of state, tick_interval, stream, redirect, and js. redirect is handled first and skips the subsequent patch. A click on soli-href does the same navigation without a round-trip.
Loading, Click-Away, and Hooks
While an event is in flight the triggering element gets soli-loading and soli-<event>-loading. soli-disable-with also swaps the label and sets disabled until the next patch restores the server-rendered markup.
<button soli-click="save" soli-disable-with="Saving…">Save</button>
<div class="menu" soli-click-away="close">…</div>
For a chart, map, or other widget that needs a JS constructor, put soli-hook="Name" on the element and register the hook before the socket connects. Auto-connect runs on DOMContentLoaded, so a script after /live/client.js can set SoliLiveView.hooks in time. Pair with soli-ignore when the widget owns its children. this.pushEvent("name", { … }) sends an event back over the socket.
<script src="/live/client.js"></script>
<script>
SoliLiveView.hooks = {
Chart: {
mounted() { this.chart = Chart.render(this.el); },
updated() { this.chart.refresh(); },
destroyed() { this.chart.teardown(); }
}
};
</script>
<div soli-hook="Chart" soli-ignore></div>
Callbacks: mounted (first seen), updated (same node survived a morph), destroyed (removed), disconnected / reconnected (socket). live(url, { hooks: { … } }) merges on top of SoliLiveView.hooks.
In-Socket Navigation
soli-patch changes the URL without dropping the socket (Phoenix live_patch). The client pushes history and sends event == "patch" with href, path, query, and hash. Browser back/forward fires the same event. A handler may also return patch: "/path" or patch: { "url": "/path", "replace": true }.
<a soli-patch="/posts/<%= id %>?tab=comments">Comments</a>
if event == "patch" {
{ "state": { "tab": event_data["params"]["query"]["tab"] } }
}
To swap the page-root LiveView to a different component without a full load, use soli-live (or return live: "/live/socket/…" from the handler). The client closes this socket and connects the new one on the same root. Pair with href / soli-patch to update the address bar.
<a soli-live="/live/socket/comments" href="/comments">Comments</a>
soli-href / handler redirect / JS navigate still do a full page load — use those when the destination is a regular page. The JS patch command only updates the address bar (it no longer synthesizes popstate).
Nested LiveViews
A LiveView template may mount another component as a child socket. After each parent render the client connects any new data-liveview-url slots and disconnects any that disappeared. The child's DOM is treated as a soli-ignore island automatically, so a parent patch does not wipe it. The two views do not share state.
<article>
<h1><%= title %></h1>
<div data-liveview-url="/live/socket/comments" data-live-root></div>
</article>
Independent child sockets still isolate state. For shared parent assigns, use a nested live component on the same socket:
<%- live_component("score", { "score": true }) %>
true / from_parent copies that key from the parent; any other value is a literal override. The helper renders app/views/live/score.html.slv and wraps it in soli-component="score". A click inside with soli-assign-score="<%= score + 1 %>" sends _assigns; the runtime merges them onto the parent (typed) before the handler runs, then the parent re-render fans the new values back into the child.
When the child also has router_live("score", "live#score"), it owns state under _components and its handler is Soli's update/2:
def score(event_data) {
if event_data["event"] == "update" {
assigns = event_data["assigns"] || {}
state = event_data["state"] || {}
return { "score": assigns["score"] ?? state["score"] ?? 0 }
}
event_data["state"]
}
send_update("score", { "id": "main", "score": 5 })
event == "update" receives assigns (what send_update sent) and state (the child's previous bag). The return value is the child's state — it does not replace the parent. Clicks inside the child go to live#score when that handler exists. A child without router_live still shares parent assigns. data-liveview-manual skips auto-connect on isolated sockets.
File Uploads
WebSocket frames are capped at 1 MiB, so bytes go over HTTP and the socket only carries an id. Put soli-upload="handler" on a file input. The layout should include csrf_meta_tag().
<input type="file" name="avatar" accept="image/*"
soli-upload="attached" soli-upload-max="2000000">
if event == "attached" {
file = event_data["params"]["file"]
# { filename, content_type, size, data } — data is base64
attach_upload(user, "avatar", file)
{ "state": { "name": file["filename"] } }
}
params["files"] is the array (multiple on the input); params["file"] is the first entry. Files larger than 256 KiB are sent as chunks (X-Soli-Upload-Id / chunk index / count); the handler still sees one hydrated file. A refresh mid-upload starts over. While the POST is in flight the input gets soli-upload-loading and data-soli-progress (0–100). A [soli-upload-bar] in the same label fills to that percent; an img[soli-upload-preview] shows a local preview for images as soon as you pick the file. A failure adds soli-upload-error and sends { "error": "…" }. Default cap is 8 MiB. Persist with has_one_attached / attach_<field>(params["file"]).
An upload belongs to the session that posted it: the id is only redeemable by that session's LiveView, and each session holds at most 8 pending uploads (they also expire after 10 minutes). A handler that never consumes its files therefore cannot fill the server's upload store or lock other users out.
The same accounting covers uploads still being assembled. Each session gets at most 4 in-progress chunked uploads, the server holds 32 across all sessions and 64 MiB of chunk data at once, and a partial upload is swept 2 minutes after its last accepted chunk — an idle deadline, not a total one, so a slow connection is never cut off mid-transfer. Over any of those, the POST returns 413 rather than growing the staging area. A retried chunk is charged once, not twice.
This matters because POST /live/upload is reachable without a session — it has to be, since a first-time visitor may not have one yet. Before these caps the partial-assembly map had only the expiry sweep, so a single unauthenticated client could mint a fresh X-Soli-Upload-Id per request, send one chunk of a declared 512, and park memory until the server ran out.
Performance
Current Limitations
Live View is young. Server-pushed re-renders and DOM-aware patching work well; some edges remain:
- The wire format is line-granular, not node-granular. The server ships the changed lines of the render (the client's morph is what makes the update DOM-aware); Phoenix-style static/dynamic splitting, which ships only the changed values, is not implemented. Fine in practice — renders are compared server-side and only the delta travels.
updateis a child handler event, not a separate process.send_update("score", assigns)writes_componentsand, whenrouter_live("score")exists, runs that handler withevent == "update". The child is not its own socket or OTP process — it cannot be targeted from another OS process. A child without a handler still shares parent assigns.- Independent child sockets still isolate state.
data-liveview-urlmounts remain their own sockets. Shared assigns uselive_component+soli-assign-*/send_updateon the parent socket. - Uploads are chunked, not resumable. Files over 256 KiB POST to
/live/uploadin chunks and hydrate as oneparams["file"]. A refresh mid-upload starts over; there is no pause/resume and no Phoenixallow_uploadconsume pipeline. Default cap is 8 MiB. - Leaving for a regular page is still a full load.
soli-href, handlerredirect, and JSnavigatedrop the socket. Same-app LiveView changes usesoli-patch(this component) orsoli-live(another/live/socket/<name>). - Scripts don't run on patch.
<script>tags inside a live region never execute when patched in; put behavior in external JS, a hook, or an Alpine island undersoli-ignore. - Reconnects restore server state, not the client shadow. A dropped socket reconnects with backoff; the new connection reuses the previous instance state (same
session:componentid, orroom:name:componentwhendata-live-roomis set) so the connect handler sees in-flight values. Every open tab of that instance is attached as another sender, so a click in one tab patches the others. The client still remounts the DOM from a fresh render. Nested child sockets reconnect independently. Once the last socket closes, the instance's state is held for two minutes so a refresh or a blip reclaims it, then reaped — a ticking view re-arms its timer on reconnect, andlive_wheresubscriptions stop firing as soon as no socket is attached. - Per-process. Instances and
live_wheresubscriptions live in server memory; a write in one process does not wake views in another. Multi-instance deployments need their own pub/sub layer.
Why Live View?
- No JavaScript required - Build interactive UIs with just server-side code
- SEO friendly - Initial HTML is server-rendered
- Reduced complexity - No client-side state management to maintain
- Real-time by default - WebSocket connection enables instant updates