Follow-up to HTML Over the Wire. I built the same live-search feature in five HTML-over-the-wire stacks: HTMX, React Server Components, Laravel Livewire, Rails Hotwire, and Phoenix LiveView. Same dataset (a list of programming languages), same UX (type into the box, table filters as you go), no JSON anywhere on the wire.
Repo: github.com/danieljohnmorris/live-search. Two of the five are running live: HTMX and RSC.
HTMX
The smallest of the five. A Go server with html/template, two endpoints, one HTML page that includes the htmx script.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "page", langs)
})
http.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "rows", filter(r.URL.Query().Get("q")))
})
The input element does the work:
<input type="search" name="q"
hx-get="/search"
hx-trigger="input changed delay:150ms, search"
hx-target="#results">
Each keystroke (debounced) fires a GET /search?q=.... The server returns HTML rows. htmx swaps them into #results. The server holds the state and returns HTML, so I didn’t write any client-side JavaScript. The whole server is ~50 lines of Go.
React Server Components
Next.js app router. The page is a server component; it reads searchParams.q and filters the data on the server.
export default async function Page({ searchParams }) {
const { q = "" } = await searchParams;
const hits = data.filter(l =>
!q || l.name.toLowerCase().includes(q.toLowerCase())
|| l.paradigm.toLowerCase().includes(q.toLowerCase()));
return (
<main>
<SearchInput initial={q} />
<table>{/* render hits */}</table>
</main>
);
}
For live-as-you-type, I needed a small client component that pushes the query into the URL, so the server re-renders:
"use client";
export default function SearchInput({ initial }) {
const router = useRouter();
const [q, setQ] = useState(initial);
useEffect(() => {
const t = setTimeout(() => {
router.replace(`/?${new URLSearchParams(q ? { q } : {}).toString()}`);
}, 150);
return () => clearTimeout(t);
}, [q]);
return <input type="search" value={q} onChange={e => setQ(e.target.value)} />;
}
That’s the trade-off with RSC for live UX. The server does the rendering, but you still write a small client component to drive it. Pure form-submit RSC works without any client JS, but you lose live-as-you-type.
Laravel Livewire
A single component class plus a Blade view. The state ($q) lives on the server; Livewire syncs the input to it over its own protocol.
class Search extends Component
{
public string $q = '';
public function render()
{
$data = json_decode(file_get_contents(base_path('data.json')), true);
$hits = strtolower($this->q) === ''
? $data
: array_values(array_filter($data, fn ($l) =>
str_contains(strtolower($l['name']), strtolower($this->q)) ||
str_contains(strtolower($l['paradigm']), strtolower($this->q))));
return view('livewire.search', ['hits' => $hits]);
}
}
<input type="search" wire:model.live.debounce.150ms="q" placeholder="...">
<table>
<tbody>
@foreach ($hits as $l)
<tr><td>{{ $l['name'] }}</td><td>{{ $l['year'] }}</td><td>{{ $l['paradigm'] }}</td></tr>
@endforeach
</tbody>
</table>
The wire:model.live.debounce.150ms="q" does everything: debounces the input, sends it to the server, gets the re-rendered component back, swaps the DOM. No routes, no JSON, no client JS to write.
Rails Hotwire
A regular Rails controller. The view has a form wrapped around the input; a Stimulus controller submits the form on input change with a debounce. Turbo handles the swap.
class LanguagesController < ApplicationController
def index
data = JSON.parse(Rails.root.join("data.json").read)
q = params[:q].to_s.downcase.strip
@hits = q.empty? ? data : data.filter { |l|
l["name"].downcase.include?(q) || l["paradigm"].downcase.include?(q)
}
respond_to do |format|
format.html
format.turbo_stream { render turbo_stream: turbo_stream.update("results", partial: "results") }
end
end
end
<%= form_with url: root_path, method: :get, data: { controller: "search" } do |f| %>
<%= f.search_field :q, value: params[:q],
data: { action: "input->search#submit" } %>
<% end %>
<tbody id="results"><%= render "results" %></tbody>
export default class extends Controller {
submit(event) {
clearTimeout(this.timer)
this.timer = setTimeout(() => event.target.form.requestSubmit(), 150)
}
}
This is the most plumbing of the five: a controller, a partial, a Stimulus controller, an ERB view. Turbo doesn’t ship a one-liner equivalent of wire:model.live. Once it’s wired up, though, every keystroke produces a <turbo-stream> that swaps the table body server-side.
Phoenix LiveView
A LiveView module owns the state and the rendering. The phx-change event fires on input changes; phx-debounce handles the timing.
defmodule DemoWeb.SearchLive do
use DemoWeb, :live_view
@data Application.app_dir(:demo, "priv/data.json")
|> File.read!() |> Jason.decode!()
def mount(_, _, socket), do: {:ok, assign(socket, q: "", hits: @data)}
def handle_event("search", %{"q" => q}, socket) do
query = q |> String.trim() |> String.downcase()
hits = if query == "", do: @data, else: Enum.filter(@data, fn l ->
String.contains?(String.downcase(l["name"]), query) or
String.contains?(String.downcase(l["paradigm"]), query)
end)
{:noreply, assign(socket, q: q, hits: hits)}
end
def render(assigns) do
~H"""
<form phx-change="search">
<input type="search" name="q" value={@q} phx-debounce="150" />
</form>
<table>
<tbody>
<%= for l <- @hits do %>
<tr><td><%= l["name"] %></td><td><%= l["year"] %></td><td><%= l["paradigm"] %></td></tr>
<% end %>
</tbody>
</table>
"""
end
end
LiveView keeps a WebSocket open per session. State lives in socket.assigns. On change, Phoenix sends a minimal DOM diff over the socket. Of the five, this is the only one that does not use request/response between browser and server, and the only one where the server holds long-lived per-user state.
What stood out
The same feature, in lines of meaningful code:
| stack | server | template/view | client JS |
|---|---|---|---|
| htmx | ~50 (Go) | one HTML file | none I wrote |
| rsc | ~30 (page.tsx) | inlined JSX | ~20 (search-input) |
| livewire | ~15 (Search.php) | one Blade view | none |
| hotwire | ~15 (controller) + view | index + partial | ~10 (Stimulus) |
| liveview | ~30 (search_live.ex) | inlined ~H | none |
A few things I noticed building these.
HTMX is the only one with no framework state. Each request is independent. The server doesn’t know who’s typing, doesn’t track sessions, doesn’t hold a connection open. That’s the appeal and the limit: you don’t get optimistic UI or partial DOM diffs without writing them yourself.
RSC is doing the most work to look the same as the others. The server component is clean, but the client component to push state into the URL is a manual implementation of what the others get from a directive (wire:model.live, phx-change). For a form-submit-style search, RSC is the cleanest of the five. For live-as-you-type, it’s the wordiest.
Livewire and LiveView have the same shape. Public class field, debounce in the markup, render method on the server. The difference is the transport (HTTP-roundtrip vs persistent WebSocket) and the language. If you’ve written one, you can read the other.
Hotwire is the most explicit. Controller, view, partial, Stimulus controller. It’s the most lines, but each piece is a regular Rails file you’d recognise from any Rails app. There’s no LiveView socket or Livewire wire-protocol: it’s form.requestSubmit() and a <turbo-stream> response.
The wire formats matter. HTMX and RSC and Hotwire send rendered HTML over normal HTTP. Livewire sends a small JSON-RPC-ish payload that includes the rendered HTML. LiveView sends a structured DOM diff over a WebSocket. From the user’s perspective they’re indistinguishable; from devtools they look very different.
The five stacks all return rendered HTML on each keystroke. The differences are in how much state the server holds, how much client code you write, and how much the framework hides.