Phoenix LiveView for a Searchable, Paginated List Without a JavaScript SPA
Phoenix LiveView lets you build interactive search and pagination in Elixir without a JavaScript SPA. Learn the lifecycle, a worked example, and the memory trade-offs.
27 Nov 2025, 10:52 UTC

The problem with a “simple” interactive page
You need a page where an admin types into a search box and a product table narrows down instantly, with pagination that updates without a full reload. The interaction is bounded: one input, one table. If you reach for a JavaScript framework, you inherit a package manager, a bundler, duplicated validation logic, and a second deployment artifact. For a leaf interaction like this, that machinery often costs more than the feature itself.
Phoenix LiveView offers a middle path. It keeps UI state and event handling in Elixir on the server, renders the first paint as ordinary HTML, and then sends small DOM diffs over a persistent WebSocket connection. You write Elixir, not JavaScript, and the browser applies the patches.
The LiveView lifecycle in three callbacks
A LiveView module is a stateful process tied to a client connection. Three callbacks cover most of the work:
mount/3runs when the connection is established and loads initial state into the socket.render/1returns HEEx—an HTML template with embedded Elixir—that becomes the first paint.handle_event/3reacts to client events such as form changes or button clicks and updates the socket without a full page reload.
Because the state lives on the server, you can query your database directly inside handle_event and re-render only the affected rows. There is no client-side store to synchronize.
A worked example: searchable product list
Imagine an admin page that lists products and supports name search with pagination. The route lives in your Phoenix router:
# lib/my_app_web/router.ex
live "/products", MyAppWeb.ProductLive.Index, :index
The module below assigns a query string, a page number, and the current result set. When the user types, a phx-change event fires, the server re-queries with Ecto, and the table updates.
# lib/my_app_web/live/product_live/index.ex
defmodule MyAppWeb.ProductLive.Index do
use MyAppWeb, :live_view
alias MyApp.Catalog
@impl true
def mount(_params, _session, socket) do
products = Catalog.list_products("", 1, 20)
{:ok, assign(socket, query: "", page: 1, per_page: 20, products: products)}
end
@impl true
def render(assigns) do
~H"""
<form phx-change="search">
<input type="text" name="q" value={@query} placeholder="Search products..." />
</form>
<table>
<thead>
<tr><th>Name</th><th>SKU</th></tr>
</thead>
<tbody>
<tr :for={product <- @products}>
<td>{product.name}</td>
<td>{product.sku}</td>
</tr>
</tbody>
</table>
"""
end
@impl true
def handle_event("search", %{"q" => q}, socket) do
products = Catalog.list_products(q, socket.assigns.page, socket.assigns.per_page)
{:noreply, assign(socket, query: q, products: products)}
end
end
The context function encapsulates the Ecto query:
# lib/my_app/catalog.ex
def list_products(q, page, per_page) do
from(p in Product,
where: ilike(p.name, ^"%#{q}%"),
order_by: p.name,
limit: ^per_page,
offset: ^((page - 1) * per_page)
)
|> Repo.all()
end
Run mix deps.get in the project root to ensure dependencies are present. Then start the server with mix phx.server and visit /products. Type in the search box and confirm the table updates without a full page reload. Open your browser’s network tab to see the WebSocket frames carrying the diffs.
The cost of stateful connections
LiveView is not free. Each connected client holds a stateful process on the BEAM. A busy page with hundreds of concurrent users increases memory and scheduler load compared with a stateless JSON endpoint. You cannot treat the LiveView route as a drop-in replacement for a cached controller action without measuring.
Several mitigations exist, but their APIs are version-sensitive:
- Temporary assigns let you clear large collections from socket state after render, reducing memory.
- Streams can push large lists to the client incrementally instead of embedding them in assigns. The stream API has changed across LiveView releases, so verify the functions available in your installed version.
- Heartbeats and timeouts shut down processes when clients disappear, but they do not eliminate the peak cost of many simultaneous connections.
There is also a hard limitation: if JavaScript is disabled or the WebSocket is blocked, the initial HTML still renders, but interactions will not update the page. LiveView is not offline-first. High-frequency local interactions—complex drag-and-drop, canvas drawing, or rapid keystroke validation that must feel instant without a network round trip—may still belong in a client-side component or JavaScript hook.
What to do next
- Pick one leaf interaction—a search box, a filter, or an inline edit—and implement it as a LiveView rather than converting an entire page.
- Check your installed version before relying on newer APIs. Run
mix deps | grep phoenix_live_viewin the project root, or inspectmix.lock, and compare the module options against that release. - Verify the fallback behavior. Disable JavaScript in your browser and reload the page. Confirm that the static HTML appears and note that live updates will fail. Decide whether that failure mode is acceptable for the feature.
- Measure before scaling. Use a load tool to open a few hundred concurrent connections to the LiveView route. Attach an IEx console to the running node and call
:erlang.memory()to record BEAM memory, count sockets, and track render latency. Attaching to a production node requires the node cookie and appropriate access; in development,iex -S mixis sufficient. If the numbers exceed your budget, remove the live route and restore the controller action.
LiveView shines when the interaction is bounded, the state naturally lives with your domain logic, and a round trip to the server is acceptable. Start there, measure, and reach for JavaScript only when the interaction genuinely outgrows the model.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.