ESC
Type to search...
S
Soli Docs

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.

Open the Field Desk

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).

User Event
Click, Submit, Change
Server
Update State & Render
Page Update
Morph DOM In Place

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-change can't lose in-flight keystrokes), and an unfocused field only changes when the server actually changes the rendered value attribute (checkboxes and selects behave the same for checked/selected).
  • Keyed lists keep identity. Items with soli-key (or an id) keep their DOM node across reorders.
  • soli-ignore subtrees are never touched — the home for Alpine widgets, charts, maps.
  • The server owns everything else. DOM your own JS inserts outside a soli-ignore subtree 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:

app/controllers/live_controller.sl
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.

Live demo · /live/socket/metrics -- fps
...

Connecting...

--
Updates/s
<100B
Per update
Push
Server-sent

tick_interval Semantics

The handler may return either shape on any invocation:

  • Bare: { ...state } — the whole hash is the new state. Equivalent to tick_interval absent.
  • Wrapped: { "state": {...}, "tick_interval": N } — state is the new state; tick_interval controls the timer.
Returned value Effect
tick_interval absentLeave the running tick alone
0Stop the tick
> 0Start (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 to published: 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_classto, classSpace-separated class names
set_attr / remove_attrto, name, value?Attribute write / delete
focustoFocus the element
dispatchto, event, detail?CustomEvent on the target
navigateurlwindow.location (full load)
patchurlhistory.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

~7KB
Client Size (gzipped)
<50ms
Round-trip Latency
DOM morph
Updates

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.
  • update is a child handler event, not a separate process. send_update("score", assigns) writes _components and, when router_live("score") exists, runs that handler with event == "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-url mounts remain their own sockets. Shared assigns use live_component + soli-assign-* / send_update on the parent socket.
  • Uploads are chunked, not resumable. Files over 256 KiB POST to /live/upload in chunks and hydrate as one params["file"]. A refresh mid-upload starts over; there is no pause/resume and no Phoenix allow_upload consume pipeline. Default cap is 8 MiB.
  • Leaving for a regular page is still a full load. soli-href, handler redirect, and JS navigate drop the socket. Same-app LiveView changes use soli-patch (this component) or soli-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 under soli-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:component id, or room:name:component when data-live-room is 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, and live_where subscriptions stop firing as soon as no socket is attached.
  • Per-process. Instances and live_where subscriptions 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