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
Try the interactive counter demo on our landing page.
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. <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">
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>
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.
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.
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.
- The directive set is a subset of Phoenix's. Click, submit, change, keydown/keyup, focus/blur,
soli-value-*, andsoli-target— there is no debounce/throttle, no window-level bindings, no JS commands, no uploads, streams, or nested live components. - Scripts don't run on patch.
<script>tags inside a live region never execute when patched in; put behavior in external JS or an Alpine island undersoli-ignore. - Reconnects re-mount. If the socket drops, the client reconnects with backoff, but the component restarts from its initial state — in-flight state is not restored.
- Per-process. Instances live in server memory; multi-instance deployments need their own pub/sub layer to coordinate.
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