Active Search
Typing debounces 300 ms, then @get('/api/search') sends the signals; the
endpoint re-renders the same <SearchResults/> Astro component (via the
Container API) and Datastar morphs it in by id. data-replace-url keeps
the address bar shareable, and the indicator + busy fade cover slow
responses. A $_typing signal fades the stale list while you’re still
typing — the client only sets it; the server clears it with a signal patch
in the same response that delivers the fresh list.
Search towns
- Aberdeen
- Bath
- Belfast
- Birmingham
- Brighton
- Bristol
- Cardiff
- Edinburgh
- Glasgow
- Inverness
- Leeds
- Liverpool
- London
- Manchester
- Newcastle
- Norwich
- Nottingham
- Oxford
- Plymouth
- Sheffield
- Swansea
- York
The demo
---
/** Active search demo. Deep link: ?q=… server-renders the filtered list. */
import SearchResults from '../SearchResults.astro';
const q = Astro.url.searchParams.get('q') ?? '';
---
<section class="demo" data-signals={`{q: ${JSON.stringify(q)}, _typing: false}`}>
<h2>Search towns</h2>
<label for="q">Filter</label>
<input
id="q"
type="text"
placeholder="e.g. Bristol"
data-bind:q
data-on:input="$_typing = true"
data-on:input__debounce.300ms="@get('/api/search')"
data-indicator="_searching"
/>
<div
class="ds-busy-fade"
data-class:is-typing="$_typing"
data-attr:aria-busy="$_searching ? 'true' : 'false'"
data-replace-url="'/examples/active-search' + ($q ? '?q=' + encodeURIComponent($q) : '')"
>
<SearchResults q={q} />
</div>
</section>The shared component
---
/** Results list shared by the page and /api/search. */
import { towns } from '../data/towns';
interface Props {
q: string;
/** Validation failure to render instead of results. */
error?: string;
}
const { q, error } = Astro.props;
const matches = q
? towns.filter((t) => t.toLowerCase().includes(q.toLowerCase()))
: towns;
---
<ul id="results" class="result-list">
{error && <li class="hint" role="alert">{error}</li>}
{!error && matches.map((town) => <li>{town}</li>)}
{!error && matches.length === 0 && <li class="hint">No matches</li>}
</ul>The endpoint
import {
patchElements,
patchSignals,
readSignals,
SignalsValidationError,
sse,
} from '@wrux/astro-datastar/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { z } from 'astro/zod';
import SearchResults from '../../components/SearchResults.astro';
export const prerender = false;
const signals = z.object({
q: z.string().max(100).default(''),
});
export const GET: APIRoute = async ({ request }) => {
const container = await AstroContainer.create();
let q = '';
let error: string | undefined;
try {
({ q } = await readSignals(request, signals));
} catch (err) {
if (!(err instanceof SignalsValidationError)) throw err;
error = err.issues.map((i) => i.message).join('; ');
}
const results = await container.renderToString(SearchResults, {
props: { q, error },
});
return sse(patchElements(results), patchSignals({ _typing: false }));
};The plugin
import { attribute, effect } from '../engine';
/**
* `data-replace-url="<expression>"` keeps the address bar in sync with
* signal state: whenever signals the expression reads change, the current
* history entry is replaced with the evaluated URL (relative or absolute,
* same-origin only).
*
* ```html
* <div data-replace-url="'/businesses' + ($q ? '?q=' + $q : '')"></div>
* ```
*
* Upstream Datastar 1.0 moved its ReplaceUrl plugin to the paid Pro
* bundle; this is our own minimal equivalent with the same attribute name.
*/
attribute({
name: 'replace-url',
requirement: { key: 'denied', value: 'must' },
returnsValue: true,
apply({ rx }) {
return effect(() => {
const url = rx();
if (typeof url !== 'string' || url === '') return;
const resolved = new URL(url, window.location.origin);
if (resolved.origin !== window.location.origin) return;
history.replaceState(
history.state,
'',
resolved.pathname + resolved.search + resolved.hash,
);
});
},
});