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

src/components/demos/ActiveSearchDemo.astro
---
/** 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

src/components/SearchResults.astro
---
/** 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

src/pages/api/search.ts
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

packages/astro-datastar/src/plugins/replace-url.ts
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,
      );
    });
  },
});