I wanted to build a search modal for the pleasure of exploring the interaction. Bram Jetten’s Hotwire article supplied the inspiration: open an overlay, type a query, follow a result. I kept that interface idea and built an independent Phoenix LiveView companion app, with search while typing from the first version.
The companion source is public. The code and checks discussed
here are at commit edafcdfc.
The app has twelve demonstration articles in PostgreSQL and a reading page
for each one. It is separate from this blog. Searching for LiveView gives
me this:

The interesting part was deciding which interactions could wait for the server. Opening, closing and moving focus should feel immediate. Searching needs the database. That split worked well, until simulated latency exposed a smaller gap: the browser could already contain a new query while the server was still correctly answering the previous one.
Opening without a round trip
The modal is rendered with the page, initially carrying hidden. Opening
runs LiveView JS commands, through phx-click, against elements that
already exist. There is no application event to request the dialog’s HTML.
This is the complete opening command from SearchModal:
defp show_modal do
JS.push_focus(to: "#open-search")
|> JS.remove_attribute("hidden", to: "#search-modal")
|> JS.set_attribute({"inert", ""}, to: "#page-content")
|> JS.set_attribute({"aria-expanded", "true"}, to: "#open-search")
|> JS.add_class("modal-open", to: "body")
|> JS.focus(to: "#search-modal:not(.is-disconnected) #search-input")
|> JS.focus(to: "#search-modal.is-disconnected #close-search")
end
It remembers the opener for focus restoration, reveals the overlay, makes the background inert and adds a body class whose CSS locks scrolling. Focus goes to the input when connected, or to Close when disconnected. LiveView’s JS attribute and class changes are aware of DOM patches, so a search response does not undo the open state.
Closing reverses those changes and calls JS.pop_focus(). Escape, the Close
button and clicking outside the panel share that command. The selectors in
open/0 and close/0 target only the relevant hidden or visible state;
pressing Escape again after closing does not pop focus again.
The panel uses focus_wrap, role="dialog", an accessible name and
aria-modal="true". In Chromium, Tab and Shift+Tab stay inside it. The
input is type="text" with role="searchbox": in the browser experiment,
a native search input cleared its value on Escape, whereas I wanted Escape
to close the dialog and retain the query.
There is also cleanup on removal. Following a result navigates to a reading
page, and phx-remove receives the closing commands directly so the outgoing
modal releases background inertness and the scroll lock. Search completion
never opens the dialog. A response arriving after I close it stays closed.
A deliberately small database search
The form uses phx-change="search" and phx-submit="search". Its input has
phx-debounce="250": typing waits for a 250 ms pause before sending a
change. Enter can submit the current value sooner. LiveView owns that
form event handling.
On the server, Content.normalize_query/1 trims the input and keeps at most
200 graphemes. Blank input returns no articles. For a nonblank query,
pattern is a percent sign, the escaped query, then another percent sign.
The database call in Content is:
Repo.all(
from(a in Article,
where:
ilike(a.title, ^pattern) or ilike(a.summary, ^pattern) or ilike(a.body, ^pattern),
order_by: [asc: a.title, asc: a.id],
limit: 10
),
timeout: 5_000
)
The ^pattern values are query parameters. Escaping wildcard characters is
a separate concern: a parameterized query can still interpret % as a
wildcard. I want someone searching for 100% to search for that literal
percent sign. The helper is:
defp escape_like(query) do
query
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
Backslash is escaped first. PostgreSQL’s pattern-matching reference
describes %, _ and the default backslash escape, as well as ILIKE’s
locale-dependent case-insensitive matching.
This searches title, summary and body, orders by title then id, and returns at most ten rows. It has no relevance ranking or accent folding. I have written about PostgreSQL full-text search before; this lab uses a simpler substring search. Twelve sample articles tell me whether the interaction works, not how this query will behave on a large catalogue.
Keeping the LiveView free to respond
The SQL runs in a task started with start_async/3, outside the LiveView
process. Inside the nonblank branch, after extracting search_fn from the
socket, the code in CatalogLive is:
socket
|> assign(:search_state, :loading)
|> start_async(:search, fn -> {generation, search_fn.(query)} end)
Each accepted search first calls cancel_async(:search), increments
search_generation, clears the result stream and keeps the raw form value
separate from the normalized query. Blank input takes the same invalidation
path, then returns to :idle without starting another task.
There are overlapping protections here, and the version matters. This app
locks LiveView 1.2.13. Its async API documentation says that when
tasks share a name, the later start_async wins and the previous result is
ignored. The implementation checks the task reference before dispatching
the result to the application callback. Reusing :search therefore already
protects that delivery path from an older task’s completion.
Cancellation alone behaves differently: it asks the old Elixir task to exit but leaves its registered reference in place. After clearing without starting a replacement, a cancellation exit or an already-sent result can therefore still reach the callback. Cancellation also does not establish that a PostgreSQL query already executing has stopped; that would need a separate experiment.
The successful callback also checks this condition (the body is omitted):
if generation == socket.assigns.search_generation and socket.assigns.search_state == :loading do
The loading-state guards are what preserve :idle after clearing: a
successful result cannot populate it, and the exit callback cannot replace
it with an error. After replacement, LiveView’s reference filtering rejects
older task deliveries. Together, those checks suffice on this stable-name
path in LiveView 1.2.13. I have kept the generation counter, but its
comparison is redundant for correctness on this path.
One testing detail matters here. The state tests call handle_async/3
directly with old generations, bypassing LiveView’s reference filtering.
They check the callback’s behavior; they do not establish that the generation
comparison is necessary in the running application.
All of this concerns queries the server has received. That qualification is where the browser problem begins.
The query the server has not seen
With liveSocket.enableLatencySim(1000), changing LiveView to
PostgreSQL exposed the revealing case: “5 results for LiveView” remained
visible during debounce before the freshness guard was added. The
companion README describes the experiment.
Those results were valid for the last search. They were wrong beside the current input. No amount of server-side cancellation can react to a keystroke whose event has not arrived yet.
The diagram shows ordering, not measured durations. If typing continues, the debounce window moves again. Transport and database execution add their own waits after it.
LiveView’s phx-change-loading and phx-submit-loading classes track the
in-flight form event, but they cannot cover the earlier debounce interval.
The hook’s pending state covers both debounce and transport while the raw
input differs from the server echo. Throughout that interval, the CSS shows
“Waiting to search…” and suppresses “Sending search…”.
“Sending search…” appears, for example, when I press Enter on an unchanged value: the raw input already matches the echo, so the transport-loading class can select that message without the hook’s pending state overriding it.
“Searching…” represents the asynchronous task’s loading state. In an instrumented browser run it appeared in the DOM between the loading and result patches, but no animation-frame sample captured it. That establishes a DOM transition, not that someone could perceive the message. Acknowledging the form event and finishing the SQL remain separate events, even when the patches arrive close together.
I want the visible response to agree with the field throughout those intervals, including when a patch for earlier input arrives.
A hook that only checks the input
The form echoes the raw accepted value in
data-query-value={@form[:query].value} and attaches the colocated hook
.CurrentQuery. The hook’s mounted() registers an input listener and
checks immediately; updated() checks again after patches; destroyed()
removes the listener. Here is its sync() method:
sync() {
const value = this.input.value
const pending = value !== this.el.dataset.queryValue
if (pending) {
this.el.dataset.inputState = value.trim() === "" ? "empty" : "pending"
} else {
delete this.el.dataset.inputState
}
const busy = pending ? value.trim() !== "" : this.el.dataset.searchState === "loading"
this.el.querySelector("#search-output").setAttribute("aria-busy", String(busy))
},
The comparison is deliberately between two raw values. Searching for
" PostgreSQL " uses "PostgreSQL" in SQL, but the echoed value must retain
the spaces. Comparing the input to the normalized search query would leave
a completed response hidden even though the server had handled that exact
input. Retry preserves the raw form value for the same reason.
When the values differ, data-input-state becomes pending, or empty for
whitespace-only input. CSS immediately hides the response and selects the
appropriate instruction in the app stylesheet:
#search-form[data-input-state] .search-response,
#search-form[data-input-state] .transport-status { display: none; }
#search-form[data-input-state="pending"] .input-status { display: block; }
#search-form[data-input-state="empty"] .empty-status { display: block; }
When the echo matches again, the hook removes that attribute. The normal
transport and server-state presentation takes over. Matching input does not
itself mean the search is complete: the hook also uses data-search-state
to maintain aria-busy while the task is loading.
That is the hook’s whole responsibility. It sends no events, schedules no
debounce and performs no search. Visibility of the modal and focus remain
in LiveView JS commands. The server still patches the form’s children, so
this form intentionally has no phx-update="ignore". The hook updates
presentation attributes and rechecks them after patches.
I like how small that browser responsibility is. It knows something the server cannot yet know, namely what is in the field right now, and uses it to avoid presenting an answer under the wrong query.
Checking the waits separately
I ran mix test on my Mac: 31 tests passed.
mix precommit also succeeded, including the same 31-test suite. Those
counts include the PostgreSQL-dependent tests. They are correctness checks,
not search performance measurements.
The database tests exercise matching across all three fields, literal wildcards, ordering, the ten-result cap and repeatable seeds. Isolated LiveView tests use the production callbacks and templates with search tasks controlled by messages. They can hold a task open, replace it, observe its cancellation, or make it fail and retry. That gives deterministic checks without pretending to make PostgreSQL slow.
Browser behavior has a separate check: the Playwright scenario records 32 Chromium assertions against the running, seeded app. These cover focus, keyboard containment, closing, raw-input retention, real database results, no matches, disconnection, mobile layout and navigation cleanup. The latency cases include editing while an earlier change is in flight, clearing and closing before completion. They also check the waiting message during debounce and transport, and the sending message when submitting an unchanged value. The settling helper waits for the transport-loading classes to clear.
Animation-frame sampling in that scenario observed no visible response whose raw echo differed from the field. That is useful evidence for the scenario, not a proof that every possible race is absent.
There are useful limits to keep visible. enableLatencySim(1000) simulates
transport delay; genuinely slow PostgreSQL execution remains to be tried.
Other browsers and assistive technologies still need checking, so I would
not call accessibility fully audited. The input’s HTML maxlength counts
UTF-16 units while the server limit counts graphemes, which can give some
Unicode input a shorter effective browser limit. The lab’s recorded
dependency advisories also need review before any external deployment.
What I wanted from this small app was the feel of opening a search box and getting on with typing. The extra care ended up in the interval before the server could possibly help. I can let the database take its time, show an honest waiting state, and still close the dialog immediately. What I do not want is an answer for the word I have already replaced.
Comments, questions, or just a reaction?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.