This is the most valuable file in the course and it is eighty-six lines. It watches the store, waits for the user to stop moving, runs five queries in parallel, and refuses to show you an answer that has been overtaken by a newer one. By the end, eight rapid clicks cost exactly one query — a number you will measure rather than assume. Checkpoint: ui-02.

The one thing signals don’t do

Computeds are pull-based: they recalculate when someone reads them. That is exactly right for derived values and useless for side effects. Nobody reads “run a database query” — it has to happen because something changed, which is push, not pull.

The signals proposal covers this with Signal.subtle.Watcher. The subtle namespace is a deliberate warning: it is the low-level primitive that framework authors build effects on, and using it directly means handling the sharp edges yourself. There are exactly two, and both are in the next fifteen lines.

Watch one computed, not six signals

Rather than watching seven signals, collect everything a query depends on into one computed and watch that:

const params = new Signal.Computed(() => ({
    ready: dbReady.get(),
    range: dateRange.get(),
    fruits: selectedFruits.get(),
    page: currentPage.get(),
    size: pageSize.get(),
    sort: sortColumn.get(),
    direction: sortDirection.get(),
    view: tableView.get(),
}));

One dependency to manage, and a single object that is literally the query’s input. Adding a filter later means adding a line here and nowhere else.

Now the watcher, and both sharp edges:

const watcher = new Signal.subtle.Watcher(() => {
    // Notification fires synchronously inside the mutation; defer, re-arm,
    // then debounce the actual query.
    queueMicrotask(() => {
        watcher.watch();
        params.get();
        clearTimeout(timer);
        timer = setTimeout(run, debounceMs);
    });
});

watcher.watch(params);
params.get(); // prime the computed so the watcher has something to observe

The callback runs synchronously, inside the .set() that triggered it. You are in the middle of someone else’s state mutation; reading signals or starting work there is how you get inconsistent reads and re-entrancy. queueMicrotask gets you out to a clean stack.

A watcher fires once and disarms. After notifying, it stops watching until you call .watch() again — which prevents notification storms and means forgetting the re-arm gives you an app that updates exactly once and then goes silent. That is a memorable afternoon.

The params.get() on the last line is the third subtlety: a computed that has never been read has no dependencies yet, because dependencies are recorded during evaluation. Prime it or the watcher observes nothing.

Waiting for the user to stop

clearTimeout(timer) then setTimeout(run, 150) is the entire debounce. Every change cancels the pending run and schedules a new one, so a burst of changes produces one query 150ms after the last of them.

Without it, dragging a date slider fires a query per input event — dozens during one drag, each doing real work in the engine, all but the last one discarded. 150ms is short enough to feel immediate and long enough to swallow a drag.

The guard that makes staleness impossible

Debouncing reduces queries; it does not order them. Two queries can still be in flight when a slow one was issued first, and a slow query returning after a fast one would overwrite newer data with older data. The fix is a counter:

const id = ++requestId;
queryPending.set(true);
try {
    const t0 = performance.now();
    const [[totalRows, countMs], [rows, rowsMs], [aggregates, aggregateMs], daily, pivot] = await Promise.all([
        timed(db.countRows(range.startDate, range.endDate, fruits)),
        timed(db.queryPaginated(range.startDate, range.endDate, fruits, size, (page - 1) * size, sort, direction)),
        timed(db.aggregateForChart(range.startDate, range.endDate, fruits)),
        db.aggregateByDate(range.startDate, range.endDate, fruits),
        view === 'summary'
            ? db.pivotByMonth(range.startDate, range.endDate, fruits)
            : Promise.resolve(null),
    ]);
    if (id === requestId) {
        queryResult.set({ totalRows, rows, aggregates, daily, pivot });
        // …timings and lastSql…
    }
} finally {
    // Only the latest in-flight request clears the pending flag
    if (id === requestId) queryPending.set(false);
}

Each run takes a ticket. When it finishes it checks whether it is still the newest; if not, it drops its result on the floor. This is switchMap semantics from reactive programming, in three lines and no library.

Note that the check appears twice. Forgetting it in the finally gives you a spinner that stops while a query is still running — a superseded request would clear a flag it no longer owns.

Promise.all over five calls is the other half of the performance story: five round trips into the worker, concurrent, bounded by the slowest rather than the sum. The pivot slot resolves to null unless the summary view is active, so the most expensive query never runs when nothing displays it.

Rendering still needs a watcher

The runner fills queryResult, but the scaffolding is plain DOM and does not know that happened. So it needs its own watcher — the same computed, microtask, re-arm dance, wrapped around render().

That duplication is the point of this lesson’s ending. You now have the pattern written twice: once for querying, once for painting. The second one is not supposed to be your job, and in the next lesson it is deleted entirely — Lit components watch the signals they read, and there is no third copy.

Build it

File Action What goes in it
src/state/query-runner.ts write The params computed, the watcher with its microtask re-arm, the debounce, the request-id guard, Promise.all of the five calls, and timing capture
src/index.ts modify Delete refresh() and every void refresh(); call startQueryRunner(); add a temporary repaint watcher; read lastSql from the store

Done when: clicking Next eight times as fast as you can advances eight pages and runs exactly one query — check queryStats.get().queries in the dev server console before and after.

Answer key: ui-01...ui-02.

Challenge: remove the guard and force the race

Delete both id === requestId checks, then make the runner unfair: await new Promise(r => setTimeout(r, Math.random() * 800)) before the queries. Click through pages quickly and watch the table land on a page you have already left. Restore the guard and the same abuse produces correct results every time. A race you have watched happen is a race you will design against.

Next lesson: components arrive, and fifty lines of imperative DOM go in one diff.