LiveView vs OctaStar
Copy MarkdownThis guide compares Phoenix LiveView and OctaStar side-by-side using the same active search example. Both implementations feature debounced input, optimistic client-side filtering, and minimal server round-trips.
The Example
A search input that filters a list of framework names as the user types, with instant client-side feedback and server-side filtering as the source of truth.
Code Comparison
OctaStar Controller
defmodule AppWeb.SearchController do
use AppWeb, :controller
@items ["Elixir", "Phoenix", "LiveView", "Datastar", "SSE", "Plug", "Ecto", "Ash", "HEEx", "Tailwind"]
@impl StarView
def mount(conn, _params) do
conn
|> signal(:query, "")
|> signal(:results, @items)
end
@impl StarView
def render(assigns) do
~H\"""
<div class="max-w-xl mx-auto p-6" data-signals={init_signals(@conn)}>
<h1 class="text-2xl font-bold mb-4">Active Search</h1>
<.search_form />
<.item_list results={@results} />
<.no_results query={@query} />
</div>
\"""
end
def search_form(assigns) do
~H\"""
<div class="mb-4 flex gap-2">
<input
type="text"
class="input grow"
placeholder="Search frameworks..."
data-bind:query
data-on:input__debounce.200ms={post("search")}
/>
<button class="btn" data-on:click={post("reset")}>
Reset
</button>
</div>
\"""
end
attr :query, :string, default: nil
def no_results(assigns) do
~H\"""
<div data-show={query_results("=== 0")}>
<p class="text-gray-500">
No results found for "<span data-text="$query">{@query}</span>"
</p>
</div>
\"""
end
attr :results, :list, default: []
def item_list(assigns) do
~H\"""
<ul id="item-list" class="grid gap-2" data-show={query_results("> 0")}>
<.item :for={item <- @results} item={item} />
</ul>
\"""
end
attr :item, :string, required: true
def item(assigns) do
~H\"""
<li class="border p-4" data-show={starts_with?("'#{@item}'")}>
{@item}
</li>
\"""
end
@impl StarView
def handle_event("search", %{"query" => query} = signals, conn) do
conn
|> signal(:results, get_items(query))
|> maybe_patch_list(signals)
end
def handle_event("reset", signals, conn) do
conn
|> signal(:query, "")
|> signal(:results, @items)
|> maybe_patch_list(signals)
end
defp get_items(""), do: @items
defp get_items(query) do
search_query = String.trim(String.downcase(query))
Enum.filter(@items, &String.contains?(&1 |> String.downcase(), search_query))
end
defp maybe_patch_list(%{assigns: %{results: x}} = conn, %{"results" => x}), do: conn
defp maybe_patch_list(conn, _signals), do: patch_element(conn, &item_list/1)
defp starts_with?(item) do
"\#{item}.trim().toLowerCase().startsWith($query.trim().toLowerCase())"
end
defp query_results(condition) do
"$results.filter(x => \#{starts_with?("x")}).length \#{condition}"
end
endLiveView Equivalent
defmodule AppWeb.SearchLive do
use AppWeb, :live_view
alias Phoenix.LiveView.ColocatedHook
@items ["Elixir", "Phoenix", "LiveView", "Datastar", "SSE", "Plug", "Ecto", "Ash", "HEEx", "Tailwind"]
@impl LiveView
def mount(_params, _session, socket) do
socket
|> assign(:query, "")
|> assign(:results, @items)
|> ok()
end
@impl LiveView
def render(assigns) do
~H\"""
<div id="active-search" class="max-w-xl mx-auto p-6" phx-hook=".ActiveSearch">
<h1 class="text-2xl font-bold mb-4">Active Search</h1>
<.search_form query={@query} />
<.item_list results={@results} />
<.no_results query={@query} has_results?={@results != []} />
</div>
<.active_search_script />
\"""
end
attr :query, :string, default: nil
def search_form(assigns) do
~H\"""
<form phx-change="search" class="mb-4 flex gap-2">
<input
type="text"
class="input grow"
placeholder="Search frameworks..."
name="query"
value={@query}
phx-debounce="200"
data-search-input
/>
<button type="button" class="btn" phx-click="reset">
Reset
</button>
</form>
\"""
end
attr :query, :string, default: nil
attr :has_results?, :boolean, default: false
def no_results(assigns) do
~H\"""
<div class="space-y-2" data-search-empty hidden={@query == "" || @has_results?}>
<p class="text-gray-500">
No results found for "<span data-search-empty-query>{@query}</span>"
</p>
</div>
\"""
end
attr :results, :list, default: []
def item_list(assigns) do
~H\"""
<ul class="grid gap-2" data-search-results hidden={@results == []}>
<.item :for={item <- @results} item={item} />
</ul>
\"""
end
attr :item, :string, required: true
def item(assigns) do
~H\"""
<li class="border p-4" data-search-item={@item}>
{@item}
</li>
\"""
end
@impl LiveView
def handle_event("search", %{"query" => query}, socket) do
socket
|> assign(:query, query)
|> assign(:results, get_items(query))
|> noreply()
end
def handle_event("reset", _params, socket) do
socket
|> assign(:query, "")
|> assign(:results, @items)
|> noreply()
end
defp get_items(""), do: @items
defp get_items(query) do
search_query = String.trim(String.downcase(query))
Enum.filter(@items, &String.contains?(&1 |> String.downcase(), search_query))
end
def active_search_script(assigns) do
~H\"""
<script :type={ColocatedHook} name=".ActiveSearch">
export default {
mounted() {
this.onInput = event => {
if (event.target.matches("[data-search-input]")) {
this.applyFilter()
}
}
this.el.addEventListener("input", this.onInput, {passive: true})
this.applyFilter()
},
updated() { this.applyFilter() },
destroyed() { this.el.removeEventListener("input", this.onInput) },
applyFilter() {
const input = this.el.querySelector("[data-search-input]")
const query = (input?.value || "").trim().toLowerCase()
let visibleCount = 0
this.el.querySelectorAll("[data-search-item]").forEach(item => {
const value = (item.dataset.searchItem || "").trim().toLowerCase()
const isVisible = query === "" || value.startsWith(query)
item.hidden = !isVisible
if (isVisible) { visibleCount += 1 }
})
const results = this.el.querySelector("[data-search-results]")
const noResults = this.el.querySelector("[data-search-empty]")
const noResultsQuery = this.el.querySelector("[data-search-empty-query]")
if (results) { results.hidden = visibleCount === 0 }
if (noResults) { noResults.hidden = query === "" || visibleCount > 0 }
if (noResultsQuery) { noResultsQuery.textContent = input?.value || "" }
}
}
</script>
\"""
end
endKey Differences
1. Client-Side Filtering
OctaStar uses Datastar's data-show with JavaScript expressions evaluated
in the browser. The filtering logic lives in small helper functions:
defp starts_with?(item) do
"#{item}.trim().toLowerCase().startsWith($query.trim().toLowerCase())"
end
defp query_results(condition) do
"$results.filter(x => #{starts_with?("x")}).length #{condition}"
endThese expressions run instantly on every keystroke without any server round-trip.
LiveView requires a colocated JavaScript hook (~60 lines) that manually
queries the DOM, computes visibility, and toggles hidden attributes. The hook
must handle mounted, updated, and destroyed lifecycle events.
2. Signal Binding
OctaStar uses data-bind:query to automatically sync the input value to the
$query signal. The data-text="$query" attribute on the "no results" span
updates the displayed query text client-side without server involvement.
LiveView requires name="query", value={@query}, and phx-change="search"
on the form, plus manual DOM queries in the hook to read the input value and
update the "no results" text content for optimistic updates.
3. Change Detection
OctaStar uses explicit change detection in maybe_patch_list/2:
defp maybe_patch_list(%{assigns: %{results: x}} = conn, %{"results" => x}), do: conn
defp maybe_patch_list(conn, _signals), do: patch_element(conn, &item_list/1)If the results haven't changed, no patch is sent. This is manual but gives you full control over what gets sent over the wire.
LiveView does automatic change tracking — it diffs the render tree and only sends changed DOM patches. This is convenient but adds overhead for computing the diff on every render.
4. Transport
Both approaches support real-time server push — Phoenix PubSub works with either protocol, and the BEAM VM handles WebSocket infrastructure and connection management out of the box. The difference is in how each protocol shapes your application architecture.
OctaStar uses SSE (Server-Sent Events) for server-to-client streaming and standard HTTP requests for client-to-server events. Datastar can subscribe to Phoenix PubSub topics to push real-time updates over the SSE stream, keeping the connection alive with heartbeat events.
LiveView uses WebSockets for everything — events, uploads, and PubSub broadcasts. The single bidirectional connection handles all communication, but this means things that are naturally HTTP (like setting session cookies) require fallback endpoints outside the LiveView.
SSE (OctaStar)
| Pros | Cons |
|---|---|
| Standard HTTP — works through all proxies, CDNs, firewalls | One-way only — client needs separate HTTP requests to send data |
| Automatic reconnection built into the protocol spec | Limited to text data (UTF-8) |
| Cookies work naturally — every client request is a regular HTTP call | Browser connection limits (~6 per domain) |
Easy to debug with browser dev tools or curl | Client must initiate the SSE connection |
| Simple mental model — stream down, POST up | No native binary data support |
| Stateless server — no per-view process holding assigns in memory |
WebSockets (LiveView)
| Pros | Cons |
|---|---|
| Bidirectional on a single connection | Can't set HTTP cookies over WebSocket — requires fallback HTTP endpoints for auth/session |
| Lower latency for rapid back-and-forth | Each LiveView holds a GenServer process in memory with full assigns state |
| Single connection handles everything (events, uploads, pubsub) | Some corporate proxies/firewalls may block or interfere |
| Binary data support | Connection lifecycle is more complex (handshake, frames, close codes) |
| Server memory grows with each open view — assigns, diffs, and process state are retained | |
| Session management requires separate HTTP routes or token-based auth |
In practice, both need logic to decide when to push updates — Datastar requires
subscribing to PubSub topics and sending heartbeat events, while LiveView
requires push_event/3 or assign changes that trigger renders. Neither has a
clear advantage for real-time capability; the choice comes down to whether you
prefer the simplicity and statelessness of HTTP (SSE) or the bidirectionality
of WebSockets.
5. Event Payloads
OctaStar sends and receives JSON signal maps. Client state arrives as a plain map you work with directly:
def handle_event("search", %{"query" => query} = signals, conn) do
conn
|> signal(:results, get_items(query))
|> maybe_patch_list(signals)
endNo form parsing, no name/value collisions, no CSRF tokens to manage. Signals
are typed JSON — strings, numbers, booleans, lists, maps — and you access them
with Map.get/2 or pattern matching.
LiveView receives form-encoded payloads through phx-change and phx-submit.
For simple inputs this works fine, but complex forms with nested data, dynamic
fields, or file uploads require careful naming conventions and manual parsing:
def handle_event("search", %{"query" => query}, socket) do
socket
|> assign(:query, query)
|> assign(:results, get_items(query))
|> noreply()
endHTML forms were designed in the 1990s for document submission, not interactive applications. Datastar sidesteps this entirely by treating state as JSON signals rather than form fields.
6. Architecture
| Aspect | OctaStar | LiveView |
|---|---|---|
| Transport | SSE (server) + HTTP (client) | WebSocket (everything) |
| Client state | Datastar signals ($query, $results) | Socket assigns |
| Optimistic UI | data-show, data-text, data-bind | JS hooks + DOM manipulation |
| Change tracking | Manual (maybe_patch_list) | Automatic (render diff) |
| Connection model | Stateless requests + SSE stream | Persistent process per view |
| Real-time | PubSub over SSE stream | PubSub over WebSocket |
| Cookies | Native (HTTP requests) | Requires fallback endpoints |
When to Choose Which
Choose OctaStar when
- You prefer the simplicity of HTTP over WebSockets
- You need cookies to work naturally without fallback endpoints
- You want explicit control over what gets sent to the client
- You want optimistic UI with minimal JavaScript
- You prefer JSON signal maps over form-encoded payloads
- You're building on top of existing Phoenix controllers
Choose LiveView when
- You want automatic change tracking without manual diff logic
- You need the full LiveView ecosystem (components, uploads, streams)
- You prefer a single bidirectional connection for all communication